forked from home-assistant/core
Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86722ba05e | |||
| be4810731a | |||
| ac6abb363c | |||
| 5367886732 | |||
| 7a51d4ff62 | |||
| ef564c537d | |||
| 082290b092 | |||
| 4a212791a2 | |||
| 6bb55ce79e | |||
| 782ff12e6e | |||
| af6f78a784 | |||
| db32460f3b | |||
| 270990fe39 | |||
| a10fed9d72 | |||
| cc5699bf08 | |||
| ad674a1c2b | |||
| b0269faae4 | |||
| 1143efedc5 | |||
| 9e75b63925 | |||
| 940327dccf | |||
| 0270026f7c | |||
| b636096ac3 | |||
| a243ed5b23 | |||
| 3cf3780587 | |||
| 3d0a0cf376 | |||
| 7aae9d9ad3 | |||
| 870bb7efd4 | |||
| 35a6679ae9 | |||
| a09d0117b1 | |||
| e9fe98f7f9 | |||
| 5b2e188b52 | |||
| c1953e938d | |||
| 77bcbbcf53 | |||
| 97587fae08 | |||
| 01b54fe1a9 | |||
| f796950493 | |||
| 495fd946bc | |||
| 6af1e25d7e | |||
| 6d47a4d7e4 | |||
| fd5533d719 | |||
| d530137bec | |||
| 4f722e864c | |||
| 62d38e786d | |||
| 859874487e | |||
| b16bf29819 | |||
| 6b10dbb38c | |||
| ea20c4b375 | |||
| 0427aeccb0 | |||
| 4898ba932d | |||
| 35a3d2306c | |||
| cdb378066c | |||
| 85700fd80f | |||
| 73a2ad7304 | |||
| f6c4b6b045 | |||
| 0b4d921762 | |||
| c8a0e5228d | |||
| 832bac8c63 | |||
| eccce7017f | |||
| fdb1baadbe | |||
| 7623ee49e4 | |||
| fa241dcd04 | |||
| bee77041e8 | |||
| 50b7eb44d1 | |||
| 7b1bf82e3c | |||
| fe82e7f24d | |||
| 433c1a57e7 | |||
| b36059fc64 | |||
| 13c9d69440 | |||
| 9c7134a865 | |||
| d7cc2a7e9a | |||
| f9276e28b0 | |||
| 15ad6db1a7 | |||
| c1043ada22 | |||
| d141122008 | |||
| abeba39842 | |||
| bb597a908d | |||
| dcae2f35ce | |||
| b06a5af069 | |||
| a624ada8d6 | |||
| d87366b1e7 | |||
| 5ce8a2d974 | |||
| a42615add0 | |||
| ecbff61332 | |||
| e9bfe82582 | |||
| 55abe68a5f | |||
| acf523b5fb | |||
| 0216455137 | |||
| cb37ae6608 | |||
| 3b462906d9 | |||
| dfb4e9c159 | |||
| 6a6814af61 | |||
| 1a7085b068 | |||
| 804d7aa4c0 | |||
| 1b1d86409c | |||
| 2520fcd284 |
@@ -18,9 +18,12 @@ from homeassistant.const import (
|
||||
EVENT_THEMES_UPDATED,
|
||||
)
|
||||
from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.category_registry import EVENT_CATEGORY_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.floor_registry import EVENT_FLOOR_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED
|
||||
from homeassistant.helpers.label_registry import EVENT_LABEL_REGISTRY_UPDATED
|
||||
from homeassistant.util.event_type import EventType
|
||||
|
||||
# These are events that do not contain any sensitive data
|
||||
@@ -41,4 +44,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
|
||||
EVENT_SHOPPING_LIST_UPDATED,
|
||||
EVENT_STATE_CHANGED,
|
||||
EVENT_THEMES_UPDATED,
|
||||
EVENT_LABEL_REGISTRY_UPDATED,
|
||||
EVENT_CATEGORY_REGISTRY_UPDATED,
|
||||
EVENT_FLOOR_REGISTRY_UPDATED,
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"codeowners": ["@home-assistant/cloud", "@ochlocracy", "@jbouwh"],
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/alexa",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ from .const import (
|
||||
DATA_LAST_WAKE_UP,
|
||||
DOMAIN,
|
||||
EVENT_RECORDING,
|
||||
SAMPLE_CHANNELS,
|
||||
SAMPLE_RATE,
|
||||
SAMPLE_WIDTH,
|
||||
SAMPLES_PER_CHUNK,
|
||||
)
|
||||
from .error import PipelineNotFound
|
||||
from .pipeline import (
|
||||
@@ -53,6 +57,10 @@ __all__ = (
|
||||
"PipelineNotFound",
|
||||
"WakeWordSettings",
|
||||
"EVENT_RECORDING",
|
||||
"SAMPLES_PER_CHUNK",
|
||||
"SAMPLE_RATE",
|
||||
"SAMPLE_WIDTH",
|
||||
"SAMPLE_CHANNELS",
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
@@ -6,6 +6,8 @@ import logging
|
||||
|
||||
from pymicro_vad import MicroVad
|
||||
|
||||
from .const import BYTES_PER_CHUNK
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -38,11 +40,6 @@ class AudioEnhancer(ABC):
|
||||
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
|
||||
"""Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def samples_per_chunk(self) -> int | None:
|
||||
"""Return number of samples per chunk or None if chunking isn't required."""
|
||||
|
||||
|
||||
class MicroVadEnhancer(AudioEnhancer):
|
||||
"""Audio enhancer that just runs microVAD."""
|
||||
@@ -61,22 +58,15 @@ class MicroVadEnhancer(AudioEnhancer):
|
||||
_LOGGER.debug("Initialized microVAD with threshold=%s", self.threshold)
|
||||
|
||||
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
|
||||
"""Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
|
||||
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
|
||||
is_speech: bool | None = None
|
||||
|
||||
if self.vad is not None:
|
||||
# Run VAD
|
||||
assert len(audio) == BYTES_PER_CHUNK
|
||||
speech_prob = self.vad.Process10ms(audio)
|
||||
is_speech = speech_prob > self.threshold
|
||||
|
||||
return EnhancedAudioChunk(
|
||||
audio=audio, timestamp_ms=timestamp_ms, is_speech=is_speech
|
||||
)
|
||||
|
||||
@property
|
||||
def samples_per_chunk(self) -> int | None:
|
||||
"""Return number of samples per chunk or None if chunking isn't required."""
|
||||
if self.is_vad_enabled:
|
||||
return 160 # 10ms
|
||||
|
||||
return None
|
||||
|
||||
@@ -19,4 +19,6 @@ EVENT_RECORDING = f"{DOMAIN}_recording"
|
||||
SAMPLE_RATE = 16000 # hertz
|
||||
SAMPLE_WIDTH = 2 # bytes
|
||||
SAMPLE_CHANNELS = 1 # mono
|
||||
SAMPLES_PER_CHUNK = 240 # 20 ms @ 16Khz
|
||||
MS_PER_CHUNK = 10
|
||||
SAMPLES_PER_CHUNK = SAMPLE_RATE // (1000 // MS_PER_CHUNK) # 10 ms @ 16Khz
|
||||
BYTES_PER_CHUNK = SAMPLES_PER_CHUNK * SAMPLE_WIDTH * SAMPLE_CHANNELS # 16-bit
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@balloob", "@synesthesiam"],
|
||||
"dependencies": ["conversation", "stt", "tts", "wake_word"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_pipeline",
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["pymicro-vad==1.0.1"]
|
||||
|
||||
@@ -51,11 +51,13 @@ from homeassistant.util.limited_size_dict import LimitedSizeDict
|
||||
|
||||
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadEnhancer
|
||||
from .const import (
|
||||
BYTES_PER_CHUNK,
|
||||
CONF_DEBUG_RECORDING_DIR,
|
||||
DATA_CONFIG,
|
||||
DATA_LAST_WAKE_UP,
|
||||
DATA_MIGRATIONS,
|
||||
DOMAIN,
|
||||
MS_PER_CHUNK,
|
||||
SAMPLE_CHANNELS,
|
||||
SAMPLE_RATE,
|
||||
SAMPLE_WIDTH,
|
||||
@@ -502,9 +504,6 @@ class AudioSettings:
|
||||
is_vad_enabled: bool = True
|
||||
"""True if VAD is used to determine the end of the voice command."""
|
||||
|
||||
samples_per_chunk: int | None = None
|
||||
"""Number of samples that will be in each audio chunk (None for no chunking)."""
|
||||
|
||||
silence_seconds: float = 0.5
|
||||
"""Seconds of silence after voice command has ended."""
|
||||
|
||||
@@ -525,11 +524,6 @@ class AudioSettings:
|
||||
or (self.auto_gain_dbfs > 0)
|
||||
)
|
||||
|
||||
@property
|
||||
def is_chunking_enabled(self) -> bool:
|
||||
"""True if chunk size is set."""
|
||||
return self.samples_per_chunk is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineRun:
|
||||
@@ -566,7 +560,9 @@ class PipelineRun:
|
||||
audio_enhancer: AudioEnhancer | None = None
|
||||
"""VAD/noise suppression/auto gain"""
|
||||
|
||||
audio_chunking_buffer: AudioBuffer | None = None
|
||||
audio_chunking_buffer: AudioBuffer = field(
|
||||
default_factory=lambda: AudioBuffer(BYTES_PER_CHUNK)
|
||||
)
|
||||
"""Buffer used when splitting audio into chunks for audio processing"""
|
||||
|
||||
_device_id: str | None = None
|
||||
@@ -599,8 +595,6 @@ class PipelineRun:
|
||||
self.audio_settings.is_vad_enabled,
|
||||
)
|
||||
|
||||
self.audio_chunking_buffer = AudioBuffer(self.samples_per_chunk * SAMPLE_WIDTH)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""Compare pipeline runs by id."""
|
||||
if isinstance(other, PipelineRun):
|
||||
@@ -608,14 +602,6 @@ class PipelineRun:
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def samples_per_chunk(self) -> int:
|
||||
"""Return number of samples expected in each audio chunk."""
|
||||
if self.audio_enhancer is not None:
|
||||
return self.audio_enhancer.samples_per_chunk or SAMPLES_PER_CHUNK
|
||||
|
||||
return self.audio_settings.samples_per_chunk or SAMPLES_PER_CHUNK
|
||||
|
||||
@callback
|
||||
def process_event(self, event: PipelineEvent) -> None:
|
||||
"""Log an event and call listener."""
|
||||
@@ -728,7 +714,7 @@ class PipelineRun:
|
||||
# after wake-word-detection.
|
||||
num_audio_chunks_to_buffer = int(
|
||||
(wake_word_settings.audio_seconds_to_buffer * SAMPLE_RATE)
|
||||
/ self.samples_per_chunk
|
||||
/ SAMPLES_PER_CHUNK
|
||||
)
|
||||
|
||||
stt_audio_buffer: deque[EnhancedAudioChunk] | None = None
|
||||
@@ -1216,60 +1202,31 @@ class PipelineRun:
|
||||
self.debug_recording_thread = None
|
||||
|
||||
async def process_volume_only(
|
||||
self,
|
||||
audio_stream: AsyncIterable[bytes],
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
sample_width: int = SAMPLE_WIDTH,
|
||||
self, audio_stream: AsyncIterable[bytes]
|
||||
) -> AsyncGenerator[EnhancedAudioChunk]:
|
||||
"""Apply volume transformation only (no VAD/audio enhancements) with optional chunking."""
|
||||
assert self.audio_chunking_buffer is not None
|
||||
|
||||
bytes_per_chunk = self.samples_per_chunk * sample_width
|
||||
ms_per_sample = sample_rate // 1000
|
||||
ms_per_chunk = self.samples_per_chunk // ms_per_sample
|
||||
timestamp_ms = 0
|
||||
|
||||
async for chunk in audio_stream:
|
||||
if self.audio_settings.volume_multiplier != 1.0:
|
||||
chunk = _multiply_volume(chunk, self.audio_settings.volume_multiplier)
|
||||
|
||||
if self.audio_settings.is_chunking_enabled:
|
||||
for sub_chunk in chunk_samples(
|
||||
chunk, bytes_per_chunk, self.audio_chunking_buffer
|
||||
):
|
||||
yield EnhancedAudioChunk(
|
||||
audio=sub_chunk,
|
||||
timestamp_ms=timestamp_ms,
|
||||
is_speech=None, # no VAD
|
||||
)
|
||||
timestamp_ms += ms_per_chunk
|
||||
else:
|
||||
# No chunking
|
||||
for sub_chunk in chunk_samples(
|
||||
chunk, BYTES_PER_CHUNK, self.audio_chunking_buffer
|
||||
):
|
||||
yield EnhancedAudioChunk(
|
||||
audio=chunk,
|
||||
audio=sub_chunk,
|
||||
timestamp_ms=timestamp_ms,
|
||||
is_speech=None, # no VAD
|
||||
)
|
||||
timestamp_ms += (len(chunk) // sample_width) // ms_per_sample
|
||||
timestamp_ms += MS_PER_CHUNK
|
||||
|
||||
async def process_enhance_audio(
|
||||
self,
|
||||
audio_stream: AsyncIterable[bytes],
|
||||
sample_rate: int = SAMPLE_RATE,
|
||||
sample_width: int = SAMPLE_WIDTH,
|
||||
self, audio_stream: AsyncIterable[bytes]
|
||||
) -> AsyncGenerator[EnhancedAudioChunk]:
|
||||
"""Split audio into 10 ms chunks and apply VAD/noise suppression/auto gain/volume transformation."""
|
||||
"""Split audio into chunks and apply VAD/noise suppression/auto gain/volume transformation."""
|
||||
assert self.audio_enhancer is not None
|
||||
assert self.audio_enhancer.samples_per_chunk is not None
|
||||
assert self.audio_chunking_buffer is not None
|
||||
|
||||
bytes_per_chunk = self.audio_enhancer.samples_per_chunk * sample_width
|
||||
ms_per_sample = sample_rate // 1000
|
||||
ms_per_chunk = (
|
||||
self.audio_enhancer.samples_per_chunk // sample_width
|
||||
) // ms_per_sample
|
||||
timestamp_ms = 0
|
||||
|
||||
async for dirty_samples in audio_stream:
|
||||
if self.audio_settings.volume_multiplier != 1.0:
|
||||
# Static gain
|
||||
@@ -1279,10 +1236,10 @@ class PipelineRun:
|
||||
|
||||
# Split into chunks for audio enhancements/VAD
|
||||
for dirty_chunk in chunk_samples(
|
||||
dirty_samples, bytes_per_chunk, self.audio_chunking_buffer
|
||||
dirty_samples, BYTES_PER_CHUNK, self.audio_chunking_buffer
|
||||
):
|
||||
yield self.audio_enhancer.enhance_chunk(dirty_chunk, timestamp_ms)
|
||||
timestamp_ms += ms_per_chunk
|
||||
timestamp_ms += MS_PER_CHUNK
|
||||
|
||||
|
||||
def _multiply_volume(chunk: bytes, volume_multiplier: float) -> bytes:
|
||||
|
||||
@@ -255,7 +255,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
self._attr_unique_id = format_unique_id(sync_status.mac, port)
|
||||
# there should always be one player with the default port per mac
|
||||
if port is DEFAULT_PORT:
|
||||
if port == DEFAULT_PORT:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, format_mac(sync_status.mac))},
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.7.29"]
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.8.7"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/coolmaster",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pycoolmasternet_async"],
|
||||
"requirements": ["pycoolmasternet-async==0.2.0"]
|
||||
"requirements": ["pycoolmasternet-async==0.1.5"]
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from doorbirdpy import DoorBird
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
@@ -17,6 +17,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -30,6 +31,8 @@ CONF_CUSTOM_URL = "hass_url_override"
|
||||
|
||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the DoorBird component."""
|
||||
@@ -68,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) ->
|
||||
door_bird_data = DoorBirdData(door_station, info, event_entity_ids)
|
||||
door_station.update_events(events)
|
||||
# Subscribe to doorbell or motion events
|
||||
if not await _async_register_events(hass, door_station):
|
||||
if not await _async_register_events(hass, door_station, entry):
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_update_listener))
|
||||
@@ -84,24 +87,30 @@ async def async_unload_entry(hass: HomeAssistant, entry: DoorBirdConfigEntry) ->
|
||||
|
||||
|
||||
async def _async_register_events(
|
||||
hass: HomeAssistant, door_station: ConfiguredDoorBird
|
||||
hass: HomeAssistant, door_station: ConfiguredDoorBird, entry: DoorBirdConfigEntry
|
||||
) -> bool:
|
||||
"""Register events on device."""
|
||||
issue_id = f"doorbird_schedule_error_{entry.entry_id}"
|
||||
try:
|
||||
await door_station.async_register_events()
|
||||
except ClientResponseError:
|
||||
persistent_notification.async_create(
|
||||
except ClientResponseError as ex:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
(
|
||||
"Doorbird configuration failed. Please verify that API "
|
||||
"Operator permission is enabled for the Doorbird user. "
|
||||
"A restart will be required once permissions have been "
|
||||
"verified."
|
||||
),
|
||||
title="Doorbird Configuration Failure",
|
||||
notification_id="doorbird_schedule_error",
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="error_registering_events",
|
||||
data={"entry_id": entry.entry_id},
|
||||
is_fixable=True,
|
||||
translation_placeholders={
|
||||
"error": str(ex),
|
||||
"name": door_station.name or entry.data[CONF_NAME],
|
||||
},
|
||||
)
|
||||
_LOGGER.debug("Error registering DoorBird events", exc_info=True)
|
||||
return False
|
||||
else:
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
return True
|
||||
|
||||
@@ -111,4 +120,4 @@ async def _update_listener(hass: HomeAssistant, entry: DoorBirdConfigEntry) -> N
|
||||
door_station = entry.runtime_data.door_station
|
||||
door_station.update_events(entry.options[CONF_EVENTS])
|
||||
# Subscribe to doorbell or motion events
|
||||
await _async_register_events(hass, door_station)
|
||||
await _async_register_events(hass, door_station, entry)
|
||||
|
||||
@@ -5,9 +5,11 @@ from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from doorbirdpy import (
|
||||
DoorBird,
|
||||
DoorBirdScheduleEntry,
|
||||
@@ -170,15 +172,21 @@ class ConfiguredDoorBird:
|
||||
) -> DoorbirdEventConfig:
|
||||
"""Get events and unconfigured favorites from http favorites."""
|
||||
device = self.device
|
||||
schedule = await device.schedule()
|
||||
events: list[DoorbirdEvent] = []
|
||||
unconfigured_favorites: defaultdict[str, list[str]] = defaultdict(list)
|
||||
try:
|
||||
schedule = await device.schedule()
|
||||
except ClientResponseError as ex:
|
||||
if ex.status == HTTPStatus.NOT_FOUND:
|
||||
# D301 models do not support schedules
|
||||
return DoorbirdEventConfig(events, [], unconfigured_favorites)
|
||||
raise
|
||||
favorite_input_type = {
|
||||
output.param: entry.input
|
||||
for entry in schedule
|
||||
for output in entry.output
|
||||
if output.event == HTTP_EVENT_TYPE
|
||||
}
|
||||
events: list[DoorbirdEvent] = []
|
||||
unconfigured_favorites: defaultdict[str, list[str]] = defaultdict(list)
|
||||
default_event_types = {
|
||||
self._get_event_name(event): event_type
|
||||
for event, event_type in DEFAULT_EVENT_TYPES
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "DoorBird",
|
||||
"codeowners": ["@oblogic7", "@bdraco", "@flacjacket"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"dependencies": ["http", "repairs"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/doorbird",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["doorbirdpy"],
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Repairs for DoorBird."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
|
||||
class DoorBirdReloadConfirmRepairFlow(RepairsFlow):
|
||||
"""Handler to show doorbird error and reload."""
|
||||
|
||||
def __init__(self, entry_id: str) -> None:
|
||||
"""Initialize the flow."""
|
||||
self.entry_id = entry_id
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
self.hass.config_entries.async_schedule_reload(self.entry_id)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
issue_registry = ir.async_get(self.hass)
|
||||
description_placeholders = None
|
||||
if issue := issue_registry.async_get_issue(self.handler, self.issue_id):
|
||||
description_placeholders = issue.translation_placeholders
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
assert data is not None
|
||||
entry_id = data["entry_id"]
|
||||
assert isinstance(entry_id, str)
|
||||
return DoorBirdReloadConfirmRepairFlow(entry_id=entry_id)
|
||||
@@ -11,6 +11,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"error_registering_events": {
|
||||
"title": "DoorBird {name} configuration failure",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::doorbird::issues::error_registering_events::title%]",
|
||||
"description": "Configuring DoorBird {name} failed with error: `{error}`. Please enable the API Operator permission for the DoorBird user and continue to reload the integration."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
|
||||
@@ -439,7 +439,9 @@ def rename_old_gas_to_mbus(
|
||||
entries = er.async_entries_for_device(ent_reg, device_id)
|
||||
|
||||
for entity in entries:
|
||||
if entity.unique_id.endswith("belgium_5min_gas_meter_reading"):
|
||||
if entity.unique_id.endswith(
|
||||
"belgium_5min_gas_meter_reading"
|
||||
) or entity.unique_id.endswith("hourly_gas_meter_reading"):
|
||||
try:
|
||||
ent_reg.async_update_entity(
|
||||
entity.entity_id,
|
||||
|
||||
@@ -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==8.2.0"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==8.3.0"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"requirements": ["pyenphase==1.20.6"],
|
||||
"requirements": ["pyenphase==1.22.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -346,7 +346,7 @@ class ESPHomeManager:
|
||||
) -> int | None:
|
||||
"""Start a voice assistant pipeline."""
|
||||
if self.voice_assistant_pipeline is not None:
|
||||
_LOGGER.warning("Voice assistant UDP server was not stopped")
|
||||
_LOGGER.warning("Previous Voice assistant pipeline was not stopped")
|
||||
self.voice_assistant_pipeline.stop()
|
||||
self.voice_assistant_pipeline = None
|
||||
|
||||
@@ -654,12 +654,13 @@ def _async_setup_device_registry(
|
||||
if device_info.manufacturer:
|
||||
manufacturer = device_info.manufacturer
|
||||
model = device_info.model
|
||||
hw_version = None
|
||||
if device_info.project_name:
|
||||
project_name = device_info.project_name.split(".")
|
||||
manufacturer = project_name[0]
|
||||
model = project_name[1]
|
||||
hw_version = device_info.project_version
|
||||
sw_version = (
|
||||
f"{device_info.project_version} (ESPHome {device_info.esphome_version})"
|
||||
)
|
||||
|
||||
suggested_area = None
|
||||
if device_info.suggested_area:
|
||||
@@ -674,7 +675,6 @@ def _async_setup_device_registry(
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
sw_version=sw_version,
|
||||
hw_version=hw_version,
|
||||
suggested_area=suggested_area,
|
||||
)
|
||||
return device_entry.id
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==24.6.2",
|
||||
"aioesphomeapi==25.0.0",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==1.0.0"
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
from aioesphomeapi import (
|
||||
DeviceInfo as ESPHomeDeviceInfo,
|
||||
EntityInfo,
|
||||
UpdateCommand,
|
||||
UpdateInfo,
|
||||
UpdateState,
|
||||
)
|
||||
@@ -259,9 +260,15 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
|
||||
"""Return the title of the update."""
|
||||
return self._state.title
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_update(self) -> None:
|
||||
"""Command device to check for update."""
|
||||
if self.available:
|
||||
self._client.update_command(key=self._key, command=UpdateCommand.CHECK)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
) -> None:
|
||||
"""Update the current value."""
|
||||
self._client.update_command(key=self._key, install=True)
|
||||
"""Command device to install update."""
|
||||
self._client.update_command(key=self._key, command=UpdateCommand.INSTALL)
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"name": "FFmpeg",
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ffmpeg",
|
||||
"integration_type": "system",
|
||||
"requirements": ["ha-ffmpeg==3.2.0"]
|
||||
}
|
||||
|
||||
@@ -398,6 +398,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
static_paths_configs: list[StaticPathConfig] = []
|
||||
|
||||
for path, should_cache in (
|
||||
("service_worker.js", False),
|
||||
("sw-modern.js", False),
|
||||
("sw-modern.js.map", False),
|
||||
("sw-legacy.js", False),
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20240731.0"]
|
||||
"requirements": ["home-assistant-frontend==20240806.1"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
"codeowners": ["@home-assistant/cloud"],
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_assistant",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push"
|
||||
}
|
||||
|
||||
@@ -59,7 +59,10 @@ def tts_options_schema(
|
||||
vol.Optional(
|
||||
CONF_GENDER,
|
||||
description={"suggested_value": config_options.get(CONF_GENDER)},
|
||||
default=texttospeech.SsmlVoiceGender.NEUTRAL.name, # type: ignore[attr-defined]
|
||||
default=config_options.get(
|
||||
CONF_GENDER,
|
||||
texttospeech.SsmlVoiceGender.NEUTRAL.name, # type: ignore[attr-defined]
|
||||
),
|
||||
): vol.All(
|
||||
vol.Upper,
|
||||
SelectSelector(
|
||||
@@ -72,7 +75,7 @@ def tts_options_schema(
|
||||
vol.Optional(
|
||||
CONF_VOICE,
|
||||
description={"suggested_value": config_options.get(CONF_VOICE)},
|
||||
default=DEFAULT_VOICE,
|
||||
default=config_options.get(CONF_VOICE, DEFAULT_VOICE),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
@@ -82,7 +85,10 @@ def tts_options_schema(
|
||||
vol.Optional(
|
||||
CONF_ENCODING,
|
||||
description={"suggested_value": config_options.get(CONF_ENCODING)},
|
||||
default=texttospeech.AudioEncoding.MP3.name, # type: ignore[attr-defined]
|
||||
default=config_options.get(
|
||||
CONF_ENCODING,
|
||||
texttospeech.AudioEncoding.MP3.name, # type: ignore[attr-defined]
|
||||
),
|
||||
): vol.All(
|
||||
vol.Upper,
|
||||
SelectSelector(
|
||||
@@ -95,22 +101,22 @@ def tts_options_schema(
|
||||
vol.Optional(
|
||||
CONF_SPEED,
|
||||
description={"suggested_value": config_options.get(CONF_SPEED)},
|
||||
default=1.0,
|
||||
default=config_options.get(CONF_SPEED, 1.0),
|
||||
): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)),
|
||||
vol.Optional(
|
||||
CONF_PITCH,
|
||||
description={"suggested_value": config_options.get(CONF_PITCH)},
|
||||
default=0,
|
||||
default=config_options.get(CONF_PITCH, 0),
|
||||
): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)),
|
||||
vol.Optional(
|
||||
CONF_GAIN,
|
||||
description={"suggested_value": config_options.get(CONF_GAIN)},
|
||||
default=0,
|
||||
default=config_options.get(CONF_GAIN, 0),
|
||||
): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)),
|
||||
vol.Optional(
|
||||
CONF_PROFILES,
|
||||
description={"suggested_value": config_options.get(CONF_PROFILES)},
|
||||
default=[],
|
||||
default=config_options.get(CONF_PROFILES, []),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
@@ -132,7 +138,7 @@ def tts_options_schema(
|
||||
vol.Optional(
|
||||
CONF_TEXT_TYPE,
|
||||
description={"suggested_value": config_options.get(CONF_TEXT_TYPE)},
|
||||
default="text",
|
||||
default=config_options.get(CONF_TEXT_TYPE, "text"),
|
||||
): vol.All(
|
||||
vol.Lower,
|
||||
SelectSelector(
|
||||
|
||||
@@ -89,9 +89,9 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
key = "type_"
|
||||
val = val.upper()
|
||||
elif key == "format":
|
||||
if (schema.get("type") == "string" and val != "enum") or (
|
||||
schema.get("type") not in ("number", "integer", "string")
|
||||
):
|
||||
if schema.get("type") == "string" and val != "enum":
|
||||
continue
|
||||
if schema.get("type") not in ("number", "integer", "string"):
|
||||
continue
|
||||
key = "format_"
|
||||
elif key == "items":
|
||||
@@ -100,11 +100,19 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
val = {k: _format_schema(v) for k, v in val.items()}
|
||||
result[key] = val
|
||||
|
||||
if result.get("enum") and result.get("type_") != "STRING":
|
||||
# enum is only allowed for STRING type. This is safe as long as the schema
|
||||
# contains vol.Coerce for the respective type, for example:
|
||||
# vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
|
||||
result["type_"] = "STRING"
|
||||
result["enum"] = [str(item) for item in result["enum"]]
|
||||
|
||||
if result.get("type_") == "OBJECT" and not result.get("properties"):
|
||||
# An object with undefined properties is not supported by Gemini API.
|
||||
# Fallback to JSON string. This will probably fail for most tools that want it,
|
||||
# but we don't have a better fallback strategy so far.
|
||||
result["properties"] = {"json": {"type_": "STRING"}}
|
||||
result["required"] = []
|
||||
return result
|
||||
|
||||
|
||||
@@ -164,6 +172,10 @@ class GoogleGenerativeAIConversationEntity(
|
||||
model="Generative AI",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
if self.entry.options.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str] | Literal["*"]:
|
||||
@@ -177,6 +189,9 @@ class GoogleGenerativeAIConversationEntity(
|
||||
self.hass, "conversation", self.entry.entry_id, self.entity_id
|
||||
)
|
||||
conversation.async_set_agent(self.hass, self.entry, self)
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When entity will be removed from Home Assistant."""
|
||||
@@ -397,3 +412,10 @@ class GoogleGenerativeAIConversationEntity(
|
||||
parts.append(llm_api.api_prompt)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -8,7 +8,11 @@ import logging
|
||||
from googlemaps import Client
|
||||
from googlemaps.distance_matrix import distance_matrix
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
@@ -72,6 +76,8 @@ class GoogleTravelTimeSensor(SensorEntity):
|
||||
|
||||
_attr_attribution = ATTRIBUTION
|
||||
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
|
||||
_attr_device_class = SensorDeviceClass.DURATION
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, config_entry, name, api_key, origin, destination, client):
|
||||
"""Initialize the sensor."""
|
||||
|
||||
@@ -18,3 +18,5 @@ FAN_MEDIUM_HIGH = "medium high"
|
||||
MAX_ERRORS = 2
|
||||
|
||||
TARGET_TEMPERATURE_STEP = 1
|
||||
|
||||
UPDATE_INTERVAL = 60
|
||||
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from greeclimate.device import Device, DeviceInfo
|
||||
from greeclimate.discovery import Discovery, Listener
|
||||
from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError
|
||||
from greeclimate.network import Response
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import (
|
||||
COORDINATORS,
|
||||
@@ -19,12 +23,13 @@ from .const import (
|
||||
DISPATCH_DEVICE_DISCOVERED,
|
||||
DOMAIN,
|
||||
MAX_ERRORS,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeviceDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Manages polling for state changes from the device."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, device: Device) -> None:
|
||||
@@ -34,28 +39,68 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN}-{device.device_info.name}",
|
||||
update_interval=timedelta(seconds=60),
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
always_update=False,
|
||||
)
|
||||
self.device = device
|
||||
self._error_count = 0
|
||||
self.device.add_handler(Response.DATA, self.device_state_updated)
|
||||
self.device.add_handler(Response.RESULT, self.device_state_updated)
|
||||
|
||||
async def _async_update_data(self):
|
||||
self._error_count: int = 0
|
||||
self._last_response_time: datetime = utcnow()
|
||||
self._last_error_time: datetime | None = None
|
||||
|
||||
def device_state_updated(self, *args: Any) -> None:
|
||||
"""Handle device state updates."""
|
||||
_LOGGER.debug("Device state updated: %s", json_dumps(args))
|
||||
self._error_count = 0
|
||||
self._last_response_time = utcnow()
|
||||
self.async_set_updated_data(self.device.raw_properties)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update the state of the device."""
|
||||
_LOGGER.debug(
|
||||
"Updating device state: %s, error count: %d", self.name, self._error_count
|
||||
)
|
||||
try:
|
||||
await self.device.update_state()
|
||||
except DeviceNotBoundError as error:
|
||||
raise UpdateFailed(f"Device {self.name} is unavailable") from error
|
||||
raise UpdateFailed(
|
||||
f"Device {self.name} is unavailable, device is not bound."
|
||||
) from error
|
||||
except DeviceTimeoutError as error:
|
||||
self._error_count += 1
|
||||
|
||||
# Under normal conditions GREE units timeout every once in a while
|
||||
if self.last_update_success and self._error_count >= MAX_ERRORS:
|
||||
_LOGGER.warning(
|
||||
"Device is unavailable: %s (%s)",
|
||||
self.name,
|
||||
self.device.device_info,
|
||||
"Device %s is unavailable: %s", self.name, self.device.device_info
|
||||
)
|
||||
raise UpdateFailed(f"Device {self.name} is unavailable") from error
|
||||
raise UpdateFailed(
|
||||
f"Device {self.name} is unavailable, could not send update request"
|
||||
) from error
|
||||
else:
|
||||
# raise update failed if time for more than MAX_ERRORS has passed since last update
|
||||
now = utcnow()
|
||||
elapsed_success = now - self._last_response_time
|
||||
if self.update_interval and elapsed_success >= self.update_interval:
|
||||
if not self._last_error_time or (
|
||||
(now - self.update_interval) >= self._last_error_time
|
||||
):
|
||||
self._last_error_time = now
|
||||
self._error_count += 1
|
||||
|
||||
_LOGGER.warning(
|
||||
"Device %s is unresponsive for %s seconds",
|
||||
self.name,
|
||||
elapsed_success,
|
||||
)
|
||||
if self.last_update_success and self._error_count >= MAX_ERRORS:
|
||||
raise UpdateFailed(
|
||||
f"Device {self.name} is unresponsive for too long and now unavailable"
|
||||
)
|
||||
|
||||
return self.device.raw_properties
|
||||
|
||||
async def push_state_update(self):
|
||||
"""Send state updates to the physical device."""
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/gree",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["greeclimate"],
|
||||
"requirements": ["greeclimate==1.4.6"]
|
||||
"requirements": ["greeclimate==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -327,14 +327,14 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_battery_2_discharge_w",
|
||||
translation_key="tlx_battery_2_discharge_w",
|
||||
api_key="bdc1DischargePower",
|
||||
api_key="bdc2DischargePower",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_battery_2_discharge_total",
|
||||
translation_key="tlx_battery_2_discharge_total",
|
||||
api_key="bdc1DischargeTotal",
|
||||
api_key="bdc2DischargeTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -376,14 +376,14 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_battery_2_charge_w",
|
||||
translation_key="tlx_battery_2_charge_w",
|
||||
api_key="bdc1ChargePower",
|
||||
api_key="bdc2ChargePower",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_battery_2_charge_total",
|
||||
translation_key="tlx_battery_2_charge_total",
|
||||
api_key="bdc1ChargeTotal",
|
||||
api_key="bdc2ChargeTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
|
||||
@@ -3,81 +3,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from aiohttp import hdrs
|
||||
from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE
|
||||
from aiohttp.web import FileResponse, Request, StreamResponse
|
||||
from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound
|
||||
from aiohttp.web_fileresponse import CONTENT_TYPES, FALLBACK_CONTENT_TYPE
|
||||
from aiohttp.web_urldispatcher import StaticResource
|
||||
from lru import LRU
|
||||
|
||||
from .const import KEY_HASS
|
||||
|
||||
CACHE_TIME: Final = 31 * 86400 # = 1 month
|
||||
CACHE_HEADER = f"public, max-age={CACHE_TIME}"
|
||||
CACHE_HEADERS: Mapping[str, str] = {hdrs.CACHE_CONTROL: CACHE_HEADER}
|
||||
PATH_CACHE: LRU[tuple[str, Path], tuple[Path | None, str | None]] = LRU(512)
|
||||
|
||||
|
||||
def _get_file_path(rel_url: str, directory: Path) -> Path | None:
|
||||
"""Return the path to file on disk or None."""
|
||||
filename = Path(rel_url)
|
||||
if filename.anchor:
|
||||
# rel_url is an absolute name like
|
||||
# /static/\\machine_name\c$ or /static/D:\path
|
||||
# where the static dir is totally different
|
||||
raise HTTPForbidden
|
||||
filepath: Path = directory.joinpath(filename).resolve()
|
||||
filepath.relative_to(directory)
|
||||
# on opening a dir, load its contents if allowed
|
||||
if filepath.is_dir():
|
||||
return None
|
||||
if filepath.is_file():
|
||||
return filepath
|
||||
raise FileNotFoundError
|
||||
CACHE_HEADERS: Mapping[str, str] = {CACHE_CONTROL: CACHE_HEADER}
|
||||
RESPONSE_CACHE: LRU[tuple[str, Path], tuple[Path, str]] = LRU(512)
|
||||
|
||||
|
||||
class CachingStaticResource(StaticResource):
|
||||
"""Static Resource handler that will add cache headers."""
|
||||
|
||||
async def _handle(self, request: Request) -> StreamResponse:
|
||||
"""Return requested file from disk as a FileResponse."""
|
||||
"""Wrap base handler to cache file path resolution and content type guess."""
|
||||
rel_url = request.match_info["filename"]
|
||||
key = (rel_url, self._directory)
|
||||
if (filepath_content_type := PATH_CACHE.get(key)) is None:
|
||||
hass = request.app[KEY_HASS]
|
||||
try:
|
||||
filepath = await hass.async_add_executor_job(_get_file_path, *key)
|
||||
except (ValueError, FileNotFoundError) as error:
|
||||
# relatively safe
|
||||
raise HTTPNotFound from error
|
||||
except HTTPForbidden:
|
||||
# forbidden
|
||||
raise
|
||||
except Exception as error:
|
||||
# perm error or other kind!
|
||||
request.app.logger.exception("Unexpected exception")
|
||||
raise HTTPNotFound from error
|
||||
response: StreamResponse
|
||||
|
||||
content_type: str | None = None
|
||||
if filepath is not None:
|
||||
content_type = (mimetypes.guess_type(rel_url))[
|
||||
0
|
||||
] or "application/octet-stream"
|
||||
PATH_CACHE[key] = (filepath, content_type)
|
||||
if key in RESPONSE_CACHE:
|
||||
file_path, content_type = RESPONSE_CACHE[key]
|
||||
response = FileResponse(file_path, chunk_size=self._chunk_size)
|
||||
response.headers[CONTENT_TYPE] = content_type
|
||||
else:
|
||||
filepath, content_type = filepath_content_type
|
||||
|
||||
if filepath and content_type:
|
||||
return FileResponse(
|
||||
filepath,
|
||||
chunk_size=self._chunk_size,
|
||||
headers={
|
||||
hdrs.CACHE_CONTROL: CACHE_HEADER,
|
||||
hdrs.CONTENT_TYPE: content_type,
|
||||
},
|
||||
response = await super()._handle(request)
|
||||
if not isinstance(response, FileResponse):
|
||||
# Must be directory index; ignore caching
|
||||
return response
|
||||
file_path = response._path # noqa: SLF001
|
||||
response.content_type = (
|
||||
CONTENT_TYPES.guess_type(file_path)[0] or FALLBACK_CONTENT_TYPE
|
||||
)
|
||||
# Cache actual header after setter construction.
|
||||
content_type = response.headers[CONTENT_TYPE]
|
||||
RESPONSE_CACHE[key] = (file_path, content_type)
|
||||
|
||||
raise HTTPForbidden if filepath is None else HTTPNotFound
|
||||
response.headers[CACHE_CONTROL] = CACHE_HEADER
|
||||
return response
|
||||
|
||||
@@ -48,6 +48,7 @@ set_options:
|
||||
required: true
|
||||
example: '["Item A", "Item B", "Item C"]'
|
||||
selector:
|
||||
object:
|
||||
text:
|
||||
multiple: true
|
||||
|
||||
reload:
|
||||
|
||||
@@ -46,4 +46,8 @@ class IronOSCoordinator(DataUpdateCoordinator[LiveDataResponse]):
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
|
||||
self.device_info = await self.device.get_device_info()
|
||||
try:
|
||||
self.device_info = await self.device.get_device_info()
|
||||
|
||||
except CommunicationError as e:
|
||||
raise UpdateFailed("Cannot connect to device") from e
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"jakim": "Jabatan Kemajuan Islam Malaysia (JAKIM)",
|
||||
"tunisia": "Tunisia",
|
||||
"algeria": "Algeria",
|
||||
"kemenag": "ementerian Agama Republik Indonesia",
|
||||
"kemenag": "Kementerian Agama Republik Indonesia",
|
||||
"morocco": "Morocco",
|
||||
"portugal": "Comunidade Islamica de Lisboa",
|
||||
"jordan": "Ministry of Awqaf, Islamic Affairs and Holy Places, Jordan",
|
||||
|
||||
@@ -302,7 +302,7 @@ class KNXModule:
|
||||
self.entry = entry
|
||||
|
||||
self.project = KNXProject(hass=hass, entry=entry)
|
||||
self.config_store = KNXConfigStore(hass=hass, entry=entry)
|
||||
self.config_store = KNXConfigStore(hass=hass, config_entry=entry)
|
||||
|
||||
self.xknx = XKNX(
|
||||
connection_config=self.connection_config(),
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import BinarySensor as XknxBinarySensor
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -23,8 +22,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import ATTR_COUNTER, ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import BinarySensorSchema
|
||||
|
||||
|
||||
@@ -34,25 +34,26 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the KNX binary sensor platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: ConfigType = hass.data[DATA_KNX_CONFIG]
|
||||
|
||||
async_add_entities(
|
||||
KNXBinarySensor(xknx, entity_config)
|
||||
KNXBinarySensor(knx_module, entity_config)
|
||||
for entity_config in config[Platform.BINARY_SENSOR]
|
||||
)
|
||||
|
||||
|
||||
class KNXBinarySensor(KnxEntity, BinarySensorEntity, RestoreEntity):
|
||||
class KNXBinarySensor(KnxYamlEntity, BinarySensorEntity, RestoreEntity):
|
||||
"""Representation of a KNX binary sensor."""
|
||||
|
||||
_device: XknxBinarySensor
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of KNX binary sensor."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxBinarySensor(
|
||||
xknx,
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_state=config[BinarySensorSchema.CONF_STATE_ADDRESS],
|
||||
invert=config[BinarySensorSchema.CONF_INVERT],
|
||||
@@ -62,7 +63,7 @@ class KNXBinarySensor(KnxEntity, BinarySensorEntity, RestoreEntity):
|
||||
],
|
||||
context_timeout=config.get(BinarySensorSchema.CONF_CONTEXT_TIMEOUT),
|
||||
reset_after=config.get(BinarySensorSchema.CONF_RESET_AFTER),
|
||||
)
|
||||
),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import RawValue as XknxRawValue
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -12,8 +11,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import CONF_PAYLOAD_LENGTH, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -22,28 +22,30 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the KNX binary sensor platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: ConfigType = hass.data[DATA_KNX_CONFIG]
|
||||
|
||||
async_add_entities(
|
||||
KNXButton(xknx, entity_config) for entity_config in config[Platform.BUTTON]
|
||||
KNXButton(knx_module, entity_config)
|
||||
for entity_config in config[Platform.BUTTON]
|
||||
)
|
||||
|
||||
|
||||
class KNXButton(KnxEntity, ButtonEntity):
|
||||
class KNXButton(KnxYamlEntity, ButtonEntity):
|
||||
"""Representation of a KNX button."""
|
||||
|
||||
_device: XknxRawValue
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX button."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxRawValue(
|
||||
xknx,
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
payload_length=config[CONF_PAYLOAD_LENGTH],
|
||||
group_address=config[KNX_ADDRESS],
|
||||
)
|
||||
),
|
||||
)
|
||||
self._payload = config[CONF_PAYLOAD]
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
|
||||
@@ -27,6 +27,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import (
|
||||
CONTROLLER_MODES,
|
||||
CURRENT_HVAC_ACTIONS,
|
||||
@@ -34,7 +35,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
PRESET_MODES,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import ClimateSchema
|
||||
|
||||
ATTR_COMMAND_VALUE = "command_value"
|
||||
@@ -48,10 +49,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up climate(s) for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.CLIMATE]
|
||||
|
||||
async_add_entities(KNXClimate(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(
|
||||
KNXClimate(knx_module, entity_config) for entity_config in config
|
||||
)
|
||||
|
||||
|
||||
def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
|
||||
@@ -130,16 +133,19 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
|
||||
)
|
||||
|
||||
|
||||
class KNXClimate(KnxEntity, ClimateEntity):
|
||||
class KNXClimate(KnxYamlEntity, ClimateEntity):
|
||||
"""Representation of a KNX climate device."""
|
||||
|
||||
_device: XknxClimate
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of a KNX climate device."""
|
||||
super().__init__(_create_climate(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_climate(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
if self._device.supports_on_off:
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import Cover as XknxCover
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -26,8 +25,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import CoverSchema
|
||||
|
||||
|
||||
@@ -37,22 +37,23 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up cover(s) for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.COVER]
|
||||
|
||||
async_add_entities(KNXCover(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(KNXCover(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
class KNXCover(KnxEntity, CoverEntity):
|
||||
class KNXCover(KnxYamlEntity, CoverEntity):
|
||||
"""Representation of a KNX cover."""
|
||||
|
||||
_device: XknxCover
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize the cover."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxCover(
|
||||
xknx,
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS),
|
||||
group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS),
|
||||
@@ -70,7 +71,7 @@ class KNXCover(KnxEntity, CoverEntity):
|
||||
invert_updown=config[CoverSchema.CONF_INVERT_UPDOWN],
|
||||
invert_position=config[CoverSchema.CONF_INVERT_POSITION],
|
||||
invert_angle=config[CoverSchema.CONF_INVERT_ANGLE],
|
||||
)
|
||||
),
|
||||
)
|
||||
self._unsubscribe_auto_updater: Callable[[], None] | None = None
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
@@ -30,7 +31,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -39,10 +40,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up entities for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATE]
|
||||
|
||||
async_add_entities(KNXDateEntity(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(
|
||||
KNXDateEntity(knx_module, entity_config) for entity_config in config
|
||||
)
|
||||
|
||||
|
||||
def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateDevice:
|
||||
@@ -58,14 +61,17 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateDevice:
|
||||
)
|
||||
|
||||
|
||||
class KNXDateEntity(KnxEntity, DateEntity, RestoreEntity):
|
||||
class KNXDateEntity(KnxYamlEntity, DateEntity, RestoreEntity):
|
||||
"""Representation of a KNX date."""
|
||||
|
||||
_device: XknxDateDevice
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(_create_xknx_device(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_xknx_device(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import KNXModule
|
||||
from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
@@ -31,7 +32,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -40,11 +41,11 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up entities for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATETIME]
|
||||
|
||||
async_add_entities(
|
||||
KNXDateTimeEntity(xknx, entity_config) for entity_config in config
|
||||
KNXDateTimeEntity(knx_module, entity_config) for entity_config in config
|
||||
)
|
||||
|
||||
|
||||
@@ -61,14 +62,17 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTimeDevice:
|
||||
)
|
||||
|
||||
|
||||
class KNXDateTimeEntity(KnxEntity, DateTimeEntity, RestoreEntity):
|
||||
class KNXDateTimeEntity(KnxYamlEntity, DateTimeEntity, RestoreEntity):
|
||||
"""Representation of a KNX datetime."""
|
||||
|
||||
_device: XknxDateTimeDevice
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(_create_xknx_device(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_xknx_device(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any, Final
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import Fan as XknxFan
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -20,8 +19,9 @@ from homeassistant.util.percentage import (
|
||||
)
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import FanSchema
|
||||
|
||||
DEFAULT_PERCENTAGE: Final = 50
|
||||
@@ -33,24 +33,25 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up fan(s) for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.FAN]
|
||||
|
||||
async_add_entities(KNXFan(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
class KNXFan(KnxEntity, FanEntity):
|
||||
class KNXFan(KnxYamlEntity, FanEntity):
|
||||
"""Representation of a KNX fan."""
|
||||
|
||||
_device: XknxFan
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of KNX fan."""
|
||||
max_step = config.get(FanSchema.CONF_MAX_STEP)
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxFan(
|
||||
xknx,
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address_speed=config.get(KNX_ADDRESS),
|
||||
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
|
||||
@@ -61,7 +62,7 @@ class KNXFan(KnxEntity, FanEntity):
|
||||
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
|
||||
),
|
||||
max_step=max_step,
|
||||
)
|
||||
),
|
||||
)
|
||||
# FanSpeedMode.STEP if max_step is set
|
||||
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
|
||||
|
||||
@@ -2,24 +2,55 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from xknx.devices import Device as XknxDevice
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DOMAIN
|
||||
if TYPE_CHECKING:
|
||||
from . import KNXModule
|
||||
|
||||
from .storage.config_store import PlatformControllerBase
|
||||
|
||||
|
||||
class KnxEntity(Entity):
|
||||
class KnxUiEntityPlatformController(PlatformControllerBase):
|
||||
"""Class to manage dynamic adding and reloading of UI entities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
knx_module: KNXModule,
|
||||
entity_platform: EntityPlatform,
|
||||
entity_class: type[KnxUiEntity],
|
||||
) -> None:
|
||||
"""Initialize the UI platform."""
|
||||
self._knx_module = knx_module
|
||||
self._entity_platform = entity_platform
|
||||
self._entity_class = entity_class
|
||||
|
||||
async def create_entity(self, unique_id: str, config: dict[str, Any]) -> None:
|
||||
"""Add a new UI entity."""
|
||||
await self._entity_platform.async_add_entities(
|
||||
[self._entity_class(self._knx_module, unique_id, config)]
|
||||
)
|
||||
|
||||
async def update_entity(
|
||||
self, entity_entry: RegistryEntry, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Update an existing UI entities configuration."""
|
||||
await self._entity_platform.async_remove_entity(entity_entry.entity_id)
|
||||
await self.create_entity(unique_id=entity_entry.unique_id, config=config)
|
||||
|
||||
|
||||
class _KnxEntityBase(Entity):
|
||||
"""Representation of a KNX entity."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, device: XknxDevice) -> None:
|
||||
"""Set up device."""
|
||||
self._device = device
|
||||
_knx_module: KNXModule
|
||||
_device: XknxDevice
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -29,8 +60,7 @@ class KnxEntity(Entity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
knx_module = cast(KNXModule, self.hass.data[DOMAIN])
|
||||
return knx_module.connected
|
||||
return self._knx_module.connected
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Request a state update from KNX bus."""
|
||||
@@ -44,8 +74,32 @@ class KnxEntity(Entity):
|
||||
"""Store register state change callback and start device object."""
|
||||
self._device.register_device_updated_cb(self.after_update_callback)
|
||||
self._device.xknx.devices.async_add(self._device)
|
||||
# super call needed to have methods of multi-inherited classes called
|
||||
# eg. for restoring state (like _KNXSwitch)
|
||||
await super().async_added_to_hass()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Disconnect device object when removed."""
|
||||
self._device.unregister_device_updated_cb(self.after_update_callback)
|
||||
self._device.xknx.devices.async_remove(self._device)
|
||||
|
||||
|
||||
class KnxYamlEntity(_KnxEntityBase):
|
||||
"""Representation of a KNX entity configured from YAML."""
|
||||
|
||||
def __init__(self, knx_module: KNXModule, device: XknxDevice) -> None:
|
||||
"""Initialize the YAML entity."""
|
||||
self._knx_module = knx_module
|
||||
self._device = device
|
||||
|
||||
|
||||
class KnxUiEntity(_KnxEntityBase, ABC):
|
||||
"""Representation of a KNX UI entity."""
|
||||
|
||||
_attr_unique_id: str
|
||||
|
||||
@abstractmethod
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize the UI entity."""
|
||||
|
||||
@@ -19,15 +19,18 @@ from homeassistant.components.light import (
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
import homeassistant.util.color as color_util
|
||||
|
||||
from . import KNXModule
|
||||
from .const import CONF_SYNC_STATE, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, ColorTempModes
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .schema import LightSchema
|
||||
from .storage.const import (
|
||||
CONF_COLOR_TEMP_MAX,
|
||||
@@ -63,12 +66,21 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up light(s) for KNX platform."""
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.LIGHT,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiLight,
|
||||
),
|
||||
)
|
||||
|
||||
entities: list[KnxEntity] = []
|
||||
if yaml_config := hass.data[DATA_KNX_CONFIG].get(Platform.LIGHT):
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.LIGHT):
|
||||
entities.extend(
|
||||
KnxYamlLight(knx_module.xknx, entity_config)
|
||||
for entity_config in yaml_config
|
||||
KnxYamlLight(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.LIGHT):
|
||||
entities.extend(
|
||||
@@ -78,13 +90,6 @@ async def async_setup_entry(
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
@callback
|
||||
def add_new_ui_light(unique_id: str, config: dict[str, Any]) -> None:
|
||||
"""Add KNX entity at runtime."""
|
||||
async_add_entities([KnxUiLight(knx_module, unique_id, config)])
|
||||
|
||||
knx_module.config_store.async_add_entity[Platform.LIGHT] = add_new_ui_light
|
||||
|
||||
|
||||
def _create_yaml_light(xknx: XKNX, config: ConfigType) -> XknxLight:
|
||||
"""Return a KNX Light device to be used within XKNX."""
|
||||
@@ -294,7 +299,7 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight
|
||||
)
|
||||
|
||||
|
||||
class _KnxLight(KnxEntity, LightEntity):
|
||||
class _KnxLight(LightEntity):
|
||||
"""Representation of a KNX light."""
|
||||
|
||||
_attr_max_color_temp_kelvin: int
|
||||
@@ -519,14 +524,17 @@ class _KnxLight(KnxEntity, LightEntity):
|
||||
await self._device.set_off()
|
||||
|
||||
|
||||
class KnxYamlLight(_KnxLight):
|
||||
class KnxYamlLight(_KnxLight, KnxYamlEntity):
|
||||
"""Representation of a KNX light."""
|
||||
|
||||
_device: XknxLight
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of KNX light."""
|
||||
super().__init__(_create_yaml_light(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_yaml_light(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN]
|
||||
self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN]
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
@@ -543,20 +551,19 @@ class KnxYamlLight(_KnxLight):
|
||||
)
|
||||
|
||||
|
||||
class KnxUiLight(_KnxLight):
|
||||
class KnxUiLight(_KnxLight, KnxUiEntity):
|
||||
"""Representation of a KNX light."""
|
||||
|
||||
_device: XknxLight
|
||||
_attr_has_entity_name = True
|
||||
_device: XknxLight
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: ConfigType
|
||||
) -> None:
|
||||
"""Initialize of KNX light."""
|
||||
super().__init__(
|
||||
_create_ui_light(
|
||||
knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME]
|
||||
)
|
||||
self._knx_module = knx_module
|
||||
self._device = _create_ui_light(
|
||||
knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME]
|
||||
)
|
||||
self._attr_max_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MAX]
|
||||
self._attr_min_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MIN]
|
||||
@@ -565,5 +572,3 @@ class KnxUiLight(_KnxLight):
|
||||
self._attr_unique_id = unique_id
|
||||
if device_info := config[CONF_ENTITY].get(CONF_DEVICE_INFO):
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)})
|
||||
|
||||
knx_module.config_store.entities[unique_id] = self
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.0.0",
|
||||
"xknxproject==3.7.1",
|
||||
"knx-frontend==2024.7.25.204106"
|
||||
"knx-frontend==2024.8.6.211307"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_get_service(
|
||||
@@ -44,7 +45,7 @@ async def async_get_service(
|
||||
|
||||
|
||||
class KNXNotificationService(BaseNotificationService):
|
||||
"""Implement demo notification service."""
|
||||
"""Implement notification service."""
|
||||
|
||||
def __init__(self, devices: list[XknxNotification]) -> None:
|
||||
"""Initialize the service."""
|
||||
@@ -86,10 +87,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up notify(s) for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.NOTIFY]
|
||||
|
||||
async_add_entities(KNXNotify(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(KNXNotify(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
def _create_notification_instance(xknx: XKNX, config: ConfigType) -> XknxNotification:
|
||||
@@ -102,14 +103,17 @@ def _create_notification_instance(xknx: XKNX, config: ConfigType) -> XknxNotific
|
||||
)
|
||||
|
||||
|
||||
class KNXNotify(KnxEntity, NotifyEntity):
|
||||
class KNXNotify(KnxYamlEntity, NotifyEntity):
|
||||
"""Representation of a KNX notification entity."""
|
||||
|
||||
_device: XknxNotification
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX notification."""
|
||||
super().__init__(_create_notification_instance(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_notification_instance(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
@@ -29,7 +30,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import NumberSchema
|
||||
|
||||
|
||||
@@ -39,10 +40,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up number(s) for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.NUMBER]
|
||||
|
||||
async_add_entities(KNXNumber(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(KNXNumber(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue:
|
||||
@@ -57,14 +58,17 @@ def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue:
|
||||
)
|
||||
|
||||
|
||||
class KNXNumber(KnxEntity, RestoreNumber):
|
||||
class KNXNumber(KnxYamlEntity, RestoreNumber):
|
||||
"""Representation of a KNX number."""
|
||||
|
||||
_device: NumericValue
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX number."""
|
||||
super().__init__(_create_numeric_value(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_numeric_value(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_native_max_value = config.get(
|
||||
NumberSchema.CONF_MAX,
|
||||
self._device.sensor_value.dpt_class.value_max,
|
||||
|
||||
@@ -8,9 +8,11 @@ from typing import Final
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.dpt import DPTBase
|
||||
from xknx.telegram.address import DeviceAddressableType
|
||||
from xknxproject import XKNXProj
|
||||
from xknxproject.models import (
|
||||
Device,
|
||||
DPTType,
|
||||
GroupAddress as GroupAddressModel,
|
||||
KNXProject as KNXProjectModel,
|
||||
ProjectInfo,
|
||||
@@ -89,7 +91,7 @@ class KNXProject:
|
||||
self.devices = project["devices"]
|
||||
self.info = project["info"]
|
||||
xknx.group_address_dpt.clear()
|
||||
xknx_ga_dict = {}
|
||||
xknx_ga_dict: dict[DeviceAddressableType, DPTType] = {}
|
||||
|
||||
for ga_model in project["group_addresses"].values():
|
||||
ga_info = _create_group_address_info(ga_model)
|
||||
@@ -97,7 +99,7 @@ class KNXProject:
|
||||
if (dpt_model := ga_model.get("dpt")) is not None:
|
||||
xknx_ga_dict[ga_model["address"]] = dpt_model
|
||||
|
||||
xknx.group_address_dpt.set(xknx_ga_dict) # type: ignore[arg-type]
|
||||
xknx.group_address_dpt.set(xknx_ga_dict)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Loaded KNX project data with %s group addresses from storage",
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import Scene as XknxScene
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -14,8 +13,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import SceneSchema
|
||||
|
||||
|
||||
@@ -25,26 +25,27 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up scene(s) for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SCENE]
|
||||
|
||||
async_add_entities(KNXScene(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(KNXScene(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
class KNXScene(KnxEntity, Scene):
|
||||
class KNXScene(KnxYamlEntity, Scene):
|
||||
"""Representation of a KNX scene."""
|
||||
|
||||
_device: XknxScene
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Init KNX scene."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxScene(
|
||||
xknx,
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address=config[KNX_ADDRESS],
|
||||
scene_number=config[SceneSchema.CONF_SCENE_NUMBER],
|
||||
)
|
||||
),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = (
|
||||
|
||||
@@ -56,6 +56,7 @@ from .const import (
|
||||
ColorTempModes,
|
||||
)
|
||||
from .validation import (
|
||||
backwards_compatible_xknx_climate_enum_member,
|
||||
dpt_base_type_validator,
|
||||
ga_list_validator,
|
||||
ga_validator,
|
||||
@@ -409,10 +410,12 @@ class ClimateSchema(KNXPlatformSchema):
|
||||
CONF_ON_OFF_INVERT, default=DEFAULT_ON_OFF_INVERT
|
||||
): cv.boolean,
|
||||
vol.Optional(CONF_OPERATION_MODES): vol.All(
|
||||
cv.ensure_list, [vol.All(vol.Upper, cv.enum(HVACOperationMode))]
|
||||
cv.ensure_list,
|
||||
[backwards_compatible_xknx_climate_enum_member(HVACOperationMode)],
|
||||
),
|
||||
vol.Optional(CONF_CONTROLLER_MODES): vol.All(
|
||||
cv.ensure_list, [vol.All(vol.Upper, cv.enum(HVACControllerMode))]
|
||||
cv.ensure_list,
|
||||
[backwards_compatible_xknx_climate_enum_member(HVACControllerMode)],
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_DEFAULT_CONTROLLER_MODE, default=HVACMode.HEAT
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import (
|
||||
CONF_PAYLOAD_LENGTH,
|
||||
CONF_RESPOND_TO_READ,
|
||||
@@ -29,7 +30,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import SelectSchema
|
||||
|
||||
|
||||
@@ -39,10 +40,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up select(s) for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SELECT]
|
||||
|
||||
async_add_entities(KNXSelect(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(KNXSelect(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
def _create_raw_value(xknx: XKNX, config: ConfigType) -> RawValue:
|
||||
@@ -58,14 +59,17 @@ def _create_raw_value(xknx: XKNX, config: ConfigType) -> RawValue:
|
||||
)
|
||||
|
||||
|
||||
class KNXSelect(KnxEntity, SelectEntity, RestoreEntity):
|
||||
class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity):
|
||||
"""Representation of a KNX select."""
|
||||
|
||||
_device: RawValue
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX select."""
|
||||
super().__init__(_create_raw_value(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_raw_value(knx_module.xknx, config),
|
||||
)
|
||||
self._option_payloads: dict[str, int] = {
|
||||
option[SelectSchema.CONF_OPTION]: option[CONF_PAYLOAD]
|
||||
for option in config[SelectSchema.CONF_OPTIONS]
|
||||
|
||||
@@ -35,7 +35,7 @@ from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from . import KNXModule
|
||||
from .const import ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import SensorSchema
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
@@ -116,17 +116,17 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up sensor(s) for KNX platform."""
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
|
||||
async_add_entities(
|
||||
entities: list[SensorEntity] = []
|
||||
entities.extend(
|
||||
KNXSystemSensor(knx_module, description)
|
||||
for description in SYSTEM_ENTITY_DESCRIPTIONS
|
||||
)
|
||||
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG].get(Platform.SENSOR)
|
||||
if config:
|
||||
async_add_entities(
|
||||
KNXSensor(knx_module.xknx, entity_config) for entity_config in config
|
||||
entities.extend(
|
||||
KNXSensor(knx_module, entity_config) for entity_config in config
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor:
|
||||
@@ -141,14 +141,17 @@ def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor:
|
||||
)
|
||||
|
||||
|
||||
class KNXSensor(KnxEntity, SensorEntity):
|
||||
class KNXSensor(KnxYamlEntity, SensorEntity):
|
||||
"""Representation of a KNX sensor."""
|
||||
|
||||
_device: XknxSensor
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of a KNX sensor."""
|
||||
super().__init__(_create_sensor(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_sensor(knx_module.xknx, config),
|
||||
)
|
||||
if device_class := config.get(CONF_DEVICE_CLASS):
|
||||
self._attr_device_class = device_class
|
||||
else:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""KNX entity configuration store."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Final, TypedDict
|
||||
from typing import Any, Final, TypedDict
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PLATFORM, Platform
|
||||
@@ -14,9 +14,6 @@ from homeassistant.util.ulid import ulid_now
|
||||
from ..const import DOMAIN
|
||||
from .const import CONF_DATA
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..knx_entity import KnxEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_VERSION: Final = 1
|
||||
@@ -34,24 +31,34 @@ class KNXConfigStoreModel(TypedDict):
|
||||
entities: KNXEntityStoreModel
|
||||
|
||||
|
||||
class PlatformControllerBase(ABC):
|
||||
"""Entity platform controller base class."""
|
||||
|
||||
@abstractmethod
|
||||
async def create_entity(self, unique_id: str, config: dict[str, Any]) -> None:
|
||||
"""Create a new entity."""
|
||||
|
||||
@abstractmethod
|
||||
async def update_entity(
|
||||
self, entity_entry: er.RegistryEntry, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Update an existing entities configuration."""
|
||||
|
||||
|
||||
class KNXConfigStore:
|
||||
"""Manage KNX config store data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize config store."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
self.data = KNXConfigStoreModel(entities={})
|
||||
|
||||
# entities and async_add_entity are filled by platform setups
|
||||
self.entities: dict[str, KnxEntity] = {} # unique_id as key
|
||||
self.async_add_entity: dict[
|
||||
Platform, Callable[[str, dict[str, Any]], None]
|
||||
] = {}
|
||||
self._platform_controllers: dict[Platform, PlatformControllerBase] = {}
|
||||
|
||||
async def load_data(self) -> None:
|
||||
"""Load config store data from storage."""
|
||||
@@ -62,14 +69,19 @@ class KNXConfigStore:
|
||||
len(self.data["entities"]),
|
||||
)
|
||||
|
||||
def add_platform(
|
||||
self, platform: Platform, controller: PlatformControllerBase
|
||||
) -> None:
|
||||
"""Add platform controller."""
|
||||
self._platform_controllers[platform] = controller
|
||||
|
||||
async def create_entity(
|
||||
self, platform: Platform, data: dict[str, Any]
|
||||
) -> str | None:
|
||||
"""Create a new entity."""
|
||||
if platform not in self.async_add_entity:
|
||||
raise ConfigStoreException(f"Entity platform not ready: {platform}")
|
||||
platform_controller = self._platform_controllers[platform]
|
||||
unique_id = f"knx_es_{ulid_now()}"
|
||||
self.async_add_entity[platform](unique_id, data)
|
||||
await platform_controller.create_entity(unique_id, data)
|
||||
# store data after entity was added to be sure config didn't raise exceptions
|
||||
self.data["entities"].setdefault(platform, {})[unique_id] = data
|
||||
await self._store.async_save(self.data)
|
||||
@@ -95,8 +107,7 @@ class KNXConfigStore:
|
||||
self, platform: Platform, entity_id: str, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Update an existing entity."""
|
||||
if platform not in self.async_add_entity:
|
||||
raise ConfigStoreException(f"Entity platform not ready: {platform}")
|
||||
platform_controller = self._platform_controllers[platform]
|
||||
entity_registry = er.async_get(self.hass)
|
||||
if (entry := entity_registry.async_get(entity_id)) is None:
|
||||
raise ConfigStoreException(f"Entity not found: {entity_id}")
|
||||
@@ -108,8 +119,7 @@ class KNXConfigStore:
|
||||
raise ConfigStoreException(
|
||||
f"Entity not found in storage: {entity_id} - {unique_id}"
|
||||
)
|
||||
await self.entities.pop(unique_id).async_remove()
|
||||
self.async_add_entity[platform](unique_id, data)
|
||||
await platform_controller.update_entity(entry, data)
|
||||
# store data after entity is added to make sure config doesn't raise exceptions
|
||||
self.data["entities"][platform][unique_id] = data
|
||||
await self._store.async_save(self.data)
|
||||
@@ -125,19 +135,21 @@ class KNXConfigStore:
|
||||
raise ConfigStoreException(
|
||||
f"Entity not found in {entry.domain}: {entry.unique_id}"
|
||||
) from err
|
||||
try:
|
||||
del self.entities[entry.unique_id]
|
||||
except KeyError:
|
||||
_LOGGER.warning("Entity not initialized when deleted: %s", entity_id)
|
||||
entity_registry.async_remove(entity_id)
|
||||
await self._store.async_save(self.data)
|
||||
|
||||
def get_entity_entries(self) -> list[er.RegistryEntry]:
|
||||
"""Get entity_ids of all configured entities by platform."""
|
||||
"""Get entity_ids of all UI configured entities."""
|
||||
entity_registry = er.async_get(self.hass)
|
||||
unique_ids = {
|
||||
uid for platform in self.data["entities"].values() for uid in platform
|
||||
}
|
||||
return [
|
||||
entity.registry_entry
|
||||
for entity in self.entities.values()
|
||||
if entity.registry_entry is not None
|
||||
registry_entry
|
||||
for registry_entry in er.async_entries_for_config_entry(
|
||||
entity_registry, self.config_entry.entry_id
|
||||
)
|
||||
if registry_entry.unique_id in unique_ids
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import Switch as XknxSwitch
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -18,9 +17,12 @@ from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -33,7 +35,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .schema import SwitchSchema
|
||||
from .storage.const import (
|
||||
CONF_DEVICE_INFO,
|
||||
@@ -52,12 +54,21 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up switch(es) for KNX platform."""
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.SWITCH,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiSwitch,
|
||||
),
|
||||
)
|
||||
|
||||
entities: list[KnxEntity] = []
|
||||
if yaml_config := hass.data[DATA_KNX_CONFIG].get(Platform.SWITCH):
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.SWITCH):
|
||||
entities.extend(
|
||||
KnxYamlSwitch(knx_module.xknx, entity_config)
|
||||
for entity_config in yaml_config
|
||||
KnxYamlSwitch(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.SWITCH):
|
||||
entities.extend(
|
||||
@@ -67,15 +78,8 @@ async def async_setup_entry(
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
@callback
|
||||
def add_new_ui_switch(unique_id: str, config: dict[str, Any]) -> None:
|
||||
"""Add KNX entity at runtime."""
|
||||
async_add_entities([KnxUiSwitch(knx_module, unique_id, config)])
|
||||
|
||||
knx_module.config_store.async_add_entity[Platform.SWITCH] = add_new_ui_switch
|
||||
|
||||
|
||||
class _KnxSwitch(KnxEntity, SwitchEntity, RestoreEntity):
|
||||
class _KnxSwitch(SwitchEntity, RestoreEntity):
|
||||
"""Base class for a KNX switch."""
|
||||
|
||||
_device: XknxSwitch
|
||||
@@ -103,52 +107,53 @@ class _KnxSwitch(KnxEntity, SwitchEntity, RestoreEntity):
|
||||
await self._device.set_off()
|
||||
|
||||
|
||||
class KnxYamlSwitch(_KnxSwitch):
|
||||
class KnxYamlSwitch(_KnxSwitch, KnxYamlEntity):
|
||||
"""Representation of a KNX switch configured from YAML."""
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
_device: XknxSwitch
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of KNX switch."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxSwitch(
|
||||
xknx,
|
||||
xknx=knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
invert=config[SwitchSchema.CONF_INVERT],
|
||||
)
|
||||
),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
|
||||
self._attr_unique_id = str(self._device.switch.group_address)
|
||||
|
||||
|
||||
class KnxUiSwitch(_KnxSwitch):
|
||||
class KnxUiSwitch(_KnxSwitch, KnxUiEntity):
|
||||
"""Representation of a KNX switch configured from UI."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_device: XknxSwitch
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize of KNX switch."""
|
||||
super().__init__(
|
||||
device=XknxSwitch(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE],
|
||||
group_address_state=[
|
||||
config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE],
|
||||
*config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE],
|
||||
],
|
||||
respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ],
|
||||
sync_state=config[DOMAIN][CONF_SYNC_STATE],
|
||||
invert=config[DOMAIN][CONF_INVERT],
|
||||
)
|
||||
"""Initialize KNX switch."""
|
||||
self._knx_module = knx_module
|
||||
self._device = XknxSwitch(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE],
|
||||
group_address_state=[
|
||||
config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE],
|
||||
*config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE],
|
||||
],
|
||||
respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ],
|
||||
sync_state=config[DOMAIN][CONF_SYNC_STATE],
|
||||
invert=config[DOMAIN][CONF_INVERT],
|
||||
)
|
||||
self._attr_entity_category = config[CONF_ENTITY][CONF_ENTITY_CATEGORY]
|
||||
self._attr_unique_id = unique_id
|
||||
if device_info := config[CONF_ENTITY].get(CONF_DEVICE_INFO):
|
||||
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)})
|
||||
|
||||
knx_module.config_store.entities[unique_id] = self
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
@@ -29,7 +30,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -38,10 +39,10 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensor(s) for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.TEXT]
|
||||
|
||||
async_add_entities(KNXText(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(KNXText(knx_module, entity_config) for entity_config in config)
|
||||
|
||||
|
||||
def _create_notification(xknx: XKNX, config: ConfigType) -> XknxNotification:
|
||||
@@ -56,15 +57,18 @@ def _create_notification(xknx: XKNX, config: ConfigType) -> XknxNotification:
|
||||
)
|
||||
|
||||
|
||||
class KNXText(KnxEntity, TextEntity, RestoreEntity):
|
||||
class KNXText(KnxYamlEntity, TextEntity, RestoreEntity):
|
||||
"""Representation of a KNX text."""
|
||||
|
||||
_device: XknxNotification
|
||||
_attr_native_max = 14
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX text."""
|
||||
super().__init__(_create_notification(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_notification(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_mode = config[CONF_MODE]
|
||||
self._attr_pattern = (
|
||||
r"[\u0000-\u00ff]*" # Latin-1
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time as dt_time
|
||||
from typing import Final
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import TimeDevice as XknxTimeDevice
|
||||
@@ -23,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
@@ -31,9 +31,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
)
|
||||
from .knx_entity import KnxEntity
|
||||
|
||||
_TIME_TRANSLATION_FORMAT: Final = "%H:%M:%S"
|
||||
from .knx_entity import KnxYamlEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -42,10 +40,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up entities for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.TIME]
|
||||
|
||||
async_add_entities(KNXTimeEntity(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(
|
||||
KNXTimeEntity(knx_module, entity_config) for entity_config in config
|
||||
)
|
||||
|
||||
|
||||
def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxTimeDevice:
|
||||
@@ -61,14 +61,17 @@ def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxTimeDevice:
|
||||
)
|
||||
|
||||
|
||||
class KNXTimeEntity(KnxEntity, TimeEntity, RestoreEntity):
|
||||
class KNXTimeEntity(KnxYamlEntity, TimeEntity, RestoreEntity):
|
||||
"""Representation of a KNX time."""
|
||||
|
||||
_device: XknxTimeDevice
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(_create_xknx_device(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_xknx_device(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Validation helpers for KNX config schemas."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
import ipaddress
|
||||
from typing import Any
|
||||
|
||||
@@ -104,3 +105,36 @@ sync_state_validator = vol.Any(
|
||||
cv.boolean,
|
||||
cv.matches_regex(r"^(init|expire|every)( \d*)?$"),
|
||||
)
|
||||
|
||||
|
||||
def backwards_compatible_xknx_climate_enum_member(enumClass: type[Enum]) -> vol.All:
|
||||
"""Transform a string to an enum member.
|
||||
|
||||
Backwards compatible with member names of xknx 2.x climate DPT Enums
|
||||
due to unintentional breaking change in HA 2024.8.
|
||||
"""
|
||||
|
||||
def _string_transform(value: Any) -> str:
|
||||
"""Upper and slugify string and substitute old member names.
|
||||
|
||||
Previously this was checked against Enum values instead of names. These
|
||||
looked like `FAN_ONLY = "Fan only"`, therefore the upper & replace part.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise vol.Invalid("value should be a string")
|
||||
name = value.upper().replace(" ", "_")
|
||||
match name:
|
||||
case "NIGHT":
|
||||
return "ECONOMY"
|
||||
case "FROST_PROTECTION":
|
||||
return "BUILDING_PROTECTION"
|
||||
case "DRY":
|
||||
return "DEHUMIDIFICATION"
|
||||
case _:
|
||||
return name
|
||||
|
||||
return vol.All(
|
||||
_string_transform,
|
||||
vol.In(enumClass.__members__),
|
||||
enumClass.__getitem__,
|
||||
)
|
||||
|
||||
@@ -19,8 +19,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import KNXModule
|
||||
from .const import DATA_KNX_CONFIG, DOMAIN
|
||||
from .knx_entity import KnxEntity
|
||||
from .knx_entity import KnxYamlEntity
|
||||
from .schema import WeatherSchema
|
||||
|
||||
|
||||
@@ -30,10 +31,12 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up switch(es) for KNX platform."""
|
||||
xknx: XKNX = hass.data[DOMAIN].xknx
|
||||
knx_module: KNXModule = hass.data[DOMAIN]
|
||||
config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.WEATHER]
|
||||
|
||||
async_add_entities(KNXWeather(xknx, entity_config) for entity_config in config)
|
||||
async_add_entities(
|
||||
KNXWeather(knx_module, entity_config) for entity_config in config
|
||||
)
|
||||
|
||||
|
||||
def _create_weather(xknx: XKNX, config: ConfigType) -> XknxWeather:
|
||||
@@ -72,7 +75,7 @@ def _create_weather(xknx: XKNX, config: ConfigType) -> XknxWeather:
|
||||
)
|
||||
|
||||
|
||||
class KNXWeather(KnxEntity, WeatherEntity):
|
||||
class KNXWeather(KnxYamlEntity, WeatherEntity):
|
||||
"""Representation of a KNX weather device."""
|
||||
|
||||
_device: XknxWeather
|
||||
@@ -80,9 +83,12 @@ class KNXWeather(KnxEntity, WeatherEntity):
|
||||
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize of a KNX sensor."""
|
||||
super().__init__(_create_weather(xknx, config))
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_weather(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_unique_id = str(self._device._temperature.group_address_state) # noqa: SLF001
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/linkplay",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-linkplay==0.0.5"],
|
||||
"requirements": ["python-linkplay==0.0.6"],
|
||||
"zeroconf": ["_linkplay._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ REPEAT_MAP: dict[LoopMode, RepeatMode] = {
|
||||
LoopMode.CONTINUOUS_PLAYBACK: RepeatMode.ALL,
|
||||
LoopMode.RANDOM_PLAYBACK: RepeatMode.ALL,
|
||||
LoopMode.LIST_CYCLE: RepeatMode.ALL,
|
||||
LoopMode.SHUFF_DISABLED_REPEAT_DISABLED: RepeatMode.OFF,
|
||||
LoopMode.SHUFF_ENABLED_REPEAT_ENABLED_LOOP_ONCE: RepeatMode.ALL,
|
||||
}
|
||||
|
||||
REPEAT_MAP_INV: dict[RepeatMode, LoopMode] = {v: k for k, v in REPEAT_MAP.items()}
|
||||
|
||||
@@ -3,9 +3,19 @@
|
||||
from typing import Final
|
||||
|
||||
MANUFACTURER_ARTSOUND: Final[str] = "ArtSound"
|
||||
MANUFACTURER_ARYLIC: Final[str] = "Arylic"
|
||||
MANUFACTURER_IEAST: Final[str] = "iEAST"
|
||||
MANUFACTURER_GENERIC: Final[str] = "Generic"
|
||||
MODELS_ARTSOUND_SMART_ZONE4: Final[str] = "Smart Zone 4 AMP"
|
||||
MODELS_ARTSOUND_SMART_HYDE: Final[str] = "Smart Hyde"
|
||||
MODELS_ARYLIC_S50: Final[str] = "S50+"
|
||||
MODELS_ARYLIC_S50_PRO: Final[str] = "S50 Pro"
|
||||
MODELS_ARYLIC_A30: Final[str] = "A30"
|
||||
MODELS_ARYLIC_A50S: Final[str] = "A50+"
|
||||
MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3"
|
||||
MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4"
|
||||
MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3"
|
||||
MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5"
|
||||
MODELS_GENERIC: Final[str] = "Generic"
|
||||
|
||||
|
||||
@@ -16,5 +26,21 @@ def get_info_from_project(project: str) -> tuple[str, str]:
|
||||
return MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4
|
||||
case "SMART_HYDE":
|
||||
return MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE
|
||||
case "ARYLIC_S50":
|
||||
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50
|
||||
case "RP0016_S50PRO_S":
|
||||
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO
|
||||
case "RP0011_WB60_S":
|
||||
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30
|
||||
case "ARYLIC_A50S":
|
||||
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S
|
||||
case "UP2STREAM_AMP_V3":
|
||||
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3
|
||||
case "UP2STREAM_AMP_V4":
|
||||
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4
|
||||
case "UP2STREAM_PRO_V3":
|
||||
return MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3
|
||||
case "iEAST-02":
|
||||
return MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5
|
||||
case _:
|
||||
return MANUFACTURER_GENERIC, MODELS_GENERIC
|
||||
|
||||
@@ -192,8 +192,8 @@ class LyricAccessoryEntity(LyricDeviceEntity):
|
||||
) -> None:
|
||||
"""Initialize the Honeywell Lyric accessory entity."""
|
||||
super().__init__(coordinator, location, device, key)
|
||||
self._room = room
|
||||
self._accessory = accessory
|
||||
self._room_id = room.id
|
||||
self._accessory_id = accessory.id
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
@@ -202,11 +202,25 @@ class LyricAccessoryEntity(LyricDeviceEntity):
|
||||
identifiers={
|
||||
(
|
||||
f"{dr.CONNECTION_NETWORK_MAC}_room_accessory",
|
||||
f"{self._mac_id}_room{self._room.id}_accessory{self._accessory.id}",
|
||||
f"{self._mac_id}_room{self._room_id}_accessory{self._accessory_id}",
|
||||
)
|
||||
},
|
||||
manufacturer="Honeywell",
|
||||
model="RCHTSENSOR",
|
||||
name=f"{self._room.roomName} Sensor",
|
||||
name=f"{self.room.roomName} Sensor",
|
||||
via_device=(dr.CONNECTION_NETWORK_MAC, self._mac_id),
|
||||
)
|
||||
|
||||
@property
|
||||
def room(self) -> LyricRoom:
|
||||
"""Get the Lyric Device."""
|
||||
return self.coordinator.data.rooms_dict[self._mac_id][self._room_id]
|
||||
|
||||
@property
|
||||
def accessory(self) -> LyricAccessories:
|
||||
"""Get the Lyric Device."""
|
||||
return next(
|
||||
accessory
|
||||
for accessory in self.room.accessories
|
||||
if accessory.id == self._accessory_id
|
||||
)
|
||||
|
||||
@@ -244,7 +244,6 @@ class LyricAccessorySensor(LyricAccessoryEntity, SensorEntity):
|
||||
accessory,
|
||||
f"{parentDevice.macID}_room{room.id}_acc{accessory.id}_{description.key}",
|
||||
)
|
||||
self.room = room
|
||||
self.entity_description = description
|
||||
if description.device_class == SensorDeviceClass.TEMPERATURE:
|
||||
if parentDevice.units == "Fahrenheit":
|
||||
@@ -255,4 +254,4 @@ class LyricAccessorySensor(LyricAccessoryEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state."""
|
||||
return self.entity_description.value_fn(self._room, self._accessory)
|
||||
return self.entity_description.value_fn(self.room, self.accessory)
|
||||
|
||||
@@ -51,17 +51,19 @@ DEFAULT_TRANSITION = 0.2
|
||||
# hw version (attributeKey 0/40/8)
|
||||
# sw version (attributeKey 0/40/10)
|
||||
TRANSITION_BLOCKLIST = (
|
||||
(4488, 514, "1.0", "1.0.0"),
|
||||
(4488, 260, "1.0", "1.0.0"),
|
||||
(5010, 769, "3.0", "1.0.0"),
|
||||
(4999, 25057, "1.0", "27.0"),
|
||||
(4448, 36866, "V1", "V1.0.0.5"),
|
||||
(5009, 514, "1.0", "1.0.0"),
|
||||
(4107, 8475, "v1.0", "v1.0"),
|
||||
(4107, 8550, "v1.0", "v1.0"),
|
||||
(4107, 8551, "v1.0", "v1.0"),
|
||||
(4107, 8656, "v1.0", "v1.0"),
|
||||
(4107, 8571, "v1.0", "v1.0"),
|
||||
(4107, 8656, "v1.0", "v1.0"),
|
||||
(4448, 36866, "V1", "V1.0.0.5"),
|
||||
(4456, 1011, "1.0.0", "2.00.00"),
|
||||
(4488, 260, "1.0", "1.0.0"),
|
||||
(4488, 514, "1.0", "1.0.0"),
|
||||
(4999, 24875, "1.0", "27.0"),
|
||||
(4999, 25057, "1.0", "27.0"),
|
||||
(5009, 514, "1.0", "1.0.0"),
|
||||
(5010, 769, "3.0", "1.0.0"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ type SelectCluster = (
|
||||
| clusters.RvcRunMode
|
||||
| clusters.RvcCleanMode
|
||||
| clusters.DishwasherMode
|
||||
| clusters.MicrowaveOvenMode
|
||||
| clusters.EnergyEvseMode
|
||||
| clusters.DeviceEnergyManagementMode
|
||||
)
|
||||
@@ -199,18 +198,6 @@ DISCOVERY_SCHEMAS = [
|
||||
clusters.DishwasherMode.Attributes.SupportedModes,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SELECT,
|
||||
entity_description=MatterSelectEntityDescription(
|
||||
key="MatterMicrowaveOvenMode",
|
||||
translation_key="mode",
|
||||
),
|
||||
entity_class=MatterModeSelectEntity,
|
||||
required_attributes=(
|
||||
clusters.MicrowaveOvenMode.Attributes.CurrentMode,
|
||||
clusters.MicrowaveOvenMode.Attributes.SupportedModes,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SELECT,
|
||||
entity_description=MatterSelectEntityDescription(
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp==2024.07.16"],
|
||||
"requirements": ["yt-dlp==2024.08.06"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/mfi",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["mficlient"],
|
||||
"requirements": ["mficlient==0.3.0"]
|
||||
"requirements": ["mficlient==0.5.0"]
|
||||
}
|
||||
|
||||
@@ -124,12 +124,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
):
|
||||
await async_create_cloud_hook(hass, webhook_id, entry)
|
||||
|
||||
if (
|
||||
CONF_CLOUDHOOK_URL not in entry.data
|
||||
and cloud.async_active_subscription(hass)
|
||||
and cloud.async_is_connected(hass)
|
||||
):
|
||||
await async_create_cloud_hook(hass, webhook_id, entry)
|
||||
if cloud.async_is_logged_in(hass):
|
||||
if (
|
||||
CONF_CLOUDHOOK_URL not in entry.data
|
||||
and cloud.async_active_subscription(hass)
|
||||
and cloud.async_is_connected(hass)
|
||||
):
|
||||
await async_create_cloud_hook(hass, webhook_id, entry)
|
||||
elif CONF_CLOUDHOOK_URL in entry.data:
|
||||
# If we have a cloudhook but no longer logged in to the cloud, remove it from the entry
|
||||
data = dict(entry.data)
|
||||
data.pop(CONF_CLOUDHOOK_URL)
|
||||
hass.config_entries.async_update_entry(entry, data=data)
|
||||
|
||||
entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook))
|
||||
|
||||
|
||||
@@ -39,6 +39,8 @@ def async_handle_timer_event(
|
||||
# Android
|
||||
"channel": "Timers",
|
||||
"importance": "high",
|
||||
"ttl": 0,
|
||||
"priority": "high",
|
||||
# iOS
|
||||
"push": {
|
||||
"interruption-level": "time-sensitive",
|
||||
|
||||
@@ -86,7 +86,7 @@ async def async_setup_platform(
|
||||
)
|
||||
if (
|
||||
result["type"] is FlowResultType.CREATE_ENTRY
|
||||
or result["reason"] == "single_instance_allowed"
|
||||
or result["reason"] == "already_configured"
|
||||
):
|
||||
async_create_issue(
|
||||
hass,
|
||||
|
||||
@@ -2,6 +2,7 @@ get_forecasts_extra:
|
||||
target:
|
||||
entity:
|
||||
domain: weather
|
||||
integration: nws
|
||||
fields:
|
||||
type:
|
||||
required: true
|
||||
|
||||
@@ -106,6 +106,10 @@ class OllamaConversationEntity(
|
||||
self._history: dict[str, MessageHistory] = {}
|
||||
self._attr_name = entry.title
|
||||
self._attr_unique_id = entry.entry_id
|
||||
if self.entry.options.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to Home Assistant."""
|
||||
@@ -114,6 +118,9 @@ class OllamaConversationEntity(
|
||||
self.hass, "conversation", self.entry.entry_id, self.entity_id
|
||||
)
|
||||
conversation.async_set_agent(self.hass, self.entry, self)
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When entity will be removed from Home Assistant."""
|
||||
@@ -334,3 +341,10 @@ class OllamaConversationEntity(
|
||||
message_history.messages = [
|
||||
message_history.messages[0]
|
||||
] + message_history.messages[drop_index:]
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -23,6 +23,7 @@ from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import assist_pipeline, conversation
|
||||
from homeassistant.components.conversation import trace
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
@@ -109,6 +110,9 @@ class OpenAIConversationEntity(
|
||||
self.hass, "conversation", self.entry.entry_id, self.entity_id
|
||||
)
|
||||
conversation.async_set_agent(self.hass, self.entry, self)
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When entity will be removed from Home Assistant."""
|
||||
@@ -319,3 +323,10 @@ class OpenAIConversationEntity(
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response, conversation_id=conversation_id
|
||||
)
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -132,7 +132,11 @@ def browse_media( # noqa: C901
|
||||
"children": [],
|
||||
}
|
||||
for playlist in plex_server.playlists():
|
||||
if playlist.playlistType != "audio" and platform == "sonos":
|
||||
if (
|
||||
playlist.type != "directory"
|
||||
and playlist.playlistType != "audio"
|
||||
and platform == "sonos"
|
||||
):
|
||||
continue
|
||||
try:
|
||||
playlists_info["children"].append(item_payload(playlist))
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"requirements": ["reolink-aio==0.9.6"]
|
||||
"requirements": ["reolink-aio==0.9.7"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -80,8 +81,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> boo
|
||||
|
||||
client_session = async_get_clientsession(hass)
|
||||
|
||||
gateway = ASyncSenseable(
|
||||
api_timeout=timeout, wss_timeout=timeout, client_session=client_session
|
||||
# Creating the AsyncSenseable object loads
|
||||
# ssl certificates which does blocking IO
|
||||
gateway = await hass.async_add_executor_job(
|
||||
partial(
|
||||
ASyncSenseable,
|
||||
api_timeout=timeout,
|
||||
wss_timeout=timeout,
|
||||
client_session=client_session,
|
||||
)
|
||||
)
|
||||
gateway.rate_limit = ACTIVE_UPDATE_RATE
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Config flow for Sense integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -48,8 +49,15 @@ class SenseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
timeout = self._auth_data[CONF_TIMEOUT]
|
||||
client_session = async_get_clientsession(self.hass)
|
||||
|
||||
self._gateway = ASyncSenseable(
|
||||
api_timeout=timeout, wss_timeout=timeout, client_session=client_session
|
||||
# Creating the AsyncSenseable object loads
|
||||
# ssl certificates which does blocking IO
|
||||
self._gateway = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
ASyncSenseable,
|
||||
api_timeout=timeout,
|
||||
wss_timeout=timeout,
|
||||
client_session=client_session,
|
||||
)
|
||||
)
|
||||
self._gateway.rate_limit = ACTIVE_UPDATE_RATE
|
||||
await self._gateway.authenticate(
|
||||
|
||||
@@ -279,6 +279,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle zeroconf discovery."""
|
||||
if discovery_info.ip_address.version == 6:
|
||||
return self.async_abort(reason="ipv6_not_supported")
|
||||
host = discovery_info.host
|
||||
# First try to get the mac address from the name
|
||||
# so we can avoid making another connection to the
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used."
|
||||
"another_device": "Re-configuration was unsuccessful, the IP address/hostname of another Shelly device was used.",
|
||||
"ipv6_not_supported": "IPv6 is not supported."
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
|
||||
@@ -185,7 +185,7 @@ async def async_setup_entry(
|
||||
{vol.Required(ATTR_OTHER_PLAYER): cv.string},
|
||||
"async_sync",
|
||||
)
|
||||
platform.async_register_entity_service(SERVICE_UNSYNC, None, "async_unsync")
|
||||
platform.async_register_entity_service(SERVICE_UNSYNC, {}, "async_unsync")
|
||||
|
||||
# Start server discovery task if not already running
|
||||
entry.async_on_unload(async_at_start(hass, start_server_discovery))
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"issues": {
|
||||
"water_heater_fallback": {
|
||||
"title": "Tado Water Heater entities now support fallback options",
|
||||
"description": "Due to added support for water heaters entities, these entities may use different overlay. Please configure integration entity and tado app water heater zone overlay options."
|
||||
"description": "Due to added support for water heaters entities, these entities may use different overlay. Please configure integration entity and tado app water heater zone overlay options. Otherwise, please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +364,6 @@ class TagEntity(Entity):
|
||||
"""Representation of a Tag entity."""
|
||||
|
||||
_unrecorded_attributes = frozenset({TAG_ID})
|
||||
_attr_translation_key = DOMAIN
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"entity": {
|
||||
"tag": {
|
||||
"tag": {
|
||||
"default": "mdi:tag-outline"
|
||||
}
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:tag-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"name": "Tags",
|
||||
"codeowners": ["@balloob", "@dmulcahey"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/tag",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
{
|
||||
"title": "Tag",
|
||||
"entity": {
|
||||
"tag": {
|
||||
"tag": {
|
||||
"state_attributes": {
|
||||
"tag_id": {
|
||||
"name": "Tag ID"
|
||||
},
|
||||
"last_scanned_by_device_id": {
|
||||
"name": "Last scanned by device ID"
|
||||
}
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"state_attributes": {
|
||||
"tag_id": {
|
||||
"name": "Tag ID"
|
||||
},
|
||||
"last_scanned_by_device_id": {
|
||||
"name": "Last scanned by device ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,11 @@ class Tami4ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
device_name = api.device_metadata.name
|
||||
if device_name is None:
|
||||
device_name = "Tami4"
|
||||
return self.async_create_entry(
|
||||
title=api.device_metadata.name,
|
||||
title=device_name,
|
||||
data={CONF_REFRESH_TOKEN: refresh_token},
|
||||
)
|
||||
|
||||
|
||||
@@ -55,8 +55,13 @@ class TedeeLockEntity(TedeeEntity, LockEntity):
|
||||
super().__init__(lock, coordinator, "lock")
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool:
|
||||
def is_locked(self) -> bool | None:
|
||||
"""Return true if lock is locked."""
|
||||
if self._lock.state in (
|
||||
TedeeLockState.HALF_OPEN,
|
||||
TedeeLockState.UNKNOWN,
|
||||
):
|
||||
return None
|
||||
return self._lock.state == TedeeLockState.LOCKED
|
||||
|
||||
@property
|
||||
@@ -87,7 +92,11 @@ class TedeeLockEntity(TedeeEntity, LockEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self._lock.is_connected
|
||||
return (
|
||||
super().available
|
||||
and self._lock.is_connected
|
||||
and self._lock.state != TedeeLockState.UNCALIBRATED
|
||||
)
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the door."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from tesla_fleet_api.exceptions import (
|
||||
TeslaFleetError,
|
||||
)
|
||||
|
||||
from homeassistant.components.application_credentials import ClientCredential
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -26,7 +27,9 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from .const import DOMAIN, LOGGER, MODELS
|
||||
from .application_credentials import TeslaOAuth2Implementation
|
||||
from .config_flow import OAuth2FlowHandler
|
||||
from .const import CLIENT_ID, DOMAIN, LOGGER, MODELS, NAME
|
||||
from .coordinator import (
|
||||
TeslaFleetEnergySiteInfoCoordinator,
|
||||
TeslaFleetEnergySiteLiveCoordinator,
|
||||
@@ -51,6 +54,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
scopes = token["scp"]
|
||||
region = token["ou_code"].lower()
|
||||
|
||||
OAuth2FlowHandler.async_register_implementation(
|
||||
hass,
|
||||
TeslaOAuth2Implementation(hass, DOMAIN, ClientCredential(CLIENT_ID, "", NAME)),
|
||||
)
|
||||
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
oauth_session = OAuth2Session(hass, entry, implementation)
|
||||
refresh_lock = asyncio.Lock()
|
||||
@@ -86,7 +94,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
vehicles: list[TeslaFleetVehicleData] = []
|
||||
energysites: list[TeslaFleetEnergyData] = []
|
||||
for product in products:
|
||||
if "vin" in product and tesla.vehicle:
|
||||
if "vin" in product and hasattr(tesla, "vehicle"):
|
||||
# Remove the protobuff 'cached_data' that we do not use to save memory
|
||||
product.pop("cached_data", None)
|
||||
vin = product["vin"]
|
||||
@@ -111,7 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
device=device,
|
||||
)
|
||||
)
|
||||
elif "energy_site_id" in product and tesla.energy:
|
||||
elif "energy_site_id" in product and hasattr(tesla, "energy"):
|
||||
site_id = product["energy_site_id"]
|
||||
if not (
|
||||
product["components"]["battery"]
|
||||
|
||||
@@ -5,15 +5,17 @@ import hashlib
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.application_credentials import ClientCredential
|
||||
from homeassistant.components.application_credentials import (
|
||||
AuthImplementation,
|
||||
AuthorizationServer,
|
||||
ClientCredential,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN, SCOPES
|
||||
from .const import AUTHORIZE_URL, DOMAIN, SCOPES, TOKEN_URL
|
||||
|
||||
CLIENT_ID = "71b813eb-4a2e-483a-b831-4dec5cb9bf0d"
|
||||
AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize"
|
||||
TOKEN_URL = "https://auth.tesla.com/oauth2/v3/token"
|
||||
AUTH_SERVER = AuthorizationServer(AUTHORIZE_URL, TOKEN_URL)
|
||||
|
||||
|
||||
async def async_get_auth_implementation(
|
||||
@@ -23,15 +25,16 @@ async def async_get_auth_implementation(
|
||||
return TeslaOAuth2Implementation(
|
||||
hass,
|
||||
DOMAIN,
|
||||
credential,
|
||||
)
|
||||
|
||||
|
||||
class TeslaOAuth2Implementation(config_entry_oauth2_flow.LocalOAuth2Implementation):
|
||||
class TeslaOAuth2Implementation(AuthImplementation):
|
||||
"""Tesla Fleet API Open Source Oauth2 implementation."""
|
||||
|
||||
_name = "Tesla Fleet API"
|
||||
|
||||
def __init__(self, hass: HomeAssistant, domain: str) -> None:
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, domain: str, credential: ClientCredential
|
||||
) -> None:
|
||||
"""Initialize local auth implementation."""
|
||||
self.hass = hass
|
||||
self._domain = domain
|
||||
@@ -45,10 +48,8 @@ class TeslaOAuth2Implementation(config_entry_oauth2_flow.LocalOAuth2Implementati
|
||||
super().__init__(
|
||||
hass,
|
||||
domain,
|
||||
CLIENT_ID,
|
||||
"", # Implementation has no client secret
|
||||
AUTHORIZE_URL,
|
||||
TOKEN_URL,
|
||||
credential,
|
||||
AUTH_SERVER,
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -8,10 +8,12 @@ from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from homeassistant.components.application_credentials import ClientCredential
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .application_credentials import TeslaOAuth2Implementation
|
||||
from .const import CLIENT_ID, DOMAIN, LOGGER, NAME
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
@@ -27,6 +29,19 @@ class OAuth2FlowHandler(
|
||||
"""Return logger."""
|
||||
return LOGGER
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow start."""
|
||||
self.async_register_implementation(
|
||||
self.hass,
|
||||
TeslaOAuth2Implementation(
|
||||
self.hass, DOMAIN, ClientCredential(CLIENT_ID, "", NAME)
|
||||
),
|
||||
)
|
||||
|
||||
return await super().async_step_user()
|
||||
|
||||
async def async_oauth_create_entry(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
|
||||
@@ -13,6 +13,11 @@ CONF_REFRESH_TOKEN = "refresh_token"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
NAME = "Home Assistant"
|
||||
CLIENT_ID = "71b813eb-4a2e-483a-b831-4dec5cb9bf0d"
|
||||
AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize"
|
||||
TOKEN_URL = "https://auth.tesla.com/oauth2/v3/token"
|
||||
|
||||
SCOPES = [
|
||||
Scope.OPENID,
|
||||
Scope.OFFLINE_ACCESS,
|
||||
|
||||
@@ -44,6 +44,7 @@ from homeassistant.core import Event as core_Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util import slugify
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import UnifiConfigEntry
|
||||
@@ -247,8 +248,9 @@ def make_wan_latency_sensors() -> tuple[UnifiSensorEntityDescription, ...]:
|
||||
def make_wan_latency_entity_description(
|
||||
wan: Literal["WAN", "WAN2"], name: str, monitor_target: str
|
||||
) -> UnifiSensorEntityDescription:
|
||||
name_wan = f"{name} {wan}"
|
||||
return UnifiSensorEntityDescription[Devices, Device](
|
||||
key=f"{name} {wan} latency",
|
||||
key=f"{name_wan} latency",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -257,13 +259,12 @@ def make_wan_latency_sensors() -> tuple[UnifiSensorEntityDescription, ...]:
|
||||
api_handler_fn=lambda api: api.devices,
|
||||
available_fn=async_device_available_fn,
|
||||
device_info_fn=async_device_device_info_fn,
|
||||
name_fn=lambda _: f"{name} {wan} latency",
|
||||
name_fn=lambda device: f"{name_wan} latency",
|
||||
object_fn=lambda api, obj_id: api.devices[obj_id],
|
||||
supported_fn=partial(
|
||||
async_device_wan_latency_supported_fn, wan, monitor_target
|
||||
),
|
||||
unique_id_fn=lambda hub,
|
||||
obj_id: f"{name.lower}_{wan.lower}_latency-{obj_id}",
|
||||
unique_id_fn=lambda hub, obj_id: f"{slugify(name_wan)}_latency-{obj_id}",
|
||||
value_fn=partial(async_device_wan_latency_value_fn, wan, monitor_target),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"_": {
|
||||
"default": "mdi:valve-open",
|
||||
"state": {
|
||||
"off": "mdi:valve-closed"
|
||||
"closed": "mdi:valve-closed"
|
||||
}
|
||||
},
|
||||
"gas": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"water": {
|
||||
"default": "mdi:valve-open",
|
||||
"state": {
|
||||
"off": "mdi:valve-closed"
|
||||
"closed": "mdi:valve-closed"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -108,10 +108,14 @@ class VeluxEntity(Entity):
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, node: Node) -> None:
|
||||
def __init__(self, node: Node, config_entry_id: str) -> None:
|
||||
"""Initialize the Velux device."""
|
||||
self.node = node
|
||||
self._attr_unique_id = node.serial_number
|
||||
self._attr_unique_id = (
|
||||
node.serial_number
|
||||
if node.serial_number
|
||||
else f"{config_entry_id}_{node.node_id}"
|
||||
)
|
||||
self._attr_name = node.name if node.name else f"#{node.node_id}"
|
||||
|
||||
@callback
|
||||
|
||||
@@ -29,7 +29,7 @@ async def async_setup_entry(
|
||||
"""Set up cover(s) for Velux platform."""
|
||||
module = hass.data[DOMAIN][config.entry_id]
|
||||
async_add_entities(
|
||||
VeluxCover(node)
|
||||
VeluxCover(node, config.entry_id)
|
||||
for node in module.pyvlx.nodes
|
||||
if isinstance(node, OpeningDevice)
|
||||
)
|
||||
@@ -41,9 +41,9 @@ class VeluxCover(VeluxEntity, CoverEntity):
|
||||
_is_blind = False
|
||||
node: OpeningDevice
|
||||
|
||||
def __init__(self, node: OpeningDevice) -> None:
|
||||
def __init__(self, node: OpeningDevice, config_entry_id: str) -> None:
|
||||
"""Initialize VeluxCover."""
|
||||
super().__init__(node)
|
||||
super().__init__(node, config_entry_id)
|
||||
self._attr_device_class = CoverDeviceClass.WINDOW
|
||||
if isinstance(node, Awning):
|
||||
self._attr_device_class = CoverDeviceClass.AWNING
|
||||
|
||||
@@ -23,7 +23,7 @@ async def async_setup_entry(
|
||||
module = hass.data[DOMAIN][config.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
VeluxLight(node)
|
||||
VeluxLight(node, config.entry_id)
|
||||
for node in module.pyvlx.nodes
|
||||
if isinstance(node, LighteningDevice)
|
||||
)
|
||||
|
||||
@@ -319,8 +319,8 @@
|
||||
"ess_discharge_total": {
|
||||
"name": "Battery discharge total"
|
||||
},
|
||||
"pcc_current_power_exchange": {
|
||||
"name": "Grid power exchange"
|
||||
"pcc_transfer_power_exchange": {
|
||||
"name": "Power exchange with grid"
|
||||
},
|
||||
"pcc_energy_consumption": {
|
||||
"name": "Energy import from grid"
|
||||
|
||||
@@ -21,7 +21,7 @@ from voip_utils import (
|
||||
VoipDatagramProtocol,
|
||||
)
|
||||
|
||||
from homeassistant.components import stt, tts
|
||||
from homeassistant.components import assist_pipeline, stt, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
Pipeline,
|
||||
PipelineEvent,
|
||||
@@ -331,15 +331,14 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
async with asyncio.timeout(self.audio_timeout):
|
||||
chunk = await self._audio_queue.get()
|
||||
|
||||
assert audio_enhancer.samples_per_chunk is not None
|
||||
vad_buffer = AudioBuffer(audio_enhancer.samples_per_chunk * WIDTH)
|
||||
vad_buffer = AudioBuffer(assist_pipeline.SAMPLES_PER_CHUNK * WIDTH)
|
||||
|
||||
while chunk:
|
||||
chunk_buffer.append(chunk)
|
||||
|
||||
segmenter.process_with_vad(
|
||||
chunk,
|
||||
audio_enhancer.samples_per_chunk,
|
||||
assist_pipeline.SAMPLES_PER_CHUNK,
|
||||
lambda x: audio_enhancer.enhance_chunk(x, 0).is_speech is True,
|
||||
vad_buffer,
|
||||
)
|
||||
@@ -371,13 +370,12 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
async with asyncio.timeout(self.audio_timeout):
|
||||
chunk = await self._audio_queue.get()
|
||||
|
||||
assert audio_enhancer.samples_per_chunk is not None
|
||||
vad_buffer = AudioBuffer(audio_enhancer.samples_per_chunk * WIDTH)
|
||||
vad_buffer = AudioBuffer(assist_pipeline.SAMPLES_PER_CHUNK * WIDTH)
|
||||
|
||||
while chunk:
|
||||
if not segmenter.process_with_vad(
|
||||
chunk,
|
||||
audio_enhancer.samples_per_chunk,
|
||||
assist_pipeline.SAMPLES_PER_CHUNK,
|
||||
lambda x: audio_enhancer.enhance_chunk(x, 0).is_speech is True,
|
||||
vad_buffer,
|
||||
):
|
||||
@@ -437,13 +435,13 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
sample_channels = wav_file.getnchannels()
|
||||
|
||||
if (
|
||||
(sample_rate != 16000)
|
||||
or (sample_width != 2)
|
||||
or (sample_channels != 1)
|
||||
(sample_rate != RATE)
|
||||
or (sample_width != WIDTH)
|
||||
or (sample_channels != CHANNELS)
|
||||
):
|
||||
raise ValueError(
|
||||
"Expected rate/width/channels as 16000/2/1,"
|
||||
" got {sample_rate}/{sample_width}/{sample_channels}}"
|
||||
f"Expected rate/width/channels as {RATE}/{WIDTH}/{CHANNELS},"
|
||||
f" got {sample_rate}/{sample_width}/{sample_channels}"
|
||||
)
|
||||
|
||||
audio_bytes = wav_file.readframes(wav_file.getnframes())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user