mirror of
https://github.com/home-assistant/core.git
synced 2026-01-04 14:55:39 +01:00
Compare commits
115 Commits
test-voice
...
2025.4.0b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14eed1778b | ||
|
|
049aaa7e8b | ||
|
|
35717e8216 | ||
|
|
2a081abc18 | ||
|
|
b7f29c7358 | ||
|
|
3bb6373df5 | ||
|
|
e1b4edec50 | ||
|
|
147bee57e1 | ||
|
|
fcdaea64da | ||
|
|
d1512d46be | ||
|
|
0be7db6270 | ||
|
|
2af0282725 | ||
|
|
ff458c8417 | ||
|
|
cc93152ff0 | ||
|
|
9965f01609 | ||
|
|
e9c76ce694 | ||
|
|
58ab7d350d | ||
|
|
e4d6e20ebd | ||
|
|
45e273897a | ||
|
|
d9ec7142d7 | ||
|
|
e162499267 | ||
|
|
67f21429e3 | ||
|
|
a0563f06c9 | ||
|
|
e7c4fdc8bb | ||
|
|
c490e350bc | ||
|
|
e11409ef99 | ||
|
|
5c8e415a76 | ||
|
|
e795fb9497 | ||
|
|
d0afabb85c | ||
|
|
4f3e8e9b94 | ||
|
|
46c1cbbc9c | ||
|
|
8d9a4ea278 | ||
|
|
22c83e2393 | ||
|
|
c83a75f6f9 | ||
|
|
841c727112 | ||
|
|
d8c9655bfd | ||
|
|
942ed89cc4 | ||
|
|
a1fe6b9cf3 | ||
|
|
2567181cc2 | ||
|
|
028e4f6029 | ||
|
|
b82e1a9bef | ||
|
|
438f226c31 | ||
|
|
2f139e3cb1 | ||
|
|
5d75e96fbf | ||
|
|
dcf2ec5c37 | ||
|
|
2431e1ba98 | ||
|
|
4ead108c15 | ||
|
|
ec8363fa49 | ||
|
|
e7ff0a3f8b | ||
|
|
f4c0eb4189 | ||
|
|
b1ee5a76e1 | ||
|
|
6b9e8c301b | ||
|
|
89c3266c7e | ||
|
|
cff0a632e8 | ||
|
|
e04d8557ae | ||
|
|
ca6286f241 | ||
|
|
35bcc9d5af | ||
|
|
25b45ce867 | ||
|
|
d568209bd5 | ||
|
|
8a43e8af9e | ||
|
|
785e5b2c16 | ||
|
|
2e3853dd7d | ||
|
|
fe99c39e25 | ||
|
|
c8ab5bc796 | ||
|
|
4f3b36c2e1 | ||
|
|
222d89a84c | ||
|
|
eb3cb0e0c7 | ||
|
|
69c8f4fbb6 | ||
|
|
3bcf1c942c | ||
|
|
220aaf93c6 | ||
|
|
febc455bc5 | ||
|
|
57f65c205e | ||
|
|
6e56486294 | ||
|
|
3a1e1684ea | ||
|
|
9d63a49812 | ||
|
|
7a4ca6dcdc | ||
|
|
1622638f10 | ||
|
|
0de3549e6e | ||
|
|
63d4efda2e | ||
|
|
b5910dd7d6 | ||
|
|
c974285490 | ||
|
|
8db91623ec | ||
|
|
3eda5333b0 | ||
|
|
3aaf859985 | ||
|
|
dba4c197c8 | ||
|
|
f842640249 | ||
|
|
aa493ff97d | ||
|
|
21d5885ded | ||
|
|
054b3bb26c | ||
|
|
77bf977d63 | ||
|
|
3f68e327f3 | ||
|
|
82db1ffd12 | ||
|
|
06f6c86ba5 | ||
|
|
e3f2f30395 | ||
|
|
4a6d2c91da | ||
|
|
d7de8c5f68 | ||
|
|
7bcba2b639 | ||
|
|
53990f8fad | ||
|
|
ed7c864869 | ||
|
|
74ff40e253 | ||
|
|
57d02d7a17 | ||
|
|
043603c9be | ||
|
|
e10801af80 | ||
|
|
f4fa4056ac | ||
|
|
208e8ae451 | ||
|
|
02f8322ac1 | ||
|
|
e8158234a9 | ||
|
|
7848c3cd79 | ||
|
|
2d8420b656 | ||
|
|
63a86763b1 | ||
|
|
b5117eb071 | ||
|
|
f0c774a4bd | ||
|
|
8bedf97382 | ||
|
|
65c05d66c0 | ||
|
|
1cb4332a3c |
@@ -119,6 +119,7 @@ homeassistant.components.bluetooth_adapters.*
|
||||
homeassistant.components.bluetooth_tracker.*
|
||||
homeassistant.components.bmw_connected_drive.*
|
||||
homeassistant.components.bond.*
|
||||
homeassistant.components.bosch_alarm.*
|
||||
homeassistant.components.braviatv.*
|
||||
homeassistant.components.bring.*
|
||||
homeassistant.components.brother.*
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -216,6 +216,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/bmw_connected_drive/ @gerard33 @rikroe
|
||||
/homeassistant/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
|
||||
/tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
|
||||
/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900
|
||||
/tests/components/bosch_alarm/ @mag1024 @sanjay900
|
||||
/homeassistant/components/bosch_shc/ @tschamm
|
||||
/tests/components/bosch_shc/ @tschamm
|
||||
/homeassistant/components/braviatv/ @bieniu @Drafteed
|
||||
|
||||
2
Dockerfile
generated
2
Dockerfile
generated
@@ -31,7 +31,7 @@ RUN \
|
||||
&& go2rtc --version
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.6.8
|
||||
RUN pip3 install uv==0.6.10
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -19,4 +19,4 @@ labels:
|
||||
org.opencontainers.image.authors: The Home Assistant Authors
|
||||
org.opencontainers.image.url: https://www.home-assistant.io/
|
||||
org.opencontainers.image.documentation: https://www.home-assistant.io/docs/
|
||||
org.opencontainers.image.licenses: Apache License 2.0
|
||||
org.opencontainers.image.licenses: Apache-2.0
|
||||
|
||||
@@ -859,14 +859,8 @@ async def _async_set_up_integrations(
|
||||
integrations, all_integrations = await _async_resolve_domains_and_preload(
|
||||
hass, config
|
||||
)
|
||||
# Detect all cycles
|
||||
integrations_after_dependencies = (
|
||||
await loader.resolve_integrations_after_dependencies(
|
||||
hass, all_integrations.values(), set(all_integrations)
|
||||
)
|
||||
)
|
||||
all_domains = set(integrations_after_dependencies)
|
||||
domains = set(integrations) & all_domains
|
||||
all_domains = set(all_integrations)
|
||||
domains = set(integrations)
|
||||
|
||||
_LOGGER.info(
|
||||
"Domains to be set up: %s | %s",
|
||||
@@ -874,8 +868,6 @@ async def _async_set_up_integrations(
|
||||
all_domains - domains,
|
||||
)
|
||||
|
||||
async_set_domains_to_be_loaded(hass, all_domains)
|
||||
|
||||
# Initialize recorder
|
||||
if "recorder" in all_domains:
|
||||
recorder.async_initialize_recorder(hass)
|
||||
@@ -908,12 +900,24 @@ async def _async_set_up_integrations(
|
||||
stage_dep_domains_unfiltered = {
|
||||
dep
|
||||
for domain in stage_domains
|
||||
for dep in integrations_after_dependencies[domain]
|
||||
for dep in all_integrations[domain].all_dependencies
|
||||
if dep not in stage_domains
|
||||
}
|
||||
stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
|
||||
|
||||
stage_all_domains = stage_domains | stage_dep_domains
|
||||
stage_all_integrations = {
|
||||
domain: all_integrations[domain] for domain in stage_all_domains
|
||||
}
|
||||
# Detect all cycles
|
||||
stage_integrations_after_dependencies = (
|
||||
await loader.resolve_integrations_after_dependencies(
|
||||
hass, stage_all_integrations.values(), stage_all_domains
|
||||
)
|
||||
)
|
||||
stage_all_domains = set(stage_integrations_after_dependencies)
|
||||
stage_domains &= stage_all_domains
|
||||
stage_dep_domains &= stage_all_domains
|
||||
|
||||
_LOGGER.info(
|
||||
"Setting up stage %s: %s | %s\nDependencies: %s | %s",
|
||||
@@ -924,6 +928,8 @@ async def _async_set_up_integrations(
|
||||
stage_dep_domains_unfiltered - stage_dep_domains,
|
||||
)
|
||||
|
||||
async_set_domains_to_be_loaded(hass, stage_all_domains)
|
||||
|
||||
if timeout is None:
|
||||
await _async_setup_multi_components(hass, stage_all_domains, config)
|
||||
continue
|
||||
|
||||
5
homeassistant/brands/bosch.json
Normal file
5
homeassistant/brands/bosch.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "bosch",
|
||||
"name": "Bosch",
|
||||
"integrations": ["bosch_alarm", "bosch_shc", "home_connect"]
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"geography_by_coords": {
|
||||
"title": "Configure a Geography",
|
||||
"title": "Configure a geography",
|
||||
"description": "Use the AirVisual cloud API to monitor a latitude/longitude.",
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
@@ -56,12 +56,12 @@
|
||||
"sensor": {
|
||||
"pollutant_label": {
|
||||
"state": {
|
||||
"co": "Carbon Monoxide",
|
||||
"n2": "Nitrogen Dioxide",
|
||||
"co": "Carbon monoxide",
|
||||
"n2": "Nitrogen dioxide",
|
||||
"o3": "Ozone",
|
||||
"p1": "PM10",
|
||||
"p2": "PM2.5",
|
||||
"s2": "Sulfur Dioxide"
|
||||
"s2": "Sulfur dioxide"
|
||||
}
|
||||
},
|
||||
"pollutant_level": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.6.10"]
|
||||
"requirements": ["aioairzone-cloud==0.6.11"]
|
||||
}
|
||||
|
||||
@@ -1438,7 +1438,7 @@ class AlexaModeController(AlexaCapability):
|
||||
# Fan preset_mode
|
||||
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}":
|
||||
mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None)
|
||||
if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, None):
|
||||
if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, ()):
|
||||
return f"{fan.ATTR_PRESET_MODE}.{mode}"
|
||||
|
||||
# Humidifier mode
|
||||
|
||||
@@ -240,6 +240,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDGUSTMPH,
|
||||
|
||||
@@ -609,6 +609,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
translation_key="wind_direction",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_WINDDIR_AVG10M,
|
||||
|
||||
@@ -6,7 +6,11 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -98,6 +102,7 @@ def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] |
|
||||
DEGREE,
|
||||
"mdi:compass",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
]
|
||||
return None
|
||||
@@ -178,6 +183,7 @@ class ArwnSensor(SensorEntity):
|
||||
units: str,
|
||||
icon: str | None = None,
|
||||
device_class: SensorDeviceClass | None = None,
|
||||
state_class: SensorStateClass | None = None,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.entity_id = _slug(name)
|
||||
@@ -188,6 +194,7 @@ class ArwnSensor(SensorEntity):
|
||||
self._attr_native_unit_of_measurement = units
|
||||
self._attr_icon = icon
|
||||
self._attr_device_class = device_class
|
||||
self._attr_state_class = state_class
|
||||
|
||||
def set_event(self, event: dict[str, Any]) -> None:
|
||||
"""Update the sensor with the most recent event."""
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Base class for assist satellite entities."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -15,6 +17,8 @@ from .const import (
|
||||
CONNECTION_TEST_DATA,
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
PREANNOUNCE_FILENAME,
|
||||
PREANNOUNCE_URL,
|
||||
AssistSatelliteEntityFeature,
|
||||
)
|
||||
from .entity import (
|
||||
@@ -56,7 +60,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Optional("message"): str,
|
||||
vol.Optional("media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): vol.Any(str, None),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("message", "media_id"),
|
||||
@@ -71,7 +75,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Optional("start_message"): str,
|
||||
vol.Optional("start_media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): vol.Any(str, None),
|
||||
vol.Optional("extra_system_prompt"): str,
|
||||
}
|
||||
),
|
||||
@@ -84,6 +88,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async_register_websocket_api(hass)
|
||||
hass.http.register_view(ConnectionTestView())
|
||||
|
||||
# Default preannounce sound
|
||||
await hass.http.async_register_static_paths(
|
||||
[
|
||||
StaticPathConfig(
|
||||
PREANNOUNCE_URL, str(Path(__file__).parent / PREANNOUNCE_FILENAME)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ CONNECTION_TEST_DATA: HassKey[dict[str, asyncio.Event]] = HassKey(
|
||||
f"{DOMAIN}_connection_tests"
|
||||
)
|
||||
|
||||
PREANNOUNCE_FILENAME = "preannounce.mp3"
|
||||
PREANNOUNCE_URL = f"/api/assist_satellite/static/{PREANNOUNCE_FILENAME}"
|
||||
|
||||
|
||||
class AssistSatelliteEntityFeature(IntFlag):
|
||||
"""Supported features of Assist satellite entity."""
|
||||
|
||||
@@ -28,7 +28,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import chat_session, entity
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
|
||||
from .const import AssistSatelliteEntityFeature
|
||||
from .const import PREANNOUNCE_URL, AssistSatelliteEntityFeature
|
||||
from .errors import AssistSatelliteError, SatelliteBusyError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -180,7 +180,7 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
self,
|
||||
message: str | None = None,
|
||||
media_id: str | None = None,
|
||||
preannounce_media_id: str | None = None,
|
||||
preannounce_media_id: str | None = PREANNOUNCE_URL,
|
||||
) -> None:
|
||||
"""Play and show an announcement on the satellite.
|
||||
|
||||
@@ -190,7 +190,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
If media_id is provided, it is played directly. It is possible
|
||||
to omit the message and the satellite will not show any text.
|
||||
|
||||
If preannounce_media_id is provided, it is played before the announcement.
|
||||
If preannounce_media_id is provided, it overrides the default sound.
|
||||
If preannounce_media_id is None, no sound is played.
|
||||
|
||||
Calls async_announce with message and media id.
|
||||
"""
|
||||
@@ -228,7 +229,7 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
start_message: str | None = None,
|
||||
start_media_id: str | None = None,
|
||||
extra_system_prompt: str | None = None,
|
||||
preannounce_media_id: str | None = None,
|
||||
preannounce_media_id: str | None = PREANNOUNCE_URL,
|
||||
) -> None:
|
||||
"""Start a conversation from the satellite.
|
||||
|
||||
@@ -239,6 +240,7 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
to omit the message and the satellite will not show any text.
|
||||
|
||||
If preannounce_media_id is provided, it is played before the announcement.
|
||||
If preannounce_media_id is None, no sound is played.
|
||||
|
||||
Calls async_start_conversation.
|
||||
"""
|
||||
|
||||
BIN
homeassistant/components/assist_satellite/preannounce.mp3
Normal file
BIN
homeassistant/components/assist_satellite/preannounce.mp3
Normal file
Binary file not shown.
@@ -8,6 +8,7 @@ announce:
|
||||
message:
|
||||
required: false
|
||||
example: "Time to wake up!"
|
||||
default: ""
|
||||
selector:
|
||||
text:
|
||||
media_id:
|
||||
@@ -28,6 +29,7 @@ start_conversation:
|
||||
start_message:
|
||||
required: false
|
||||
example: "You left the lights on in the living room. Turn them off?"
|
||||
default: ""
|
||||
selector:
|
||||
text:
|
||||
start_media_id:
|
||||
|
||||
@@ -198,7 +198,8 @@ async def websocket_test_connection(
|
||||
|
||||
hass.async_create_background_task(
|
||||
satellite.async_internal_announce(
|
||||
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}"
|
||||
media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}",
|
||||
preannounce_media_id=None,
|
||||
),
|
||||
f"assist_satellite_connection_test_{msg['entity_id']}",
|
||||
)
|
||||
|
||||
62
homeassistant/components/bosch_alarm/__init__.py
Normal file
62
homeassistant/components/bosch_alarm/__init__.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""The Bosch Alarm integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ssl import SSLError
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL]
|
||||
|
||||
type BoschAlarmConfigEntry = ConfigEntry[Panel]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
|
||||
"""Set up Bosch Alarm from a config entry."""
|
||||
|
||||
panel = Panel(
|
||||
host=entry.data[CONF_HOST],
|
||||
port=entry.data[CONF_PORT],
|
||||
automation_code=entry.data.get(CONF_PASSWORD),
|
||||
installer_or_user_code=entry.data.get(
|
||||
CONF_INSTALLER_CODE, entry.data.get(CONF_USER_CODE)
|
||||
),
|
||||
)
|
||||
try:
|
||||
await panel.connect()
|
||||
except (PermissionError, ValueError) as err:
|
||||
await panel.disconnect()
|
||||
raise ConfigEntryNotReady from err
|
||||
except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err:
|
||||
await panel.disconnect()
|
||||
raise ConfigEntryNotReady("Connection failed") from err
|
||||
|
||||
entry.runtime_data = panel
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
manufacturer="Bosch Security Systems",
|
||||
model=panel.model,
|
||||
sw_version=panel.firmware_version,
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await entry.runtime_data.disconnect()
|
||||
return unload_ok
|
||||
109
homeassistant/components/bosch_alarm/alarm_control_panel.py
Normal file
109
homeassistant/components/bosch_alarm/alarm_control_panel.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Support for Bosch Alarm Panel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
AlarmControlPanelEntityFeature,
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BoschAlarmConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up control panels for each area."""
|
||||
panel = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AreaAlarmControlPanel(
|
||||
panel,
|
||||
area_id,
|
||||
config_entry.unique_id or config_entry.entry_id,
|
||||
)
|
||||
for area_id in panel.areas
|
||||
)
|
||||
|
||||
|
||||
class AreaAlarmControlPanel(AlarmControlPanelEntity):
|
||||
"""An alarm control panel entity for a bosch alarm panel."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_features = (
|
||||
AlarmControlPanelEntityFeature.ARM_HOME
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
)
|
||||
_attr_code_arm_required = False
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
|
||||
"""Initialise a Bosch Alarm control panel entity."""
|
||||
self.panel = panel
|
||||
self._area = panel.areas[area_id]
|
||||
self._area_id = area_id
|
||||
self._attr_unique_id = f"{unique_id}_area_{area_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
name=self._area.name,
|
||||
manufacturer="Bosch Security Systems",
|
||||
via_device=(
|
||||
DOMAIN,
|
||||
unique_id,
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||
"""Return the state of the alarm."""
|
||||
if self._area.is_triggered():
|
||||
return AlarmControlPanelState.TRIGGERED
|
||||
if self._area.is_disarmed():
|
||||
return AlarmControlPanelState.DISARMED
|
||||
if self._area.is_arming():
|
||||
return AlarmControlPanelState.ARMING
|
||||
if self._area.is_pending():
|
||||
return AlarmControlPanelState.PENDING
|
||||
if self._area.is_part_armed():
|
||||
return AlarmControlPanelState.ARMED_HOME
|
||||
if self._area.is_all_armed():
|
||||
return AlarmControlPanelState.ARMED_AWAY
|
||||
return None
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Disarm this panel."""
|
||||
await self.panel.area_disarm(self._area_id)
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
await self.panel.area_arm_part(self._area_id)
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
await self.panel.area_arm_all(self._area_id)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.panel.connection_status()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity attached to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._area.status_observer.attach(self.schedule_update_ha_state)
|
||||
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
self._area.status_observer.detach(self.schedule_update_ha_state)
|
||||
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
|
||||
165
homeassistant/components/bosch_alarm/config_flow.py
Normal file
165
homeassistant/components/bosch_alarm/config_flow.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Config flow for Bosch Alarm integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_CODE,
|
||||
CONF_HOST,
|
||||
CONF_MODEL,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PORT, default=7700): cv.positive_int,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_AUTH_DATA_SCHEMA_SOLUTION = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USER_CODE): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_AUTH_DATA_SCHEMA_AMAX = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_INSTALLER_CODE): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_AUTH_DATA_SCHEMA_BG = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_INIT_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_CODE): str})
|
||||
|
||||
|
||||
async def try_connect(
|
||||
data: dict[str, Any], load_selector: int = 0
|
||||
) -> tuple[str, int | None]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
panel = Panel(
|
||||
host=data[CONF_HOST],
|
||||
port=data[CONF_PORT],
|
||||
automation_code=data.get(CONF_PASSWORD),
|
||||
installer_or_user_code=data.get(CONF_INSTALLER_CODE, data.get(CONF_USER_CODE)),
|
||||
)
|
||||
|
||||
try:
|
||||
await panel.connect(load_selector)
|
||||
finally:
|
||||
await panel.disconnect()
|
||||
|
||||
return (panel.model, panel.serial_number)
|
||||
|
||||
|
||||
class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Bosch Alarm."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Init config flow."""
|
||||
|
||||
self._data: dict[str, Any] = {}
|
||||
|
||||
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:
|
||||
try:
|
||||
# Use load_selector = 0 to fetch the panel model without authentication.
|
||||
(model, serial) = await try_connect(user_input, 0)
|
||||
except (
|
||||
OSError,
|
||||
ConnectionRefusedError,
|
||||
ssl.SSLError,
|
||||
asyncio.exceptions.TimeoutError,
|
||||
) as e:
|
||||
_LOGGER.error("Connection Error: %s", e)
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
self._data = user_input
|
||||
self._data[CONF_MODEL] = model
|
||||
return await self.async_step_auth()
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_auth(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the auth step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
# Each model variant requires a different authentication flow
|
||||
if "Solution" in self._data[CONF_MODEL]:
|
||||
schema = STEP_AUTH_DATA_SCHEMA_SOLUTION
|
||||
elif "AMAX" in self._data[CONF_MODEL]:
|
||||
schema = STEP_AUTH_DATA_SCHEMA_AMAX
|
||||
else:
|
||||
schema = STEP_AUTH_DATA_SCHEMA_BG
|
||||
|
||||
if user_input is not None:
|
||||
self._data.update(user_input)
|
||||
try:
|
||||
(model, serial_number) = await try_connect(
|
||||
self._data, Panel.LOAD_EXTENDED_INFO
|
||||
)
|
||||
except (PermissionError, ValueError) as e:
|
||||
errors["base"] = "invalid_auth"
|
||||
_LOGGER.error("Authentication Error: %s", e)
|
||||
except (
|
||||
OSError,
|
||||
ConnectionRefusedError,
|
||||
ssl.SSLError,
|
||||
TimeoutError,
|
||||
) as e:
|
||||
_LOGGER.error("Connection Error: %s", e)
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if serial_number:
|
||||
await self.async_set_unique_id(str(serial_number))
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]})
|
||||
return self.async_create_entry(title=f"Bosch {model}", data=self._data)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="auth",
|
||||
data_schema=self.add_suggested_values_to_schema(schema, user_input),
|
||||
errors=errors,
|
||||
)
|
||||
6
homeassistant/components/bosch_alarm/const.py
Normal file
6
homeassistant/components/bosch_alarm/const.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Constants for the Bosch Alarm integration."""
|
||||
|
||||
DOMAIN = "bosch_alarm"
|
||||
HISTORY_ATTR = "history"
|
||||
CONF_INSTALLER_CODE = "installer_code"
|
||||
CONF_USER_CODE = "user_code"
|
||||
11
homeassistant/components/bosch_alarm/manifest.json
Normal file
11
homeassistant/components/bosch_alarm/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "bosch_alarm",
|
||||
"name": "Bosch Alarm",
|
||||
"codeowners": ["@mag1024", "@sanjay900"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["bosch-alarm-mode2==0.4.3"]
|
||||
}
|
||||
84
homeassistant/components/bosch_alarm/quality_scale.yaml
Normal file
84
homeassistant/components/bosch_alarm/quality_scale.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions defined
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
No polling
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
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: |
|
||||
No custom actions are defined.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
No repairs
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not make any HTTP requests.
|
||||
strict-typing: done
|
||||
36
homeassistant/components/bosch_alarm/strings.json
Normal file
36
homeassistant/components/bosch_alarm/strings.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Bosch alarm panel",
|
||||
"port": "The port used to connect to your Bosch alarm panel. This is usually 7700"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"installer_code": "Installer code",
|
||||
"user_code": "User code"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The Mode 2 automation code from your panel",
|
||||
"installer_code": "The installer code from your panel",
|
||||
"user_code": "The user code from your panel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=DEGREE,
|
||||
icon="mdi:compass-outline",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="pressure",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["casttube", "pychromecast"],
|
||||
"requirements": ["PyChromecast==14.0.6"],
|
||||
"requirements": ["PyChromecast==14.0.7"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_googlecast._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from hass_nabucasa import Cloud, CloudError
|
||||
from hass_nabucasa.api import CloudApiNonRetryableError
|
||||
from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError
|
||||
from hass_nabucasa.cloud_api import (
|
||||
FilesHandlerListEntry,
|
||||
async_files_delete_file,
|
||||
@@ -120,6 +121,8 @@ class CloudBackupAgent(BackupAgent):
|
||||
"""
|
||||
if not backup.protected:
|
||||
raise BackupAgentError("Cloud backups must be protected")
|
||||
if self._cloud.subscription_expired:
|
||||
raise BackupAgentError("Cloud subscription has expired")
|
||||
|
||||
size = backup.size
|
||||
try:
|
||||
@@ -152,6 +155,13 @@ class CloudBackupAgent(BackupAgent):
|
||||
) from err
|
||||
raise BackupAgentError(f"Failed to upload backup {err}") from err
|
||||
except CloudError as err:
|
||||
if (
|
||||
isinstance(err, CloudApiError)
|
||||
and isinstance(err.orig_exc, ClientResponseError)
|
||||
and err.orig_exc.status == HTTPStatus.FORBIDDEN
|
||||
and self._cloud.subscription_expired
|
||||
):
|
||||
raise BackupAgentError("Cloud subscription has expired") from err
|
||||
if tries == _RETRY_LIMIT:
|
||||
raise BackupAgentError(f"Failed to upload backup {err}") from err
|
||||
tries += 1
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to Cloudflare",
|
||||
"description": "This integration requires an API Token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.",
|
||||
"description": "This integration requires an API token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.",
|
||||
"data": {
|
||||
"api_token": "[%key:common::config_flow::data::api_token%]"
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
"title": "Choose the Zone to Update",
|
||||
"title": "Choose the zone to update",
|
||||
"data": {
|
||||
"zone": "Zone"
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"title": "Choose the Records to Update",
|
||||
"title": "Choose the records to update",
|
||||
"data": {
|
||||
"records": "Records"
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
"services": {
|
||||
"update_records": {
|
||||
"name": "Update records",
|
||||
"description": "Manually trigger update to Cloudflare records."
|
||||
"description": "Manually triggers an update of Cloudflare records."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ ALARM_ACTIONS: dict[str, str] = {
|
||||
|
||||
|
||||
ALARM_AREA_ARMED_STATUS: dict[str, int] = {
|
||||
DISABLE: 0,
|
||||
HOME_P1: 1,
|
||||
HOME_P2: 2,
|
||||
NIGHT: 3,
|
||||
@@ -128,20 +129,38 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
|
||||
AlarmAreaState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
|
||||
}.get(self._area.human_status)
|
||||
|
||||
async def _async_update_state(self, area_state: AlarmAreaState, armed: int) -> None:
|
||||
"""Update state after action."""
|
||||
self._area.human_status = area_state
|
||||
self._area.armed = armed
|
||||
await self.async_update_ha_state()
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
if code != str(self._api.device_pin):
|
||||
return
|
||||
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE])
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
|
||||
)
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY])
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
|
||||
)
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME])
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
|
||||
)
|
||||
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Send arm night command."""
|
||||
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT])
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
|
||||
)
|
||||
|
||||
@@ -9,3 +9,5 @@ _LOGGER = logging.getLogger(__package__)
|
||||
DOMAIN = "comelit"
|
||||
DEFAULT_PORT = 80
|
||||
DEVICE_TYPE_LIST = [BRIDGE, VEDO]
|
||||
|
||||
SCAN_INTERVAL = 5
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .const import _LOGGER, DOMAIN, SCAN_INTERVAL
|
||||
|
||||
type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator]
|
||||
|
||||
@@ -53,7 +53,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
|
||||
logger=_LOGGER,
|
||||
config_entry=entry,
|
||||
name=f"{DOMAIN}-{host}-coordinator",
|
||||
update_interval=timedelta(seconds=5),
|
||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||
)
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
|
||||
@@ -650,7 +650,14 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
if (
|
||||
(maybe_result is None) # first result
|
||||
or (num_matched_entities > best_num_matched_entities)
|
||||
or (
|
||||
# More literal text matched
|
||||
result.text_chunks_matched > maybe_result.text_chunks_matched
|
||||
)
|
||||
or (
|
||||
# More entities matched
|
||||
num_matched_entities > best_num_matched_entities
|
||||
)
|
||||
or (
|
||||
# Fewer unmatched entities
|
||||
(num_matched_entities == best_num_matched_entities)
|
||||
@@ -662,16 +669,6 @@ class DefaultAgent(ConversationEntity):
|
||||
and (num_unmatched_entities == best_num_unmatched_entities)
|
||||
and (num_unmatched_ranges > best_num_unmatched_ranges)
|
||||
)
|
||||
or (
|
||||
# More literal text matched
|
||||
(num_matched_entities == best_num_matched_entities)
|
||||
and (num_unmatched_entities == best_num_unmatched_entities)
|
||||
and (num_unmatched_ranges == best_num_unmatched_ranges)
|
||||
and (
|
||||
result.text_chunks_matched
|
||||
> maybe_result.text_chunks_matched
|
||||
)
|
||||
)
|
||||
or (
|
||||
# Prefer match failures with entities
|
||||
(result.text_chunks_matched == maybe_result.text_chunks_matched)
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from hassil.recognize import MISSING_ENTITY, RecognizeResult
|
||||
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
|
||||
from home_assistant_intents import get_language_scores
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import http, websocket_api
|
||||
@@ -38,6 +40,7 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_list_agents)
|
||||
websocket_api.async_register_command(hass, websocket_list_sentences)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -336,6 +339,36 @@ def _get_unmatched_slots(
|
||||
return unmatched_slots
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/agent/homeassistant/language_scores",
|
||||
vol.Optional("language"): str,
|
||||
vol.Optional("country"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_hass_agent_language_scores(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get support scores per language."""
|
||||
language = msg.get("language", hass.config.language)
|
||||
country = msg.get("country", hass.config.country)
|
||||
|
||||
scores = await hass.async_add_executor_job(get_language_scores)
|
||||
matching_langs = language_util.matches(language, scores.keys(), country=country)
|
||||
preferred_lang = matching_langs[0] if matching_langs else language
|
||||
result = {
|
||||
"languages": {
|
||||
lang_key: asdict(lang_scores) for lang_key, lang_scores in scores.items()
|
||||
},
|
||||
"preferred_language": preferred_lang,
|
||||
}
|
||||
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
class ConversationProcessView(http.HomeAssistantView):
|
||||
"""View to process text."""
|
||||
|
||||
|
||||
@@ -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.3.23"]
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.28"]
|
||||
}
|
||||
|
||||
@@ -50,10 +50,10 @@ class DukeEnergyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
username = auth["cdp_internal_user_id"].lower()
|
||||
username = auth["internalUserID"].lower()
|
||||
await self.async_set_unique_id(username)
|
||||
self._abort_if_unique_id_configured()
|
||||
email = auth["email"].lower()
|
||||
email = auth["loginEmailAddress"].lower()
|
||||
data = {
|
||||
CONF_EMAIL: email,
|
||||
CONF_USERNAME: username,
|
||||
|
||||
@@ -8,7 +8,11 @@ from aiodukeenergy import DukeEnergy
|
||||
from aiohttp import ClientError
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
|
||||
from homeassistant.components.recorder.models import (
|
||||
StatisticData,
|
||||
StatisticMeanType,
|
||||
StatisticMetaData,
|
||||
)
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
async_add_external_statistics,
|
||||
get_last_statistics,
|
||||
@@ -137,7 +141,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
|
||||
f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}"
|
||||
)
|
||||
consumption_metadata = StatisticMetaData(
|
||||
has_mean=False,
|
||||
mean_type=StatisticMeanType.NONE,
|
||||
has_sum=True,
|
||||
name=f"{name_prefix} Consumption",
|
||||
source=DOMAIN,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["recorder"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/duke_energy",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["aiodukeenergy==0.2.2"]
|
||||
"requirements": ["aiodukeenergy==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.3.1"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.4.0"]
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
key="DEGREE",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
EcoWittSensorTypes.WATT_METERS_SQUARED: SensorEntityDescription(
|
||||
key="WATT_METERS_SQUARED",
|
||||
|
||||
@@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, cast
|
||||
|
||||
from elvia import Elvia, error as ElviaError
|
||||
|
||||
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
|
||||
from homeassistant.components.recorder.models import (
|
||||
StatisticData,
|
||||
StatisticMeanType,
|
||||
StatisticMetaData,
|
||||
)
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
async_add_external_statistics,
|
||||
get_last_statistics,
|
||||
@@ -144,7 +148,7 @@ class ElviaImporter:
|
||||
async_add_external_statistics(
|
||||
hass=self.hass,
|
||||
metadata=StatisticMetaData(
|
||||
has_mean=False,
|
||||
mean_type=StatisticMeanType.NONE,
|
||||
has_sum=True,
|
||||
name=f"{self.metering_point_id} Consumption",
|
||||
source=DOMAIN,
|
||||
|
||||
@@ -168,6 +168,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=DEGREE,
|
||||
value_fn=lambda data: data.conditions.get("wind_bearing", {}).get("value"),
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
ECSensorEntityDescription(
|
||||
key="wind_chill",
|
||||
|
||||
@@ -370,8 +370,10 @@ class EsphomeAssistSatellite(
|
||||
announcement.media_id,
|
||||
)
|
||||
media_id = announcement.media_id
|
||||
if announcement.media_id_source != "tts":
|
||||
# Route non-TTS media through the proxy
|
||||
is_media_tts = announcement.media_id_source == "tts"
|
||||
preannounce_media_id = announcement.preannounce_media_id
|
||||
if (not is_media_tts) or preannounce_media_id:
|
||||
# Route media through the proxy
|
||||
format_to_use: MediaPlayerSupportedFormat | None = None
|
||||
for supported_format in chain(
|
||||
*self.entry_data.media_player_formats.values()
|
||||
@@ -384,22 +386,33 @@ class EsphomeAssistSatellite(
|
||||
assert (self.registry_entry is not None) and (
|
||||
self.registry_entry.device_id is not None
|
||||
)
|
||||
proxy_url = async_create_proxy_url(
|
||||
self.hass,
|
||||
self.registry_entry.device_id,
|
||||
media_id,
|
||||
|
||||
make_proxy_url = partial(
|
||||
async_create_proxy_url,
|
||||
hass=self.hass,
|
||||
device_id=self.registry_entry.device_id,
|
||||
media_format=format_to_use.format,
|
||||
rate=format_to_use.sample_rate or None,
|
||||
channels=format_to_use.num_channels or None,
|
||||
width=format_to_use.sample_bytes or None,
|
||||
)
|
||||
media_id = async_process_play_media_url(self.hass, proxy_url)
|
||||
|
||||
if not is_media_tts:
|
||||
media_id = async_process_play_media_url(
|
||||
self.hass, make_proxy_url(media_url=media_id)
|
||||
)
|
||||
|
||||
if preannounce_media_id:
|
||||
preannounce_media_id = async_process_play_media_url(
|
||||
self.hass, make_proxy_url(media_url=preannounce_media_id)
|
||||
)
|
||||
|
||||
await self.cli.send_voice_assistant_announcement_await_response(
|
||||
media_id,
|
||||
_ANNOUNCEMENT_TIMEOUT_SEC,
|
||||
announcement.message,
|
||||
start_conversation=run_pipeline_after,
|
||||
preannounce_media_id=preannounce_media_id or "",
|
||||
)
|
||||
|
||||
async def handle_pipeline_start(
|
||||
|
||||
@@ -33,6 +33,16 @@ class EsphomeEvent(EsphomeEntity[EventInfo, Event], EventEntity):
|
||||
self._trigger_event(self._state.event_type)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
# Event entities should go available directly
|
||||
# when the device comes online and not wait
|
||||
# for the next data push.
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
platform_async_setup_entry,
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import pyeverlights
|
||||
import voluptuous as vol
|
||||
@@ -84,7 +84,7 @@ class EverLightsLight(LightEntity):
|
||||
api: pyeverlights.EverLights,
|
||||
channel: int,
|
||||
status: dict[str, Any],
|
||||
effects,
|
||||
effects: list[str],
|
||||
) -> None:
|
||||
"""Initialize the light."""
|
||||
self._api = api
|
||||
@@ -106,8 +106,10 @@ class EverLightsLight(LightEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color)
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness)
|
||||
hs_color = cast(
|
||||
tuple[float, float], kwargs.get(ATTR_HS_COLOR, self._attr_hs_color)
|
||||
)
|
||||
brightness = cast(int, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness))
|
||||
effect = kwargs.get(ATTR_EFFECT)
|
||||
|
||||
if effect is not None:
|
||||
@@ -116,7 +118,7 @@ class EverLightsLight(LightEntity):
|
||||
rgb = color_int_to_rgb(colors[0])
|
||||
hsv = color_util.color_RGB_to_hsv(*rgb)
|
||||
hs_color = hsv[:2]
|
||||
brightness = hsv[2] / 100 * 255
|
||||
brightness = round(hsv[2] / 100 * 255)
|
||||
|
||||
else:
|
||||
rgb = color_util.color_hsv_to_RGB(
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from collections import namedtuple
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from fints.client import FinTS3PinTanClient
|
||||
from fints.models import SEPAAccount
|
||||
@@ -73,7 +73,7 @@ def setup_platform(
|
||||
credentials = BankCredentials(
|
||||
config[CONF_BIN], config[CONF_USERNAME], config[CONF_PIN], config[CONF_URL]
|
||||
)
|
||||
fints_name = config.get(CONF_NAME, config[CONF_BIN])
|
||||
fints_name = cast(str, config.get(CONF_NAME, config[CONF_BIN]))
|
||||
|
||||
account_config = {
|
||||
acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_ACCOUNTS]
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250306.0"]
|
||||
"requirements": ["home-assistant-frontend==20250328.0"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai # type: ignore[attr-defined]
|
||||
from google.genai import Client
|
||||
from google.genai.errors import APIError, ClientError
|
||||
from requests.exceptions import Timeout
|
||||
import voluptuous as vol
|
||||
@@ -43,7 +43,7 @@ CONF_FILENAMES = "filenames"
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = (Platform.CONVERSATION,)
|
||||
|
||||
type GoogleGenerativeAIConfigEntry = ConfigEntry[genai.Client]
|
||||
type GoogleGenerativeAIConfigEntry = ConfigEntry[Client]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
@@ -139,7 +139,11 @@ async def async_setup_entry(
|
||||
"""Set up Google Generative AI Conversation from a config entry."""
|
||||
|
||||
try:
|
||||
client = genai.Client(api_key=entry.data[CONF_API_KEY])
|
||||
|
||||
def _init_client() -> Client:
|
||||
return Client(api_key=entry.data[CONF_API_KEY])
|
||||
|
||||
client = await hass.async_add_executor_job(_init_client)
|
||||
await client.aio.models.get(
|
||||
model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
"name": "Panel light"
|
||||
},
|
||||
"quiet": {
|
||||
"name": "Quiet"
|
||||
"name": "Quiet mode"
|
||||
},
|
||||
"fresh_air": {
|
||||
"name": "Fresh air"
|
||||
},
|
||||
"xfan": {
|
||||
"name": "XFan"
|
||||
"name": "Xtra fan"
|
||||
},
|
||||
"health_mode": {
|
||||
"name": "Health mode"
|
||||
|
||||
@@ -244,6 +244,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
BSH_DOOR_STATE_LOCKED: False,
|
||||
BSH_DOOR_STATE_OPEN: True,
|
||||
},
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
self._attr_unique_id = f"{appliance.info.ha_id}-Door"
|
||||
@@ -283,7 +284,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
DOMAIN,
|
||||
f"deprecated_binary_common_door_sensor_{self.entity_id}",
|
||||
breaks_in_ha_version="2025.5.0",
|
||||
is_fixable=False,
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_binary_common_door_sensor",
|
||||
translation_placeholders={
|
||||
|
||||
@@ -207,11 +207,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
brightness = round(
|
||||
color_util.brightness_to_value(
|
||||
self._brightness_scale,
|
||||
kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness),
|
||||
cast(int, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness)),
|
||||
)
|
||||
)
|
||||
|
||||
hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color)
|
||||
hs_color = cast(
|
||||
tuple[float, float], kwargs.get(ATTR_HS_COLOR, self._attr_hs_color)
|
||||
)
|
||||
|
||||
rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness)
|
||||
hex_val = color_util.color_rgb_to_hex(*rgb)
|
||||
|
||||
@@ -26,6 +26,11 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
NUMBERS = (
|
||||
NumberEntityDescription(
|
||||
key=SettingKey.BSH_COMMON_ALARM_CLOCK,
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
translation_key="alarm_clock",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
|
||||
@@ -110,17 +110,71 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_time_alarm_clock_in_automations_scripts": {
|
||||
"title": "Deprecated alarm clock entity detected in some automations or scripts",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_time_alarm_clock_in_automations_scripts::title%]",
|
||||
"description": "The alarm clock entity `{entity_id}`, which is deprecated because it's being moved to the `number` platform, is used in the following automations or scripts:\n{items}\n\nPlease, fix this issue by updating your automations or scripts to use the new `number` entity."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_time_alarm_clock": {
|
||||
"title": "Deprecated alarm clock entity",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_time_alarm_clock::title%]",
|
||||
"description": "The alarm clock entity `{entity_id}` is deprecated because it's being moved to the `number` platform.\n\nPlease use the new `number` entity."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_binary_common_door_sensor": {
|
||||
"title": "Deprecated binary door sensor detected in some automations or scripts",
|
||||
"description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_binary_common_door_sensor::title%]",
|
||||
"description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_command_actions": {
|
||||
"title": "The command related actions are deprecated in favor of the new buttons",
|
||||
"description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue."
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_command_actions::title%]",
|
||||
"description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_program_switch_in_automations_scripts": {
|
||||
"title": "Deprecated program switch detected in some automations or scripts",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_program_switch_in_automations_scripts::title%]",
|
||||
"description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_program_switch": {
|
||||
"title": "Deprecated program switch detected in some automations or scripts",
|
||||
"description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue."
|
||||
"title": "Deprecated program switch entities",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_program_switch::title%]",
|
||||
"description": "The switch entity `{entity_id}` and all the other program switches are deprecated.\n\nPlease use the active program select entity instead."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_set_program_and_option_actions": {
|
||||
"title": "The executed action is deprecated",
|
||||
@@ -868,6 +922,9 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"alarm_clock": {
|
||||
"name": "Alarm clock"
|
||||
},
|
||||
"refrigerator_setpoint_temperature": {
|
||||
"name": "Refrigerator temperature"
|
||||
},
|
||||
|
||||
@@ -266,7 +266,10 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM),
|
||||
SwitchEntityDescription(
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
self._attr_name = f"{appliance.info.name} {desc}"
|
||||
self._attr_unique_id = f"{appliance.info.ha_id}-{desc}"
|
||||
@@ -304,11 +307,12 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_program_switch_{self.entity_id}",
|
||||
f"deprecated_program_switch_in_automations_scripts_{self.entity_id}",
|
||||
breaks_in_ha_version="2025.6.0",
|
||||
is_fixable=False,
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_program_switch",
|
||||
translation_key="deprecated_program_switch_in_automations_scripts",
|
||||
translation_placeholders={
|
||||
"entity_id": self.entity_id,
|
||||
"items": "\n".join(items_list),
|
||||
@@ -317,12 +321,34 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Call when entity will be removed from hass."""
|
||||
async_delete_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_program_switch_in_automations_scripts_{self.entity_id}",
|
||||
)
|
||||
async_delete_issue(
|
||||
self.hass, DOMAIN, f"deprecated_program_switch_{self.entity_id}"
|
||||
)
|
||||
|
||||
def create_action_handler_issue(self) -> None:
|
||||
"""Create deprecation issue."""
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_program_switch_{self.entity_id}",
|
||||
breaks_in_ha_version="2025.6.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_program_switch",
|
||||
translation_placeholders={
|
||||
"entity_id": self.entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Start the program."""
|
||||
self.create_action_handler_issue()
|
||||
try:
|
||||
await self.coordinator.client.start_program(
|
||||
self.appliance.info.ha_id, program_key=self.program.key
|
||||
@@ -339,6 +365,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Stop the program."""
|
||||
self.create_action_handler_issue()
|
||||
try:
|
||||
await self.coordinator.client.stop_program(self.appliance.info.ha_id)
|
||||
except HomeConnectError as err:
|
||||
|
||||
@@ -6,10 +6,18 @@ from typing import cast
|
||||
from aiohomeconnect.model import SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.components.time import TimeEntity, TimeEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import DOMAIN
|
||||
@@ -23,6 +31,7 @@ TIME_ENTITIES = (
|
||||
TimeEntityDescription(
|
||||
key=SettingKey.BSH_COMMON_ALARM_CLOCK,
|
||||
translation_key="alarm_clock",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -67,8 +76,78 @@ def time_to_seconds(t: time) -> int:
|
||||
class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity):
|
||||
"""Time setting class for Home Connect."""
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if self.bsh_key == SettingKey.BSH_COMMON_ALARM_CLOCK:
|
||||
automations = automations_with_entity(self.hass, self.entity_id)
|
||||
scripts = scripts_with_entity(self.hass, self.entity_id)
|
||||
items = automations + scripts
|
||||
if not items:
|
||||
return
|
||||
|
||||
entity_reg: er.EntityRegistry = er.async_get(self.hass)
|
||||
entity_automations = [
|
||||
automation_entity
|
||||
for automation_id in automations
|
||||
if (automation_entity := entity_reg.async_get(automation_id))
|
||||
]
|
||||
entity_scripts = [
|
||||
script_entity
|
||||
for script_id in scripts
|
||||
if (script_entity := entity_reg.async_get(script_id))
|
||||
]
|
||||
|
||||
items_list = [
|
||||
f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
|
||||
for item in entity_automations
|
||||
] + [
|
||||
f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
|
||||
for item in entity_scripts
|
||||
]
|
||||
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}",
|
||||
breaks_in_ha_version="2025.10.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_time_alarm_clock",
|
||||
translation_placeholders={
|
||||
"entity_id": self.entity_id,
|
||||
"items": "\n".join(items_list),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Call when entity will be removed from hass."""
|
||||
if self.bsh_key == SettingKey.BSH_COMMON_ALARM_CLOCK:
|
||||
async_delete_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}",
|
||||
)
|
||||
async_delete_issue(
|
||||
self.hass, DOMAIN, f"deprecated_time_alarm_clock_{self.entity_id}"
|
||||
)
|
||||
|
||||
async def async_set_value(self, value: time) -> None:
|
||||
"""Set the native value of the entity."""
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_time_alarm_clock_{self.entity_id}",
|
||||
breaks_in_ha_version="2025.10.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_time_alarm_clock",
|
||||
translation_placeholders={
|
||||
"entity_id": self.entity_id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
|
||||
@@ -31,7 +31,6 @@ class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]):
|
||||
_LOGGER,
|
||||
name="firmware update coordinator",
|
||||
update_interval=FIRMWARE_REFRESH_INTERVAL,
|
||||
always_update=False,
|
||||
)
|
||||
self.hass = hass
|
||||
self.session = session
|
||||
|
||||
@@ -199,7 +199,7 @@ class BaseFirmwareUpdateEntity(
|
||||
# This entity is not currently associated with a device so we must manually
|
||||
# give it a name
|
||||
self._attr_name = f"{self._config_entry.title} Update"
|
||||
self._attr_title = self.entity_description.firmware_name or "unknown"
|
||||
self._attr_title = self.entity_description.firmware_name or "Unknown"
|
||||
|
||||
if (
|
||||
self._current_firmware_info is None
|
||||
|
||||
@@ -15,14 +15,13 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a Home Assistant SkyConnect config entry."""
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, ["update"])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await hass.config_entries.async_unload_platforms(entry, ["update"])
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -21,11 +21,20 @@ from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import FIRMWARE, FIRMWARE_VERSION, NABU_CASA_FIRMWARE_RELEASES_URL
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FIRMWARE,
|
||||
FIRMWARE_VERSION,
|
||||
NABU_CASA_FIRMWARE_RELEASES_URL,
|
||||
PRODUCT,
|
||||
SERIAL_NUMBER,
|
||||
HardwareVariant,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,7 +51,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
|
||||
fw_type="skyconnect_zigbee_ncp",
|
||||
version_key="ezsp_version",
|
||||
expected_firmware_type=ApplicationType.EZSP,
|
||||
firmware_name="EmberZNet",
|
||||
firmware_name="EmberZNet Zigbee",
|
||||
),
|
||||
ApplicationType.SPINEL: FirmwareUpdateEntityDescription(
|
||||
key="firmware",
|
||||
@@ -55,6 +64,28 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
|
||||
expected_firmware_type=ApplicationType.SPINEL,
|
||||
firmware_name="OpenThread RCP",
|
||||
),
|
||||
ApplicationType.CPC: FirmwareUpdateEntityDescription(
|
||||
key="firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
version_parser=lambda fw: fw,
|
||||
fw_type="skyconnect_multipan",
|
||||
version_key="cpc_version",
|
||||
expected_firmware_type=ApplicationType.CPC,
|
||||
firmware_name="Multiprotocol",
|
||||
),
|
||||
ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription(
|
||||
key="firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
version_parser=lambda fw: fw,
|
||||
fw_type=None, # We don't want to update the bootloader
|
||||
version_key="gecko_bootloader_version",
|
||||
expected_firmware_type=ApplicationType.GECKO_BOOTLOADER,
|
||||
firmware_name="Gecko Bootloader",
|
||||
),
|
||||
None: FirmwareUpdateEntityDescription(
|
||||
key="firmware",
|
||||
display_precision=0,
|
||||
@@ -77,9 +108,16 @@ def _async_create_update_entity(
|
||||
) -> FirmwareUpdateEntity:
|
||||
"""Create an update entity that handles firmware type changes."""
|
||||
firmware_type = config_entry.data[FIRMWARE]
|
||||
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
|
||||
ApplicationType(firmware_type) if firmware_type is not None else None
|
||||
]
|
||||
|
||||
try:
|
||||
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
|
||||
ApplicationType(firmware_type)
|
||||
]
|
||||
except (KeyError, ValueError):
|
||||
_LOGGER.debug(
|
||||
"Unknown firmware type %r, using default entity description", firmware_type
|
||||
)
|
||||
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None]
|
||||
|
||||
entity = FirmwareUpdateEntity(
|
||||
device=config_entry.data["device"],
|
||||
@@ -130,6 +168,7 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""SkyConnect firmware update entity."""
|
||||
|
||||
bootloader_reset_type = None
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -141,8 +180,18 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Initialize the SkyConnect firmware update entity."""
|
||||
super().__init__(device, config_entry, update_coordinator, entity_description)
|
||||
|
||||
self._attr_unique_id = (
|
||||
f"{self._config_entry.data['serial_number']}_{self.entity_description.key}"
|
||||
variant = HardwareVariant.from_usb_product_name(
|
||||
self._config_entry.data[PRODUCT]
|
||||
)
|
||||
serial_number = self._config_entry.data[SERIAL_NUMBER]
|
||||
|
||||
self._attr_unique_id = f"{serial_number}_{self.entity_description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_number)},
|
||||
name=f"{variant.full_name} ({serial_number[:8]})",
|
||||
model=variant.full_name,
|
||||
manufacturer="Nabu Casa",
|
||||
serial_number=serial_number,
|
||||
)
|
||||
|
||||
# Use the cached firmware info if it exists
|
||||
@@ -155,6 +204,17 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
source="homeassistant_sky_connect",
|
||||
)
|
||||
|
||||
def _update_attributes(self) -> None:
|
||||
"""Recompute the attributes of the entity."""
|
||||
super()._update_attributes()
|
||||
|
||||
assert self.device_entry is not None
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_update_device(
|
||||
device_id=self.device_entry.id,
|
||||
sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}",
|
||||
)
|
||||
|
||||
@callback
|
||||
def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None:
|
||||
"""Handle updated firmware info being pushed by an integration."""
|
||||
|
||||
@@ -62,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await hass.config_entries.async_unload_platforms(entry, ["update"])
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
DOMAIN = "homeassistant_yellow"
|
||||
|
||||
RADIO_MODEL = "Home Assistant Yellow"
|
||||
RADIO_MANUFACTURER = "Nabu Casa"
|
||||
MODEL = "Home Assistant Yellow"
|
||||
MANUFACTURER = "Nabu Casa"
|
||||
|
||||
RADIO_DEVICE = "/dev/ttyAMA1"
|
||||
|
||||
ZHA_HW_DISCOVERY_DATA = {
|
||||
|
||||
@@ -149,5 +149,12 @@
|
||||
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
|
||||
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"update": {
|
||||
"firmware": {
|
||||
"name": "Radio firmware"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,17 @@ from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FIRMWARE,
|
||||
FIRMWARE_VERSION,
|
||||
MANUFACTURER,
|
||||
MODEL,
|
||||
NABU_CASA_FIRMWARE_RELEASES_URL,
|
||||
RADIO_DEVICE,
|
||||
)
|
||||
@@ -39,7 +43,7 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
|
||||
ApplicationType | None, FirmwareUpdateEntityDescription
|
||||
] = {
|
||||
ApplicationType.EZSP: FirmwareUpdateEntityDescription(
|
||||
key="firmware",
|
||||
key="radio_firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
@@ -47,10 +51,10 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
|
||||
fw_type="yellow_zigbee_ncp",
|
||||
version_key="ezsp_version",
|
||||
expected_firmware_type=ApplicationType.EZSP,
|
||||
firmware_name="EmberZNet",
|
||||
firmware_name="EmberZNet Zigbee",
|
||||
),
|
||||
ApplicationType.SPINEL: FirmwareUpdateEntityDescription(
|
||||
key="firmware",
|
||||
key="radio_firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
@@ -60,12 +64,34 @@ FIRMWARE_ENTITY_DESCRIPTIONS: dict[
|
||||
expected_firmware_type=ApplicationType.SPINEL,
|
||||
firmware_name="OpenThread RCP",
|
||||
),
|
||||
None: FirmwareUpdateEntityDescription(
|
||||
ApplicationType.CPC: FirmwareUpdateEntityDescription(
|
||||
key="firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
version_parser=lambda fw: fw,
|
||||
fw_type="yellow_multipan",
|
||||
version_key="cpc_version",
|
||||
expected_firmware_type=ApplicationType.CPC,
|
||||
firmware_name="Multiprotocol",
|
||||
),
|
||||
ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription(
|
||||
key="firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
version_parser=lambda fw: fw,
|
||||
fw_type=None, # We don't want to update the bootloader
|
||||
version_key="gecko_bootloader_version",
|
||||
expected_firmware_type=ApplicationType.GECKO_BOOTLOADER,
|
||||
firmware_name="Gecko Bootloader",
|
||||
),
|
||||
None: FirmwareUpdateEntityDescription(
|
||||
key="radio_firmware",
|
||||
display_precision=0,
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
version_parser=lambda fw: fw,
|
||||
fw_type=None,
|
||||
version_key=None,
|
||||
expected_firmware_type=None,
|
||||
@@ -82,9 +108,16 @@ def _async_create_update_entity(
|
||||
) -> FirmwareUpdateEntity:
|
||||
"""Create an update entity that handles firmware type changes."""
|
||||
firmware_type = config_entry.data[FIRMWARE]
|
||||
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
|
||||
ApplicationType(firmware_type) if firmware_type is not None else None
|
||||
]
|
||||
|
||||
try:
|
||||
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
|
||||
ApplicationType(firmware_type)
|
||||
]
|
||||
except (KeyError, ValueError):
|
||||
_LOGGER.debug(
|
||||
"Unknown firmware type %r, using default entity description", firmware_type
|
||||
)
|
||||
entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None]
|
||||
|
||||
entity = FirmwareUpdateEntity(
|
||||
device=RADIO_DEVICE,
|
||||
@@ -135,6 +168,7 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Yellow firmware update entity."""
|
||||
|
||||
bootloader_reset_type = "yellow" # Triggers a GPIO reset
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -145,8 +179,13 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
) -> None:
|
||||
"""Initialize the Yellow firmware update entity."""
|
||||
super().__init__(device, config_entry, update_coordinator, entity_description)
|
||||
|
||||
self._attr_unique_id = self.entity_description.key
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, "yellow")},
|
||||
name=MODEL,
|
||||
model=MODEL,
|
||||
manufacturer=MANUFACTURER,
|
||||
)
|
||||
|
||||
# Use the cached firmware info if it exists
|
||||
if self._config_entry.data[FIRMWARE] is not None:
|
||||
@@ -158,6 +197,17 @@ class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
source="homeassistant_yellow",
|
||||
)
|
||||
|
||||
def _update_attributes(self) -> None:
|
||||
"""Recompute the attributes of the entity."""
|
||||
super()._update_attributes()
|
||||
|
||||
assert self.device_entry is not None
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_update_device(
|
||||
device_id=self.device_entry.id,
|
||||
sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}",
|
||||
)
|
||||
|
||||
@callback
|
||||
def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None:
|
||||
"""Handle updated firmware info being pushed by an integration."""
|
||||
|
||||
@@ -19,6 +19,7 @@ PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.LOCK,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
|
||||
73
homeassistant/components/homee/lock.py
Normal file
73
homeassistant/components/homee/lock.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""The Homee lock platform."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyHomee.const import AttributeChangedBy, AttributeType
|
||||
|
||||
from homeassistant.components.lock import LockEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeeConfigEntry
|
||||
from .entity import HomeeEntity
|
||||
from .helpers import get_name_for_enum
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeeConfigEntry,
|
||||
async_add_devices: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add the Homee platform for the lock component."""
|
||||
|
||||
async_add_devices(
|
||||
HomeeLock(attribute, config_entry)
|
||||
for node in config_entry.runtime_data.nodes
|
||||
for attribute in node.attributes
|
||||
if (attribute.type == AttributeType.LOCK_STATE and attribute.editable)
|
||||
)
|
||||
|
||||
|
||||
class HomeeLock(HomeeEntity, LockEntity):
|
||||
"""Representation of a Homee lock."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool:
|
||||
"""Return if lock is locked."""
|
||||
return self._attribute.current_value == 1.0
|
||||
|
||||
@property
|
||||
def is_locking(self) -> bool:
|
||||
"""Return if lock is locking."""
|
||||
return self._attribute.target_value > self._attribute.current_value
|
||||
|
||||
@property
|
||||
def is_unlocking(self) -> bool:
|
||||
"""Return if lock is unlocking."""
|
||||
return self._attribute.target_value < self._attribute.current_value
|
||||
|
||||
@property
|
||||
def changed_by(self) -> str:
|
||||
"""Return by whom or what the lock was last changed."""
|
||||
changed_id = str(self._attribute.changed_by_id)
|
||||
changed_by_name = get_name_for_enum(
|
||||
AttributeChangedBy, self._attribute.changed_by
|
||||
)
|
||||
if self._attribute.changed_by == AttributeChangedBy.USER:
|
||||
changed_id = self._entry.runtime_data.get_user_by_id(
|
||||
self._attribute.changed_by_id
|
||||
).username
|
||||
|
||||
return f"{changed_by_name}-{changed_id}"
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock specified lock. A code to lock the lock with may be specified."""
|
||||
await self.async_set_homee_value(1)
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock specified lock. A code to unlock the lock with may be specified."""
|
||||
await self.async_set_homee_value(0)
|
||||
@@ -178,6 +178,7 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
|
||||
key="WIND_DIRECTION",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
"WIND_DIRECTION_RANGE": SensorEntityDescription(
|
||||
key="WIND_DIRECTION_RANGE",
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
{
|
||||
"entity": {
|
||||
"light": {
|
||||
"hue_light": {
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
"state": {
|
||||
"candle": "mdi:candle",
|
||||
"sparkle": "mdi:shimmer",
|
||||
"glisten": "mdi:creation",
|
||||
"sunrise": "mdi:weather-sunset-up",
|
||||
"sunset": "mdi:weather-sunset",
|
||||
"fire": "mdi:fire",
|
||||
"prism": "mdi:triangle-outline",
|
||||
"opal": "mdi:diamond-stone",
|
||||
"underwater": "mdi:waves",
|
||||
"cosmos": "mdi:star-shooting",
|
||||
"sunbeam": "mdi:spotlight-beam",
|
||||
"enchant": "mdi:magic-staff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"hue_activate_scene": {
|
||||
"service": "mdi:palette"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
}
|
||||
},
|
||||
"manual": {
|
||||
"title": "Manual configure a Hue bridge",
|
||||
"title": "Manually configure a Hue bridge",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
@@ -46,8 +46,8 @@
|
||||
"button_2": "Second button",
|
||||
"button_3": "Third button",
|
||||
"button_4": "Fourth button",
|
||||
"double_buttons_1_3": "First and Third buttons",
|
||||
"double_buttons_2_4": "Second and Fourth buttons",
|
||||
"double_buttons_1_3": "First and third button",
|
||||
"double_buttons_2_4": "Second and fourth button",
|
||||
"dim_down": "Dim down",
|
||||
"dim_up": "Dim up",
|
||||
"turn_off": "[%key:common::action::turn_off%]",
|
||||
|
||||
@@ -227,12 +227,16 @@ def _get_work_area_names(data: MowerAttributes) -> list[str]:
|
||||
@callback
|
||||
def _get_current_work_area_name(data: MowerAttributes) -> str:
|
||||
"""Return the name of the current work area."""
|
||||
if data.mower.work_area_id is None:
|
||||
return STATE_NO_WORK_AREA_ACTIVE
|
||||
if TYPE_CHECKING:
|
||||
# Sensor does not get created if values are None
|
||||
assert data.work_areas is not None
|
||||
return data.work_areas[data.mower.work_area_id].name
|
||||
if (
|
||||
data.mower.work_area_id is not None
|
||||
and data.mower.work_area_id in data.work_areas
|
||||
):
|
||||
return data.work_areas[data.mower.work_area_id].name
|
||||
|
||||
return STATE_NO_WORK_AREA_ACTIVE
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["imgw_pib==1.0.9"]
|
||||
"requirements": ["imgw_pib==1.0.10"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import datetime
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
|
||||
from homeassistant.components.recorder.models import StatisticMeanType
|
||||
from homeassistant.components.recorder.models.statistics import (
|
||||
StatisticData,
|
||||
StatisticMetaData,
|
||||
@@ -270,7 +271,7 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity):
|
||||
]
|
||||
|
||||
metadata: StatisticMetaData = {
|
||||
"has_mean": False,
|
||||
"mean_type": StatisticMeanType.NONE,
|
||||
"has_sum": True,
|
||||
"name": f"{self.device_entry.name} {self.name}",
|
||||
"source": DOMAIN,
|
||||
|
||||
@@ -16,7 +16,8 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_CANDLE_LIGHT_MINUTES,
|
||||
@@ -26,11 +27,21 @@ from .const import (
|
||||
DEFAULT_DIASPORA,
|
||||
DEFAULT_HAVDALAH_OFFSET_MINUTES,
|
||||
DEFAULT_LANGUAGE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .entity import JewishCalendarConfigEntry, JewishCalendarData
|
||||
from .service import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Jewish Calendar service."""
|
||||
async_setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
DOMAIN = "jewish_calendar"
|
||||
|
||||
ATTR_DATE = "date"
|
||||
ATTR_NUSACH = "nusach"
|
||||
|
||||
CONF_DIASPORA = "diaspora"
|
||||
CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset"
|
||||
CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset"
|
||||
@@ -11,3 +14,5 @@ DEFAULT_CANDLE_LIGHT = 18
|
||||
DEFAULT_DIASPORA = False
|
||||
DEFAULT_HAVDALAH_OFFSET_MINUTES = 0
|
||||
DEFAULT_LANGUAGE = "english"
|
||||
|
||||
SERVICE_COUNT_OMER = "count_omer"
|
||||
|
||||
7
homeassistant/components/jewish_calendar/icons.json
Normal file
7
homeassistant/components/jewish_calendar/icons.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"services": {
|
||||
"count_omer": {
|
||||
"service": "mdi:counter"
|
||||
}
|
||||
}
|
||||
}
|
||||
63
homeassistant/components/jewish_calendar/service.py
Normal file
63
homeassistant/components/jewish_calendar/service.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Services for Jewish Calendar."""
|
||||
|
||||
import datetime
|
||||
from typing import cast
|
||||
|
||||
from hdate import HebrewDate
|
||||
from hdate.omer import Nusach, Omer
|
||||
from hdate.translator import Language
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_LANGUAGE
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import LanguageSelector, LanguageSelectorConfig
|
||||
|
||||
from .const import ATTR_DATE, ATTR_NUSACH, DOMAIN, SERVICE_COUNT_OMER
|
||||
|
||||
SUPPORTED_LANGUAGES = {"en": "english", "fr": "french", "he": "hebrew"}
|
||||
OMER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DATE, default=datetime.date.today): cv.date,
|
||||
vol.Required(ATTR_NUSACH, default="sfarad"): vol.In(
|
||||
[nusach.name.lower() for nusach in Nusach]
|
||||
),
|
||||
vol.Required(CONF_LANGUAGE, default="he"): LanguageSelector(
|
||||
LanguageSelectorConfig(languages=list(SUPPORTED_LANGUAGES.keys()))
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the Jewish Calendar services."""
|
||||
|
||||
async def get_omer_count(call: ServiceCall) -> ServiceResponse:
|
||||
"""Return the Omer blessing for a given date."""
|
||||
hebrew_date = HebrewDate.from_gdate(call.data["date"])
|
||||
nusach = Nusach[call.data["nusach"].upper()]
|
||||
|
||||
# Currently Omer only supports Hebrew, English, and French and requires
|
||||
# the full language name
|
||||
language = cast(Language, SUPPORTED_LANGUAGES[call.data[CONF_LANGUAGE]])
|
||||
|
||||
omer = Omer(date=hebrew_date, nusach=nusach, language=language)
|
||||
return {
|
||||
"message": str(omer.count_str()),
|
||||
"weeks": omer.week,
|
||||
"days": omer.day,
|
||||
"total_days": omer.total_days,
|
||||
}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_COUNT_OMER,
|
||||
get_omer_count,
|
||||
schema=OMER_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
29
homeassistant/components/jewish_calendar/services.yaml
Normal file
29
homeassistant/components/jewish_calendar/services.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
count_omer:
|
||||
fields:
|
||||
date:
|
||||
required: true
|
||||
example: "2025-04-14"
|
||||
selector:
|
||||
date:
|
||||
nusach:
|
||||
required: true
|
||||
example: "sfarad"
|
||||
default: "sfarad"
|
||||
selector:
|
||||
select:
|
||||
translation_key: "nusach"
|
||||
options:
|
||||
- "sfarad"
|
||||
- "ashkenaz"
|
||||
- "adot_mizrah"
|
||||
- "italian"
|
||||
language:
|
||||
required: true
|
||||
default: "he"
|
||||
example: "he"
|
||||
selector:
|
||||
language:
|
||||
languages:
|
||||
- "en"
|
||||
- "he"
|
||||
- "fr"
|
||||
@@ -45,5 +45,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"nusach": {
|
||||
"options": {
|
||||
"sfarad": "Sfarad",
|
||||
"ashkenaz": "Ashkenaz",
|
||||
"adot_mizrah": "Adot Mizrah",
|
||||
"italian": "Italian"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"count_omer": {
|
||||
"name": "Count the Omer",
|
||||
"description": "Returns the phrase for counting the Omer on a given date.",
|
||||
"fields": {
|
||||
"date": {
|
||||
"name": "Date",
|
||||
"description": "Date to count the Omer for."
|
||||
},
|
||||
"nusach": {
|
||||
"name": "Nusach",
|
||||
"description": "Nusach to count the Omer in."
|
||||
},
|
||||
"language": {
|
||||
"name": "Language",
|
||||
"description": "Language to count the Omer in."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,24 @@ from random import random
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
|
||||
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
|
||||
from homeassistant.components.recorder.models import (
|
||||
StatisticData,
|
||||
StatisticMeanType,
|
||||
StatisticMetaData,
|
||||
)
|
||||
from homeassistant.components.recorder.statistics import (
|
||||
async_add_external_statistics,
|
||||
async_import_statistics,
|
||||
get_last_statistics,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume
|
||||
from homeassistant.const import (
|
||||
DEGREE,
|
||||
Platform,
|
||||
UnitOfEnergy,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
@@ -72,6 +82,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set the config entry up."""
|
||||
if "recorder" in hass.config.components:
|
||||
# Insert stats for mean_type_changed issue
|
||||
await _insert_wrong_wind_direction_statistics(hass)
|
||||
|
||||
# Set up demo platforms with config entry
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
entry, COMPONENTS_WITH_DEMO_PLATFORM
|
||||
@@ -233,7 +247,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
"name": "Outdoor temperature",
|
||||
"statistic_id": f"{DOMAIN}:temperature_outdoor",
|
||||
"unit_of_measurement": UnitOfTemperature.CELSIUS,
|
||||
"has_mean": True,
|
||||
"mean_type": StatisticMeanType.ARITHMETIC,
|
||||
"has_sum": False,
|
||||
}
|
||||
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
|
||||
@@ -246,7 +260,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
"name": "Energy consumption 1",
|
||||
"statistic_id": f"{DOMAIN}:energy_consumption_kwh",
|
||||
"unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR,
|
||||
"has_mean": False,
|
||||
"mean_type": StatisticMeanType.NONE,
|
||||
"has_sum": True,
|
||||
}
|
||||
await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 1)
|
||||
@@ -258,7 +272,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
"name": "Energy consumption 2",
|
||||
"statistic_id": f"{DOMAIN}:energy_consumption_mwh",
|
||||
"unit_of_measurement": UnitOfEnergy.MEGA_WATT_HOUR,
|
||||
"has_mean": False,
|
||||
"mean_type": StatisticMeanType.NONE,
|
||||
"has_sum": True,
|
||||
}
|
||||
await _insert_sum_statistics(
|
||||
@@ -272,7 +286,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
"name": "Gas consumption 1",
|
||||
"statistic_id": f"{DOMAIN}:gas_consumption_m3",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
|
||||
"has_mean": False,
|
||||
"mean_type": StatisticMeanType.NONE,
|
||||
"has_sum": True,
|
||||
}
|
||||
await _insert_sum_statistics(
|
||||
@@ -286,7 +300,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
"name": "Gas consumption 2",
|
||||
"statistic_id": f"{DOMAIN}:gas_consumption_ft3",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_FEET,
|
||||
"has_mean": False,
|
||||
"mean_type": StatisticMeanType.NONE,
|
||||
"has_sum": True,
|
||||
}
|
||||
await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 15)
|
||||
@@ -298,7 +312,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issues_issue_1",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
|
||||
"has_mean": True,
|
||||
"mean_type": StatisticMeanType.ARITHMETIC,
|
||||
"has_sum": False,
|
||||
}
|
||||
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
|
||||
@@ -310,7 +324,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issues_issue_2",
|
||||
"unit_of_measurement": "cats",
|
||||
"has_mean": True,
|
||||
"mean_type": StatisticMeanType.ARITHMETIC,
|
||||
"has_sum": False,
|
||||
}
|
||||
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
|
||||
@@ -322,7 +336,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issues_issue_3",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
|
||||
"has_mean": True,
|
||||
"mean_type": StatisticMeanType.ARITHMETIC,
|
||||
"has_sum": False,
|
||||
}
|
||||
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
|
||||
@@ -334,8 +348,28 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issues_issue_4",
|
||||
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
|
||||
"has_mean": True,
|
||||
"mean_type": StatisticMeanType.ARITHMETIC,
|
||||
"has_sum": False,
|
||||
}
|
||||
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
|
||||
async_import_statistics(hass, metadata, statistics)
|
||||
|
||||
|
||||
async def _insert_wrong_wind_direction_statistics(hass: HomeAssistant) -> None:
|
||||
"""Insert some fake wind direction statistics."""
|
||||
now = dt_util.now()
|
||||
yesterday = now - datetime.timedelta(days=1)
|
||||
yesterday_midnight = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_midnight = yesterday_midnight + datetime.timedelta(days=1)
|
||||
|
||||
# Add some statistics required to raise the mean_type_changed issue later
|
||||
metadata: StatisticMetaData = {
|
||||
"source": RECORDER_DOMAIN,
|
||||
"name": None,
|
||||
"statistic_id": "sensor.statistics_issues_issue_5",
|
||||
"unit_of_measurement": DEGREE,
|
||||
"mean_type": StatisticMeanType.ARITHMETIC,
|
||||
"has_sum": False,
|
||||
}
|
||||
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 0, 360)
|
||||
async_import_statistics(hass, metadata, statistics)
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfPower
|
||||
from homeassistant.const import DEGREE, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -87,6 +87,16 @@ async def async_setup_entry(
|
||||
state_class=None,
|
||||
unit_of_measurement=UnitOfPower.WATT,
|
||||
),
|
||||
DemoSensor(
|
||||
device_unique_id="statistics_issues",
|
||||
unique_id="statistics_issue_5",
|
||||
device_name="Statistics issues",
|
||||
entity_name="Issue 5",
|
||||
state=100,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
unit_of_measurement=DEGREE,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"import_confirm": {
|
||||
"title": "Import Konnected Device",
|
||||
"description": "A Konnected Alarm Panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry."
|
||||
"title": "Import Konnected device",
|
||||
"description": "A Konnected alarm panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry."
|
||||
},
|
||||
"user": {
|
||||
"description": "Please enter the host information for your Konnected Panel.",
|
||||
"description": "Please enter the host information for your Konnected panel.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
}
|
||||
},
|
||||
"confirm": {
|
||||
"title": "Konnected Device Ready",
|
||||
"description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings."
|
||||
"title": "Konnected device ready",
|
||||
"description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected alarm panel settings."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -45,8 +45,8 @@
|
||||
}
|
||||
},
|
||||
"options_io_ext": {
|
||||
"title": "Configure Extended I/O",
|
||||
"description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.",
|
||||
"title": "Configure extended I/O",
|
||||
"description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.",
|
||||
"data": {
|
||||
"8": "Zone 8",
|
||||
"9": "Zone 9",
|
||||
@@ -59,25 +59,25 @@
|
||||
}
|
||||
},
|
||||
"options_binary": {
|
||||
"title": "Configure Binary Sensor",
|
||||
"title": "Configure binary sensor",
|
||||
"description": "{zone} options",
|
||||
"data": {
|
||||
"type": "Binary Sensor Type",
|
||||
"type": "Binary sensor type",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"inverse": "Invert the open/close state"
|
||||
}
|
||||
},
|
||||
"options_digital": {
|
||||
"title": "Configure Digital Sensor",
|
||||
"title": "Configure digital sensor",
|
||||
"description": "[%key:component::konnected::options::step::options_binary::description%]",
|
||||
"data": {
|
||||
"type": "Sensor Type",
|
||||
"type": "Sensor type",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"poll_interval": "Poll Interval (minutes)"
|
||||
"poll_interval": "Poll interval (minutes)"
|
||||
}
|
||||
},
|
||||
"options_switch": {
|
||||
"title": "Configure Switchable Output",
|
||||
"title": "Configure switchable output",
|
||||
"description": "{zone} options: state {state}",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
@@ -89,18 +89,18 @@
|
||||
}
|
||||
},
|
||||
"options_misc": {
|
||||
"title": "Configure Misc",
|
||||
"title": "Configure misc",
|
||||
"description": "Please select the desired behavior for your panel",
|
||||
"data": {
|
||||
"discovery": "Respond to discovery requests on your network",
|
||||
"blink": "Blink panel LED on when sending state change",
|
||||
"override_api_host": "Override default Home Assistant API host panel URL",
|
||||
"api_host": "Override API host URL"
|
||||
"override_api_host": "Override default Home Assistant API host URL",
|
||||
"api_host": "Custom API host URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"bad_host": "Invalid Override API host URL"
|
||||
"bad_host": "Invalid custom API host URL"
|
||||
},
|
||||
"abort": {
|
||||
"not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]"
|
||||
|
||||
@@ -106,6 +106,7 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=DEGREE,
|
||||
suggested_display_precision=2,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
"WetDry": LaCrosseSensorEntityDescription(
|
||||
key="WetDry",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from led_ble import LEDBLE
|
||||
|
||||
@@ -83,7 +83,7 @@ class LEDBLEEntity(CoordinatorEntity[DataUpdateCoordinator[None]], LightEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn on."""
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness)
|
||||
brightness = cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness))
|
||||
if effect := kwargs.get(ATTR_EFFECT):
|
||||
await self._async_set_effect(effect, brightness)
|
||||
return
|
||||
|
||||
@@ -465,7 +465,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
):
|
||||
params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value)
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
brightness = params.get(ATTR_BRIGHTNESS, light.brightness)
|
||||
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
|
||||
color_temp,
|
||||
brightness,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
{
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:lightbulb"
|
||||
"default": "mdi:lightbulb",
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
"default": "mdi:circle-medium",
|
||||
"state": {
|
||||
"off": "mdi:star-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -93,7 +93,10 @@
|
||||
"name": "Color temperature (Kelvin)"
|
||||
},
|
||||
"effect": {
|
||||
"name": "Effect"
|
||||
"name": "Effect",
|
||||
"state": {
|
||||
"off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"effect_list": {
|
||||
"name": "Available effects"
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["linkplay"],
|
||||
"requirements": ["python-linkplay==0.2.1"],
|
||||
"requirements": ["python-linkplay==0.2.2"],
|
||||
"zeroconf": ["_linkplay._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class MatterEventEntity(MatterEntity, EventEntity):
|
||||
max_presses_supported = self.get_matter_attribute_value(
|
||||
clusters.Switch.Attributes.MultiPressMax
|
||||
)
|
||||
max_presses_supported = min(max_presses_supported or 1, 8)
|
||||
max_presses_supported = min(max_presses_supported or 2, 8)
|
||||
for i in range(max_presses_supported):
|
||||
event_types.append(f"multi_press_{i + 1}") # noqa: PERF401
|
||||
elif feature_map & SwitchFeature.kMomentarySwitch:
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.02.19"],
|
||||
"requirements": ["yt-dlp[default]==2025.03.26"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ from homeassistant.helpers.network import (
|
||||
from .const import CONTENT_AUTH_EXPIRY_TIME, MediaClass, MediaType
|
||||
|
||||
# Paths that we don't need to sign
|
||||
PATHS_WITHOUT_AUTH = ("/api/tts_proxy/", "/api/esphome/ffmpeg_proxy/")
|
||||
PATHS_WITHOUT_AUTH = (
|
||||
"/api/tts_proxy/",
|
||||
"/api/esphome/ffmpeg_proxy/",
|
||||
"/api/assist_satellite/static/",
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice
|
||||
import pymelcloud.ata_device as ata
|
||||
@@ -236,7 +236,7 @@ class AtaDeviceClimate(MelCloudClimate):
|
||||
set_dict: dict[str, Any] = {}
|
||||
if ATTR_HVAC_MODE in kwargs:
|
||||
self._apply_set_hvac_mode(
|
||||
kwargs.get(ATTR_HVAC_MODE, self.hvac_mode), set_dict
|
||||
cast(HVACMode, kwargs.get(ATTR_HVAC_MODE, self.hvac_mode)), set_dict
|
||||
)
|
||||
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
|
||||
@@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Fetch data from API endpoint."""
|
||||
assert isinstance(department, str)
|
||||
return await hass.async_add_executor_job(
|
||||
client.get_warning_current_phenomenoms, department, 0, True
|
||||
client.get_warning_current_phenomenons, department, 0, True
|
||||
)
|
||||
|
||||
coordinator_forecast = DataUpdateCoordinator(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/meteo_france",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["meteofrance_api"],
|
||||
"requirements": ["meteofrance-api==1.3.0"]
|
||||
"requirements": ["meteofrance-api==1.4.0"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
|
||||
from meteofrance_api.helpers import (
|
||||
get_warning_text_status_from_indice_color,
|
||||
readeable_phenomenoms_dict,
|
||||
readable_phenomenons_dict,
|
||||
)
|
||||
from meteofrance_api.model.forecast import Forecast
|
||||
from meteofrance_api.model.rain import Rain
|
||||
@@ -336,7 +336,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor[CurrentPhenomenons]):
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
**readeable_phenomenoms_dict(self.coordinator.data.phenomenons_max_colors),
|
||||
**readable_phenomenons_dict(self.coordinator.data.phenomenons_max_colors),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=DEGREE,
|
||||
icon="mdi:weather-windy",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="rain",
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
"flow_title": "{short_mac} ({ip_address})",
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Connect to your Motion Gateway, if the IP address is not set, auto-discovery is used",
|
||||
"description": "Connect to your Motionblinds gateway. If the IP address is not set, auto-discovery is used",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::ip%]"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions",
|
||||
"description": "You will need the 16 character API key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-api-key for instructions",
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"title": "Select the Motion Gateway that you wish to connect",
|
||||
"description": "Run the setup again if you want to connect additional Motion Gateways",
|
||||
"title": "Select the Motionblinds gateway that you wish to connect",
|
||||
"description": "Run the setup again if you want to connect additional Motionblinds gateways",
|
||||
"data": {
|
||||
"select_ip": "[%key:common::config_flow::data::ip%]"
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"connection_error": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"not_motionblinds": "Discovered device is not a Motion gateway"
|
||||
"not_motionblinds": "Discovered device is not a Motionblinds gateway"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -27,6 +27,13 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.file_upload import process_uploaded_file
|
||||
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
|
||||
from homeassistant.components.sensor import (
|
||||
CONF_STATE_CLASS,
|
||||
DEVICE_CLASS_UNITS,
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.switch import SwitchDeviceClass
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigEntry,
|
||||
@@ -45,18 +52,22 @@ from homeassistant.const import (
|
||||
ATTR_SW_VERSION,
|
||||
CONF_CLIENT_ID,
|
||||
CONF_DEVICE,
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_DISCOVERY,
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_OPTIMISTIC,
|
||||
CONF_PASSWORD,
|
||||
CONF_PAYLOAD,
|
||||
CONF_PLATFORM,
|
||||
CONF_PORT,
|
||||
CONF_PROTOCOL,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USERNAME,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
@@ -99,11 +110,16 @@ from .const import (
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_DISCOVERY_PREFIX,
|
||||
CONF_ENTITY_PICTURE,
|
||||
CONF_EXPIRE_AFTER,
|
||||
CONF_KEEPALIVE,
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE,
|
||||
CONF_OPTIONS,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_QOS,
|
||||
CONF_RETAIN,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_SUGGESTED_DISPLAY_PRECISION,
|
||||
CONF_TLS_INSECURE,
|
||||
CONF_TRANSPORT,
|
||||
CONF_WILL_MESSAGE,
|
||||
@@ -120,6 +136,7 @@ from .const import (
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_PREFIX,
|
||||
DEFAULT_PROTOCOL,
|
||||
DEFAULT_QOS,
|
||||
DEFAULT_TRANSPORT,
|
||||
DEFAULT_WILL,
|
||||
DEFAULT_WS_PATH,
|
||||
@@ -133,9 +150,9 @@ from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData
|
||||
from .util import (
|
||||
async_create_certificate_temp_files,
|
||||
get_file_path,
|
||||
learn_more_url,
|
||||
valid_birth_will,
|
||||
valid_publish_topic,
|
||||
valid_qos_schema,
|
||||
valid_subscribe_topic,
|
||||
valid_subscribe_topic_template,
|
||||
)
|
||||
@@ -164,7 +181,6 @@ PASSWORD_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWO
|
||||
QOS_SELECTOR = NumberSelector(
|
||||
NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2)
|
||||
)
|
||||
QOS_DATA_SCHEMA = vol.All(QOS_SELECTOR, valid_qos_schema)
|
||||
KEEPALIVE_SELECTOR = vol.All(
|
||||
NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
@@ -217,7 +233,7 @@ KEY_UPLOAD_SELECTOR = FileSelector(
|
||||
)
|
||||
|
||||
# Subentry selectors
|
||||
SUBENTRY_PLATFORMS = [Platform.NOTIFY]
|
||||
SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH]
|
||||
SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[platform.value for platform in SUBENTRY_PLATFORMS],
|
||||
@@ -225,7 +241,6 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
|
||||
translation_key=CONF_PLATFORM,
|
||||
)
|
||||
)
|
||||
|
||||
TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
|
||||
|
||||
SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
|
||||
@@ -241,17 +256,118 @@ SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
# Sensor specific selectors
|
||||
SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[device_class.value for device_class in SensorDeviceClass],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="device_class_sensor",
|
||||
sort=True,
|
||||
)
|
||||
)
|
||||
SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[device_class.value for device_class in SensorStateClass],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key=CONF_STATE_CLASS,
|
||||
)
|
||||
)
|
||||
OPTIONS_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[],
|
||||
custom_value=True,
|
||||
multiple=True,
|
||||
)
|
||||
)
|
||||
SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector(
|
||||
NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9)
|
||||
)
|
||||
EXPIRE_AFTER_SELECTOR = NumberSelector(
|
||||
NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0)
|
||||
)
|
||||
|
||||
# Switch specific selectors
|
||||
SWITCH_DEVICE_CLASS_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[device_class.value for device_class in SwitchDeviceClass],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="device_class_switch",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def validate_sensor_platform_config(
|
||||
config: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""Validate the sensor options, state and device class config."""
|
||||
errors: dict[str, str] = {}
|
||||
# Only allow `options` to be set for `enum` sensors
|
||||
# to limit the possible sensor values
|
||||
if config.get(CONF_OPTIONS) is not None:
|
||||
if config.get(CONF_STATE_CLASS) or config.get(CONF_UNIT_OF_MEASUREMENT):
|
||||
errors[CONF_OPTIONS] = "options_not_allowed_with_state_class_or_uom"
|
||||
|
||||
if (device_class := config.get(CONF_DEVICE_CLASS)) != SensorDeviceClass.ENUM:
|
||||
errors[CONF_DEVICE_CLASS] = "options_device_class_enum"
|
||||
|
||||
if (
|
||||
(device_class := config.get(CONF_DEVICE_CLASS)) == SensorDeviceClass.ENUM
|
||||
and errors is not None
|
||||
and CONF_OPTIONS not in config
|
||||
):
|
||||
errors[CONF_OPTIONS] = "options_with_enum_device_class"
|
||||
|
||||
if (
|
||||
device_class in DEVICE_CLASS_UNITS
|
||||
and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None
|
||||
and errors is not None
|
||||
):
|
||||
# Do not allow an empty unit of measurement in a subentry data flow
|
||||
errors[CONF_UNIT_OF_MEASUREMENT] = "uom_required_for_device_class"
|
||||
return errors
|
||||
|
||||
if (
|
||||
device_class is not None
|
||||
and device_class in DEVICE_CLASS_UNITS
|
||||
and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class]
|
||||
):
|
||||
errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom"
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlatformField:
|
||||
"""Stores a platform config field schema, required flag and validator."""
|
||||
|
||||
selector: Selector
|
||||
selector: Selector[Any] | Callable[..., Selector[Any]]
|
||||
required: bool
|
||||
validator: Callable[..., Any]
|
||||
error: str | None = None
|
||||
default: str | int | vol.Undefined = vol.UNDEFINED
|
||||
exclude_from_reconfig: bool = False
|
||||
conditions: tuple[dict[str, Any], ...] | None = None
|
||||
custom_filtering: bool = False
|
||||
section: str | None = None
|
||||
|
||||
|
||||
@callback
|
||||
def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
|
||||
"""Return a context based unit of measurement selector."""
|
||||
if (
|
||||
user_data is None
|
||||
or (device_class := user_data.get(CONF_DEVICE_CLASS)) is None
|
||||
or device_class not in DEVICE_CLASS_UNITS
|
||||
):
|
||||
return TEXT_SELECTOR
|
||||
return SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[str(uom) for uom in DEVICE_CLASS_UNITS[device_class]],
|
||||
sort=True,
|
||||
custom_value=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
COMMON_ENTITY_FIELDS = {
|
||||
@@ -262,9 +378,30 @@ COMMON_ENTITY_FIELDS = {
|
||||
CONF_ENTITY_PICTURE: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"),
|
||||
}
|
||||
|
||||
COMMON_MQTT_FIELDS = {
|
||||
CONF_QOS: PlatformField(QOS_SELECTOR, False, valid_qos_schema, default=0),
|
||||
CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool),
|
||||
PLATFORM_ENTITY_FIELDS = {
|
||||
Platform.NOTIFY.value: {},
|
||||
Platform.SENSOR.value: {
|
||||
CONF_DEVICE_CLASS: PlatformField(SENSOR_DEVICE_CLASS_SELECTOR, False, str),
|
||||
CONF_STATE_CLASS: PlatformField(SENSOR_STATE_CLASS_SELECTOR, False, str),
|
||||
CONF_UNIT_OF_MEASUREMENT: PlatformField(
|
||||
unit_of_measurement_selector, False, str, custom_filtering=True
|
||||
),
|
||||
CONF_SUGGESTED_DISPLAY_PRECISION: PlatformField(
|
||||
SUGGESTED_DISPLAY_PRECISION_SELECTOR,
|
||||
False,
|
||||
cv.positive_int,
|
||||
section="advanced_settings",
|
||||
),
|
||||
CONF_OPTIONS: PlatformField(
|
||||
OPTIONS_SELECTOR,
|
||||
False,
|
||||
cv.ensure_list,
|
||||
conditions=({"device_class": "enum"},),
|
||||
),
|
||||
},
|
||||
Platform.SWITCH.value: {
|
||||
CONF_DEVICE_CLASS: PlatformField(SWITCH_DEVICE_CLASS_SELECTOR, False, str),
|
||||
},
|
||||
}
|
||||
PLATFORM_MQTT_FIELDS = {
|
||||
Platform.NOTIFY.value: {
|
||||
@@ -274,19 +411,63 @@ PLATFORM_MQTT_FIELDS = {
|
||||
CONF_COMMAND_TEMPLATE: PlatformField(
|
||||
TEMPLATE_SELECTOR, False, cv.template, "invalid_template"
|
||||
),
|
||||
CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool),
|
||||
},
|
||||
Platform.SENSOR.value: {
|
||||
CONF_STATE_TOPIC: PlatformField(
|
||||
TEXT_SELECTOR, True, valid_subscribe_topic, "invalid_subscribe_topic"
|
||||
),
|
||||
CONF_VALUE_TEMPLATE: PlatformField(
|
||||
TEMPLATE_SELECTOR, False, cv.template, "invalid_template"
|
||||
),
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField(
|
||||
TEMPLATE_SELECTOR,
|
||||
False,
|
||||
cv.template,
|
||||
"invalid_template",
|
||||
conditions=({CONF_STATE_CLASS: "total"},),
|
||||
),
|
||||
CONF_EXPIRE_AFTER: PlatformField(
|
||||
EXPIRE_AFTER_SELECTOR, False, cv.positive_int, section="advanced_settings"
|
||||
),
|
||||
},
|
||||
Platform.SWITCH.value: {
|
||||
CONF_COMMAND_TOPIC: PlatformField(
|
||||
TEXT_SELECTOR, True, valid_publish_topic, "invalid_publish_topic"
|
||||
),
|
||||
CONF_COMMAND_TEMPLATE: PlatformField(
|
||||
TEMPLATE_SELECTOR, False, cv.template, "invalid_template"
|
||||
),
|
||||
CONF_STATE_TOPIC: PlatformField(
|
||||
TEXT_SELECTOR, False, valid_subscribe_topic, "invalid_subscribe_topic"
|
||||
),
|
||||
CONF_VALUE_TEMPLATE: PlatformField(
|
||||
TEMPLATE_SELECTOR, False, cv.template, "invalid_template"
|
||||
),
|
||||
CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool),
|
||||
CONF_OPTIMISTIC: PlatformField(BOOLEAN_SELECTOR, False, bool),
|
||||
},
|
||||
}
|
||||
ENTITY_CONFIG_VALIDATOR: dict[
|
||||
str,
|
||||
Callable[[dict[str, Any]], dict[str, str]] | None,
|
||||
] = {
|
||||
Platform.NOTIFY.value: None,
|
||||
Platform.SENSOR.value: validate_sensor_platform_config,
|
||||
Platform.SWITCH.value: None,
|
||||
}
|
||||
|
||||
MQTT_DEVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_NAME): TEXT_SELECTOR,
|
||||
vol.Optional(ATTR_SW_VERSION): TEXT_SELECTOR,
|
||||
vol.Optional(ATTR_HW_VERSION): TEXT_SELECTOR,
|
||||
vol.Optional(ATTR_MODEL): TEXT_SELECTOR,
|
||||
vol.Optional(ATTR_MODEL_ID): TEXT_SELECTOR,
|
||||
vol.Optional(ATTR_CONFIGURATION_URL): TEXT_SELECTOR,
|
||||
}
|
||||
)
|
||||
MQTT_DEVICE_PLATFORM_FIELDS = {
|
||||
ATTR_NAME: PlatformField(TEXT_SELECTOR, False, str),
|
||||
ATTR_SW_VERSION: PlatformField(TEXT_SELECTOR, False, str),
|
||||
ATTR_HW_VERSION: PlatformField(TEXT_SELECTOR, False, str),
|
||||
ATTR_MODEL: PlatformField(TEXT_SELECTOR, False, str),
|
||||
ATTR_MODEL_ID: PlatformField(TEXT_SELECTOR, False, str),
|
||||
ATTR_CONFIGURATION_URL: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"),
|
||||
CONF_QOS: PlatformField(
|
||||
QOS_SELECTOR, False, int, default=DEFAULT_QOS, section="mqtt_settings"
|
||||
),
|
||||
}
|
||||
|
||||
REAUTH_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -337,38 +518,151 @@ def validate_field(
|
||||
errors[field] = error
|
||||
|
||||
|
||||
@callback
|
||||
def _check_conditions(
|
||||
platform_field: PlatformField, component_data: dict[str, Any] | None = None
|
||||
) -> bool:
|
||||
"""Only include field if one of conditions match, or no conditions are set."""
|
||||
if platform_field.conditions is None or component_data is None:
|
||||
return True
|
||||
return any(
|
||||
all(component_data.get(key) == value for key, value in condition.items())
|
||||
for condition in platform_field.conditions
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def calculate_merged_config(
|
||||
merged_user_input: dict[str, Any],
|
||||
data_schema_fields: dict[str, PlatformField],
|
||||
component_data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate merged config."""
|
||||
base_schema_fields = {
|
||||
key
|
||||
for key, platform_field in data_schema_fields.items()
|
||||
if _check_conditions(platform_field, component_data)
|
||||
} - set(merged_user_input)
|
||||
return {
|
||||
key: value
|
||||
for key, value in component_data.items()
|
||||
if key not in base_schema_fields
|
||||
} | merged_user_input
|
||||
|
||||
|
||||
@callback
|
||||
def validate_user_input(
|
||||
user_input: dict[str, Any],
|
||||
data_schema_fields: dict[str, PlatformField],
|
||||
errors: dict[str, str],
|
||||
) -> None:
|
||||
*,
|
||||
component_data: dict[str, Any] | None = None,
|
||||
config_validator: Callable[[dict[str, Any]], dict[str, str]] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
"""Validate user input."""
|
||||
for field, value in user_input.items():
|
||||
errors: dict[str, str] = {}
|
||||
# Merge sections
|
||||
merged_user_input: dict[str, Any] = {}
|
||||
for key, value in user_input.items():
|
||||
if isinstance(value, dict):
|
||||
merged_user_input.update(value)
|
||||
else:
|
||||
merged_user_input[key] = value
|
||||
|
||||
for field, value in merged_user_input.items():
|
||||
validator = data_schema_fields[field].validator
|
||||
try:
|
||||
validator(value)
|
||||
except (ValueError, vol.Invalid):
|
||||
errors[field] = data_schema_fields[field].error or "invalid_input"
|
||||
|
||||
if config_validator is not None:
|
||||
if TYPE_CHECKING:
|
||||
assert component_data is not None
|
||||
|
||||
errors |= config_validator(
|
||||
calculate_merged_config(
|
||||
merged_user_input, data_schema_fields, component_data
|
||||
),
|
||||
)
|
||||
|
||||
return merged_user_input, errors
|
||||
|
||||
|
||||
@callback
|
||||
def data_schema_from_fields(
|
||||
data_schema_fields: dict[str, PlatformField],
|
||||
reconfig: bool,
|
||||
component_data: dict[str, Any] | None = None,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
device_data: MqttDeviceData | None = None,
|
||||
) -> vol.Schema:
|
||||
"""Generate data schema from platform fields."""
|
||||
return vol.Schema(
|
||||
{
|
||||
"""Generate custom data schema from platform fields or device data."""
|
||||
if device_data is not None:
|
||||
component_data_with_user_input: dict[str, Any] | None = dict(device_data)
|
||||
if TYPE_CHECKING:
|
||||
assert component_data_with_user_input is not None
|
||||
component_data_with_user_input.update(
|
||||
component_data_with_user_input.pop("mqtt_settings", {})
|
||||
)
|
||||
else:
|
||||
component_data_with_user_input = deepcopy(component_data)
|
||||
if component_data_with_user_input is not None and user_input is not None:
|
||||
component_data_with_user_input |= user_input
|
||||
|
||||
sections: dict[str | None, None] = {
|
||||
field_details.section: None for field_details in data_schema_fields.values()
|
||||
}
|
||||
data_schema: dict[Any, Any] = {}
|
||||
all_data_element_options: set[Any] = set()
|
||||
no_reconfig_options: set[Any] = set()
|
||||
for schema_section in sections:
|
||||
data_schema_element = {
|
||||
vol.Required(field_name, default=field_details.default)
|
||||
if field_details.required
|
||||
else vol.Optional(
|
||||
field_name, default=field_details.default
|
||||
): field_details.selector
|
||||
): field_details.selector(component_data_with_user_input) # type: ignore[operator]
|
||||
if field_details.custom_filtering
|
||||
else field_details.selector
|
||||
for field_name, field_details in data_schema_fields.items()
|
||||
if not field_details.exclude_from_reconfig or not reconfig
|
||||
if field_details.section == schema_section
|
||||
and (not field_details.exclude_from_reconfig or not reconfig)
|
||||
and _check_conditions(field_details, component_data_with_user_input)
|
||||
}
|
||||
)
|
||||
data_element_options = set(data_schema_element)
|
||||
all_data_element_options |= data_element_options
|
||||
no_reconfig_options |= {
|
||||
field_name
|
||||
for field_name, field_details in data_schema_fields.items()
|
||||
if field_details.section == schema_section
|
||||
and field_details.exclude_from_reconfig
|
||||
}
|
||||
if schema_section is None:
|
||||
data_schema.update(data_schema_element)
|
||||
continue
|
||||
collapsed = (
|
||||
not any(
|
||||
(default := data_schema_fields[str(option)].default) is vol.UNDEFINED
|
||||
or component_data_with_user_input[str(option)] != default
|
||||
for option in data_element_options
|
||||
if option in component_data_with_user_input
|
||||
)
|
||||
if component_data_with_user_input is not None
|
||||
else True
|
||||
)
|
||||
data_schema[vol.Optional(schema_section)] = section(
|
||||
vol.Schema(data_schema_element), SectionConfig({"collapsed": collapsed})
|
||||
)
|
||||
|
||||
# Reset all fields from the component_data not in the schema
|
||||
if component_data:
|
||||
filtered_fields = (
|
||||
set(data_schema_fields) - all_data_element_options - no_reconfig_options
|
||||
)
|
||||
for field in filtered_fields:
|
||||
if field in component_data:
|
||||
del component_data[field]
|
||||
return vol.Schema(data_schema)
|
||||
|
||||
|
||||
class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -849,7 +1143,7 @@ class MQTTOptionsFlowHandler(OptionsFlow):
|
||||
"birth_payload", description={"suggested_value": birth[CONF_PAYLOAD]}
|
||||
)
|
||||
] = TEXT_SELECTOR
|
||||
fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_DATA_SCHEMA
|
||||
fields[vol.Optional("birth_qos", default=birth[ATTR_QOS])] = QOS_SELECTOR
|
||||
fields[vol.Optional("birth_retain", default=birth[ATTR_RETAIN])] = (
|
||||
BOOLEAN_SELECTOR
|
||||
)
|
||||
@@ -872,7 +1166,7 @@ class MQTTOptionsFlowHandler(OptionsFlow):
|
||||
"will_payload", description={"suggested_value": will[CONF_PAYLOAD]}
|
||||
)
|
||||
] = TEXT_SELECTOR
|
||||
fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_DATA_SCHEMA
|
||||
fields[vol.Optional("will_qos", default=will[ATTR_QOS])] = QOS_SELECTOR
|
||||
fields[vol.Optional("will_retain", default=will[ATTR_RETAIN])] = (
|
||||
BOOLEAN_SELECTOR
|
||||
)
|
||||
@@ -893,20 +1187,56 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
@callback
|
||||
def update_component_fields(
|
||||
self, data_schema: vol.Schema, user_input: dict[str, Any]
|
||||
self,
|
||||
data_schema_fields: dict[str, PlatformField],
|
||||
merged_user_input: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update the componment fields."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._component_id is not None
|
||||
component_data = self._subentry_data["components"][self._component_id]
|
||||
# Remove the fields from the component data if they are not in the user input
|
||||
for field in [
|
||||
form_field
|
||||
for form_field in data_schema.schema
|
||||
if form_field in component_data and form_field not in user_input
|
||||
]:
|
||||
# Remove the fields from the component data
|
||||
# if they are not in the schema and not in the user input
|
||||
config = calculate_merged_config(
|
||||
merged_user_input, data_schema_fields, component_data
|
||||
)
|
||||
for field in (
|
||||
field
|
||||
for field, platform_field in data_schema_fields.items()
|
||||
if field in (set(component_data) - set(config))
|
||||
and not platform_field.exclude_from_reconfig
|
||||
):
|
||||
component_data.pop(field)
|
||||
component_data.update(user_input)
|
||||
component_data.update(merged_user_input)
|
||||
|
||||
@callback
|
||||
def generate_names(self) -> tuple[str, str]:
|
||||
"""Generate the device and full entity name."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._component_id is not None
|
||||
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
||||
if entity_name := self._subentry_data["components"][self._component_id].get(
|
||||
CONF_NAME
|
||||
):
|
||||
full_entity_name: str = f"{device_name} {entity_name}"
|
||||
else:
|
||||
full_entity_name = device_name
|
||||
return device_name, full_entity_name
|
||||
|
||||
@callback
|
||||
def get_suggested_values_from_component(
|
||||
self, data_schema: vol.Schema
|
||||
) -> dict[str, Any]:
|
||||
"""Get suggestions from component data based on the data schema."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._component_id is not None
|
||||
component_data = self._subentry_data["components"][self._component_id]
|
||||
return {
|
||||
field_key: self.get_suggested_values_from_component(value.schema)
|
||||
if isinstance(value, section)
|
||||
else component_data.get(field_key)
|
||||
for field_key, value in data_schema.schema.items()
|
||||
}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -929,17 +1259,22 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Add a new MQTT device."""
|
||||
errors: dict[str, str] = {}
|
||||
validate_field("configuration_url", cv.url, user_input, errors, "invalid_url")
|
||||
if not errors and user_input is not None:
|
||||
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
return await self.async_step_summary_menu()
|
||||
return await self.async_step_entity()
|
||||
|
||||
errors: dict[str, Any] = {}
|
||||
device_data = self._subentry_data[CONF_DEVICE]
|
||||
data_schema = data_schema_from_fields(
|
||||
MQTT_DEVICE_PLATFORM_FIELDS,
|
||||
device_data=device_data,
|
||||
reconfig=True,
|
||||
)
|
||||
if user_input is not None:
|
||||
_, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS)
|
||||
if not errors:
|
||||
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
return await self.async_step_summary_menu()
|
||||
return await self.async_step_entity()
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
MQTT_DEVICE_SCHEMA,
|
||||
self._subentry_data[CONF_DEVICE] if user_input is None else user_input,
|
||||
data_schema, device_data if user_input is None else user_input
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id=CONF_DEVICE,
|
||||
@@ -956,25 +1291,28 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
data_schema_fields = COMMON_ENTITY_FIELDS
|
||||
entity_name_label: str = ""
|
||||
platform_label: str = ""
|
||||
component_data: dict[str, Any] | None = None
|
||||
if reconfig := (self._component_id is not None):
|
||||
name: str | None = self._subentry_data["components"][
|
||||
self._component_id
|
||||
].get(CONF_NAME)
|
||||
component_data = self._subentry_data["components"][self._component_id]
|
||||
name: str | None = component_data.get(CONF_NAME)
|
||||
platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} "
|
||||
entity_name_label = f" ({name})" if name is not None else ""
|
||||
data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig)
|
||||
if user_input is not None:
|
||||
validate_user_input(user_input, data_schema_fields, errors)
|
||||
merged_user_input, errors = validate_user_input(
|
||||
user_input, data_schema_fields, component_data=component_data
|
||||
)
|
||||
if not errors:
|
||||
if self._component_id is None:
|
||||
self._component_id = uuid4().hex
|
||||
self._subentry_data["components"].setdefault(self._component_id, {})
|
||||
self.update_component_fields(data_schema, user_input)
|
||||
return await self.async_step_mqtt_platform_config()
|
||||
self.update_component_fields(data_schema_fields, merged_user_input)
|
||||
return await self.async_step_entity_platform_config()
|
||||
data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
|
||||
elif self.source == SOURCE_RECONFIGURE and self._component_id is not None:
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
data_schema, self._subentry_data["components"][self._component_id]
|
||||
data_schema,
|
||||
self.get_suggested_values_from_component(data_schema),
|
||||
)
|
||||
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
||||
return self.async_show_form(
|
||||
@@ -994,9 +1332,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
||||
entities = [
|
||||
SelectOptionDict(
|
||||
value=key, label=f"{device_name} {component.get(CONF_NAME, '-')}"
|
||||
value=key,
|
||||
label=f"{device_name} {component_data.get(CONF_NAME, '-')}"
|
||||
f" ({component_data[CONF_PLATFORM]})",
|
||||
)
|
||||
for key, component in self._subentry_data["components"].items()
|
||||
for key, component_data in self._subentry_data["components"].items()
|
||||
]
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
@@ -1034,6 +1374,61 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
return await self.async_step_summary_menu()
|
||||
return self._show_update_or_delete_form("delete_entity")
|
||||
|
||||
async def async_step_entity_platform_config(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Configure platform entity details."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._component_id is not None
|
||||
component_data = self._subentry_data["components"][self._component_id]
|
||||
platform = component_data[CONF_PLATFORM]
|
||||
data_schema_fields = PLATFORM_ENTITY_FIELDS[platform]
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
data_schema = data_schema_from_fields(
|
||||
data_schema_fields,
|
||||
reconfig=bool(
|
||||
{field for field in data_schema_fields if field in component_data}
|
||||
),
|
||||
component_data=component_data,
|
||||
user_input=user_input,
|
||||
)
|
||||
if not data_schema.schema:
|
||||
return await self.async_step_mqtt_platform_config()
|
||||
if user_input is not None:
|
||||
# Test entity fields against the validator
|
||||
merged_user_input, errors = validate_user_input(
|
||||
user_input,
|
||||
data_schema_fields,
|
||||
component_data=component_data,
|
||||
config_validator=ENTITY_CONFIG_VALIDATOR[platform],
|
||||
)
|
||||
if not errors:
|
||||
self.update_component_fields(data_schema_fields, merged_user_input)
|
||||
return await self.async_step_mqtt_platform_config()
|
||||
|
||||
data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
|
||||
else:
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
data_schema,
|
||||
self.get_suggested_values_from_component(data_schema),
|
||||
)
|
||||
|
||||
device_name, full_entity_name = self.generate_names()
|
||||
return self.async_show_form(
|
||||
step_id="entity_platform_config",
|
||||
data_schema=data_schema,
|
||||
description_placeholders={
|
||||
"mqtt_device": device_name,
|
||||
CONF_PLATFORM: platform,
|
||||
"entity": full_entity_name,
|
||||
"url": learn_more_url(platform),
|
||||
}
|
||||
| (user_input or {}),
|
||||
errors=errors,
|
||||
last_step=False,
|
||||
)
|
||||
|
||||
async def async_step_mqtt_platform_config(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
@@ -1041,16 +1436,26 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
errors: dict[str, str] = {}
|
||||
if TYPE_CHECKING:
|
||||
assert self._component_id is not None
|
||||
platform = self._subentry_data["components"][self._component_id][CONF_PLATFORM]
|
||||
data_schema_fields = PLATFORM_MQTT_FIELDS[platform] | COMMON_MQTT_FIELDS
|
||||
component_data = self._subentry_data["components"][self._component_id]
|
||||
platform = component_data[CONF_PLATFORM]
|
||||
data_schema_fields = PLATFORM_MQTT_FIELDS[platform]
|
||||
data_schema = data_schema_from_fields(
|
||||
data_schema_fields, reconfig=self._component_id is not None
|
||||
data_schema_fields,
|
||||
reconfig=bool(
|
||||
{field for field in data_schema_fields if field in component_data}
|
||||
),
|
||||
component_data=component_data,
|
||||
)
|
||||
if user_input is not None:
|
||||
# Test entity fields against the validator
|
||||
validate_user_input(user_input, data_schema_fields, errors)
|
||||
merged_user_input, errors = validate_user_input(
|
||||
user_input,
|
||||
data_schema_fields,
|
||||
component_data=component_data,
|
||||
config_validator=ENTITY_CONFIG_VALIDATOR[platform],
|
||||
)
|
||||
if not errors:
|
||||
self.update_component_fields(data_schema, user_input)
|
||||
self.update_component_fields(data_schema_fields, merged_user_input)
|
||||
self._component_id = None
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
return await self.async_step_summary_menu()
|
||||
@@ -1059,16 +1464,10 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
|
||||
else:
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
data_schema, self._subentry_data["components"][self._component_id]
|
||||
data_schema,
|
||||
self.get_suggested_values_from_component(data_schema),
|
||||
)
|
||||
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
||||
entity_name: str | None
|
||||
if entity_name := self._subentry_data["components"][self._component_id].get(
|
||||
CONF_NAME
|
||||
):
|
||||
full_entity_name: str = f"{device_name} {entity_name}"
|
||||
else:
|
||||
full_entity_name = device_name
|
||||
device_name, full_entity_name = self.generate_names()
|
||||
return self.async_show_form(
|
||||
step_id="mqtt_platform_config",
|
||||
data_schema=data_schema,
|
||||
@@ -1076,6 +1475,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"mqtt_device": device_name,
|
||||
CONF_PLATFORM: platform,
|
||||
"entity": full_entity_name,
|
||||
"url": learn_more_url(platform),
|
||||
},
|
||||
errors=errors,
|
||||
last_step=False,
|
||||
@@ -1087,12 +1487,12 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
) -> SubentryFlowResult:
|
||||
"""Create a subentry for a new MQTT device."""
|
||||
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
||||
component: dict[str, Any] = next(
|
||||
component_data: dict[str, Any] = next(
|
||||
iter(self._subentry_data["components"].values())
|
||||
)
|
||||
platform = component[CONF_PLATFORM]
|
||||
platform = component_data[CONF_PLATFORM]
|
||||
entity_name: str | None
|
||||
if entity_name := component.get(CONF_NAME):
|
||||
if entity_name := component_data.get(CONF_NAME):
|
||||
full_entity_name: str = f"{device_name} {entity_name}"
|
||||
else:
|
||||
full_entity_name = device_name
|
||||
@@ -1151,8 +1551,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
self._component_id = None
|
||||
mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
||||
mqtt_items = ", ".join(
|
||||
f"{mqtt_device} {component.get(CONF_NAME, '-')}"
|
||||
for component in self._subentry_data["components"].values()
|
||||
f"{mqtt_device} {component_data.get(CONF_NAME, '-')} ({component_data[CONF_PLATFORM]})"
|
||||
for component_data in self._subentry_data["components"].values()
|
||||
)
|
||||
menu_options = [
|
||||
"entity",
|
||||
|
||||
@@ -86,6 +86,7 @@ CONF_EFFECT_STATE_TOPIC = "effect_state_topic"
|
||||
CONF_EFFECT_TEMPLATE = "effect_template"
|
||||
CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template"
|
||||
CONF_ENTITY_PICTURE = "entity_picture"
|
||||
CONF_EXPIRE_AFTER = "expire_after"
|
||||
CONF_FLASH_TIME_LONG = "flash_time_long"
|
||||
CONF_FLASH_TIME_SHORT = "flash_time_short"
|
||||
CONF_GREEN_TEMPLATE = "green_template"
|
||||
@@ -93,6 +94,7 @@ CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
|
||||
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
|
||||
CONF_HS_STATE_TOPIC = "hs_state_topic"
|
||||
CONF_HS_VALUE_TEMPLATE = "hs_value_template"
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
|
||||
CONF_MAX_KELVIN = "max_kelvin"
|
||||
CONF_MAX_MIREDS = "max_mireds"
|
||||
CONF_MIN_KELVIN = "min_kelvin"
|
||||
@@ -128,6 +130,7 @@ CONF_STATE_CLOSED = "state_closed"
|
||||
CONF_STATE_CLOSING = "state_closing"
|
||||
CONF_STATE_OPEN = "state_open"
|
||||
CONF_STATE_OPENING = "state_opening"
|
||||
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
|
||||
CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
|
||||
CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template"
|
||||
CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic"
|
||||
|
||||
@@ -123,7 +123,7 @@ from .subscription import (
|
||||
async_subscribe_topics_internal,
|
||||
async_unsubscribe_topics,
|
||||
)
|
||||
from .util import mqtt_config_entry_enabled
|
||||
from .util import learn_more_url, mqtt_config_entry_enabled
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -300,6 +300,7 @@ def async_setup_entity_entry_helper(
|
||||
availability_config = subentry_data.get("availability", {})
|
||||
subentry_entities: list[Entity] = []
|
||||
device_config = subentry_data["device"].copy()
|
||||
device_mqtt_options = device_config.pop("mqtt_settings", {})
|
||||
device_config["identifiers"] = config_subentry_id
|
||||
for component_id, component_data in subentry_data["components"].items():
|
||||
if component_data["platform"] != domain:
|
||||
@@ -311,6 +312,7 @@ def async_setup_entity_entry_helper(
|
||||
component_config[CONF_DEVICE] = device_config
|
||||
component_config.pop("platform")
|
||||
component_config.update(availability_config)
|
||||
component_config.update(device_mqtt_options)
|
||||
|
||||
try:
|
||||
config = platform_schema_modern(component_config)
|
||||
@@ -346,9 +348,6 @@ def async_setup_entity_entry_helper(
|
||||
line = getattr(yaml_config, "__line__", "?")
|
||||
issue_id = hex(hash(frozenset(yaml_config)))
|
||||
yaml_config_str = yaml_dump(yaml_config)
|
||||
learn_more_url = (
|
||||
f"https://www.home-assistant.io/integrations/{domain}.mqtt/"
|
||||
)
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
@@ -356,7 +355,7 @@ def async_setup_entity_entry_helper(
|
||||
issue_domain=domain,
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.ERROR,
|
||||
learn_more_url=learn_more_url,
|
||||
learn_more_url=learn_more_url(domain),
|
||||
translation_placeholders={
|
||||
"domain": domain,
|
||||
"config_file": config_file,
|
||||
|
||||
@@ -420,6 +420,12 @@ class MqttComponentConfig:
|
||||
discovery_payload: MQTTDiscoveryPayload
|
||||
|
||||
|
||||
class DeviceMqttOptions(TypedDict, total=False):
|
||||
"""Hold the shared MQTT specific options for an MQTT device."""
|
||||
|
||||
qos: int
|
||||
|
||||
|
||||
class MqttDeviceData(TypedDict, total=False):
|
||||
"""Hold the data for an MQTT device."""
|
||||
|
||||
@@ -430,6 +436,7 @@ class MqttDeviceData(TypedDict, total=False):
|
||||
hw_version: str
|
||||
model: str
|
||||
model_id: str
|
||||
mqtt_settings: DeviceMqttOptions
|
||||
|
||||
|
||||
class MqttAvailabilityData(TypedDict, total=False):
|
||||
|
||||
@@ -41,7 +41,15 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import subscription
|
||||
from .config import MQTT_RO_SCHEMA
|
||||
from .const import CONF_OPTIONS, CONF_STATE_TOPIC, DOMAIN, PAYLOAD_NONE
|
||||
from .const import (
|
||||
CONF_EXPIRE_AFTER,
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE,
|
||||
CONF_OPTIONS,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_SUGGESTED_DISPLAY_PRECISION,
|
||||
DOMAIN,
|
||||
PAYLOAD_NONE,
|
||||
)
|
||||
from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper
|
||||
from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage
|
||||
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
|
||||
@@ -51,10 +59,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
CONF_EXPIRE_AFTER = "expire_after"
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
|
||||
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
|
||||
|
||||
MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset(
|
||||
{
|
||||
sensor.ATTR_LAST_RESET,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user