forked from home-assistant/core
Compare commits
120 Commits
2024.8.0b0
...
2024.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ae4fc9504a | |||
| 2ef337ec2e | |||
| 723b7bd532 | |||
| 4fdb11b0d8 | |||
| fe2e6c37f4 | |||
| 4a75c55a8f | |||
| dfb59469cf | |||
| bdb2e1e2e9 | |||
| c4f6f1e3d8 | |||
| fb3eae54ea | |||
| d3f8fce788 | |||
| 44e58a8c87 | |||
| 3d3879b0db | |||
| a8b1eb34f3 | |||
| fd77058def | |||
| b147ca6c5b | |||
| 670c4cacfa | |||
| 1ed0a89303 | |||
| ab0597da7b | |||
| a3db6bc8fa | |||
| 9bfc8f6e27 | |||
| 6fddef2dc5 | |||
| ec08a85aa0 | |||
| de7af575c5 | |||
| d3831bae4e | |||
| 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,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airgradient==0.7.1"],
|
||||
"requirements": ["airgradient==0.8.0"],
|
||||
"zeroconf": ["_airgradient._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -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))},
|
||||
@@ -317,21 +317,24 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
await self.async_update_status()
|
||||
|
||||
except (TimeoutError, ClientError):
|
||||
_LOGGER.error("Node %s:%s is offline, retrying later", self.name, self.port)
|
||||
_LOGGER.error("Node %s:%s is offline, retrying later", self.host, self.port)
|
||||
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
|
||||
self.start_polling()
|
||||
|
||||
except CancelledError:
|
||||
_LOGGER.debug("Stopping the polling of node %s:%s", self.name, self.port)
|
||||
_LOGGER.debug("Stopping the polling of node %s:%s", self.host, self.port)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error in %s:%s", self.name, self.port)
|
||||
_LOGGER.exception("Unexpected error in %s:%s", self.host, self.port)
|
||||
raise
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Start the polling task."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self._polling_task = self.hass.async_create_task(self._start_poll_command())
|
||||
self._polling_task = self.hass.async_create_background_task(
|
||||
self._start_poll_command(),
|
||||
name=f"bluesound.polling_{self.host}:{self.port}",
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop the polling task."""
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["dio_chacon_api"],
|
||||
"requirements": ["dio-chacon-wifi-api==1.1.0"]
|
||||
"requirements": ["dio-chacon-wifi-api==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from coinbase.wallet.client import Client
|
||||
from coinbase.rest import RESTClient
|
||||
from coinbase.rest.rest_base import HTTPError
|
||||
from coinbase.wallet.client import Client as LegacyClient
|
||||
from coinbase.wallet.error import AuthenticationError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -15,8 +17,23 @@ from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import (
|
||||
ACCOUNT_IS_VAULT,
|
||||
API_ACCOUNT_AMOUNT,
|
||||
API_ACCOUNT_AVALIABLE,
|
||||
API_ACCOUNT_BALANCE,
|
||||
API_ACCOUNT_CURRENCY,
|
||||
API_ACCOUNT_CURRENCY_CODE,
|
||||
API_ACCOUNT_HOLD,
|
||||
API_ACCOUNT_ID,
|
||||
API_ACCOUNTS_DATA,
|
||||
API_ACCOUNT_NAME,
|
||||
API_ACCOUNT_VALUE,
|
||||
API_ACCOUNTS,
|
||||
API_DATA,
|
||||
API_RATES_CURRENCY,
|
||||
API_RESOURCE_TYPE,
|
||||
API_TYPE_VAULT,
|
||||
API_V3_ACCOUNT_ID,
|
||||
API_V3_TYPE_VAULT,
|
||||
CONF_CURRENCIES,
|
||||
CONF_EXCHANGE_BASE,
|
||||
CONF_EXCHANGE_RATES,
|
||||
@@ -59,9 +76,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData:
|
||||
"""Create and update a Coinbase Data instance."""
|
||||
client = Client(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
|
||||
if "organizations" not in entry.data[CONF_API_KEY]:
|
||||
client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
|
||||
version = "v2"
|
||||
else:
|
||||
client = RESTClient(
|
||||
api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN]
|
||||
)
|
||||
version = "v3"
|
||||
base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD")
|
||||
instance = CoinbaseData(client, base_rate)
|
||||
instance = CoinbaseData(client, base_rate, version)
|
||||
instance.update()
|
||||
return instance
|
||||
|
||||
@@ -86,42 +110,83 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non
|
||||
registry.async_remove(entity.entity_id)
|
||||
|
||||
|
||||
def get_accounts(client):
|
||||
def get_accounts(client, version):
|
||||
"""Handle paginated accounts."""
|
||||
response = client.get_accounts()
|
||||
accounts = response[API_ACCOUNTS_DATA]
|
||||
next_starting_after = response.pagination.next_starting_after
|
||||
|
||||
while next_starting_after:
|
||||
response = client.get_accounts(starting_after=next_starting_after)
|
||||
accounts += response[API_ACCOUNTS_DATA]
|
||||
if version == "v2":
|
||||
accounts = response[API_DATA]
|
||||
next_starting_after = response.pagination.next_starting_after
|
||||
|
||||
return accounts
|
||||
while next_starting_after:
|
||||
response = client.get_accounts(starting_after=next_starting_after)
|
||||
accounts += response[API_DATA]
|
||||
next_starting_after = response.pagination.next_starting_after
|
||||
|
||||
return [
|
||||
{
|
||||
API_ACCOUNT_ID: account[API_ACCOUNT_ID],
|
||||
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
|
||||
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY][
|
||||
API_ACCOUNT_CURRENCY_CODE
|
||||
],
|
||||
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT],
|
||||
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_TYPE_VAULT,
|
||||
}
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
accounts = response[API_ACCOUNTS]
|
||||
while response["has_next"]:
|
||||
response = client.get_accounts(cursor=response["cursor"])
|
||||
accounts += response["accounts"]
|
||||
|
||||
return [
|
||||
{
|
||||
API_ACCOUNT_ID: account[API_V3_ACCOUNT_ID],
|
||||
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
|
||||
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY],
|
||||
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE]
|
||||
+ account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE],
|
||||
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_V3_TYPE_VAULT,
|
||||
}
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
|
||||
class CoinbaseData:
|
||||
"""Get the latest data and update the states."""
|
||||
|
||||
def __init__(self, client, exchange_base):
|
||||
def __init__(self, client, exchange_base, version):
|
||||
"""Init the coinbase data object."""
|
||||
|
||||
self.client = client
|
||||
self.accounts = None
|
||||
self.exchange_base = exchange_base
|
||||
self.exchange_rates = None
|
||||
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
|
||||
if version == "v2":
|
||||
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
|
||||
else:
|
||||
self.user_id = (
|
||||
"v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID]
|
||||
)
|
||||
self.api_version = version
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def update(self):
|
||||
"""Get the latest data from coinbase."""
|
||||
|
||||
try:
|
||||
self.accounts = get_accounts(self.client)
|
||||
self.exchange_rates = self.client.get_exchange_rates(
|
||||
currency=self.exchange_base
|
||||
)
|
||||
except AuthenticationError as coinbase_error:
|
||||
self.accounts = get_accounts(self.client, self.api_version)
|
||||
if self.api_version == "v2":
|
||||
self.exchange_rates = self.client.get_exchange_rates(
|
||||
currency=self.exchange_base
|
||||
)
|
||||
else:
|
||||
self.exchange_rates = self.client.get(
|
||||
"/v2/exchange-rates",
|
||||
params={API_RATES_CURRENCY: self.exchange_base},
|
||||
)[API_DATA]
|
||||
except (AuthenticationError, HTTPError) as coinbase_error:
|
||||
_LOGGER.error(
|
||||
"Authentication error connecting to coinbase: %s", coinbase_error
|
||||
)
|
||||
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from coinbase.wallet.client import Client
|
||||
from coinbase.rest import RESTClient
|
||||
from coinbase.rest.rest_base import HTTPError
|
||||
from coinbase.wallet.client import Client as LegacyClient
|
||||
from coinbase.wallet.error import AuthenticationError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -15,18 +17,17 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN
|
||||
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from . import get_accounts
|
||||
from .const import (
|
||||
ACCOUNT_IS_VAULT,
|
||||
API_ACCOUNT_CURRENCY,
|
||||
API_ACCOUNT_CURRENCY_CODE,
|
||||
API_DATA,
|
||||
API_RATES,
|
||||
API_RESOURCE_TYPE,
|
||||
API_TYPE_VAULT,
|
||||
CONF_CURRENCIES,
|
||||
CONF_EXCHANGE_BASE,
|
||||
CONF_EXCHANGE_PRECISION,
|
||||
@@ -49,8 +50,11 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
|
||||
def get_user_from_client(api_key, api_token):
|
||||
"""Get the user name from Coinbase API credentials."""
|
||||
client = Client(api_key, api_token)
|
||||
return client.get_current_user()
|
||||
if "organizations" not in api_key:
|
||||
client = LegacyClient(api_key, api_token)
|
||||
return client.get_current_user()["name"]
|
||||
client = RESTClient(api_key=api_key, api_secret=api_token)
|
||||
return client.get_portfolios()["portfolios"][0]["name"]
|
||||
|
||||
|
||||
async def validate_api(hass: HomeAssistant, data):
|
||||
@@ -60,11 +64,13 @@ async def validate_api(hass: HomeAssistant, data):
|
||||
user = await hass.async_add_executor_job(
|
||||
get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN]
|
||||
)
|
||||
except AuthenticationError as error:
|
||||
if "api key" in str(error):
|
||||
except (AuthenticationError, HTTPError) as error:
|
||||
if "api key" in str(error) or " 401 Client Error" in str(error):
|
||||
_LOGGER.debug("Coinbase rejected API credentials due to an invalid API key")
|
||||
raise InvalidKey from error
|
||||
if "invalid signature" in str(error):
|
||||
if "invalid signature" in str(
|
||||
error
|
||||
) or "'Could not deserialize key data" in str(error):
|
||||
_LOGGER.debug(
|
||||
"Coinbase rejected API credentials due to an invalid API secret"
|
||||
)
|
||||
@@ -73,8 +79,8 @@ async def validate_api(hass: HomeAssistant, data):
|
||||
raise InvalidAuth from error
|
||||
except ConnectionError as error:
|
||||
raise CannotConnect from error
|
||||
|
||||
return {"title": user["name"]}
|
||||
api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2"
|
||||
return {"title": user, "api_version": api_version}
|
||||
|
||||
|
||||
async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options):
|
||||
@@ -82,14 +88,20 @@ async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, optio
|
||||
|
||||
client = hass.data[DOMAIN][config_entry.entry_id].client
|
||||
|
||||
accounts = await hass.async_add_executor_job(get_accounts, client)
|
||||
accounts = await hass.async_add_executor_job(
|
||||
get_accounts, client, config_entry.data.get("api_version", "v2")
|
||||
)
|
||||
|
||||
accounts_currencies = [
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
account[API_ACCOUNT_CURRENCY]
|
||||
for account in accounts
|
||||
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
|
||||
if not account[ACCOUNT_IS_VAULT]
|
||||
]
|
||||
available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
|
||||
if config_entry.data.get("api_version", "v2") == "v2":
|
||||
available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
|
||||
else:
|
||||
resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates")
|
||||
available_rates = resp[API_DATA]
|
||||
if CONF_CURRENCIES in options:
|
||||
for currency in options[CONF_CURRENCIES]:
|
||||
if currency not in accounts_currencies:
|
||||
@@ -134,6 +146,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
user_input[CONF_API_VERSION] = info["api_version"]
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Constants used for Coinbase."""
|
||||
|
||||
ACCOUNT_IS_VAULT = "is_vault"
|
||||
|
||||
CONF_CURRENCIES = "account_balance_currencies"
|
||||
CONF_EXCHANGE_BASE = "exchange_base"
|
||||
CONF_EXCHANGE_RATES = "exchange_rate_currencies"
|
||||
@@ -10,18 +12,25 @@ DOMAIN = "coinbase"
|
||||
|
||||
# Constants for data returned by Coinbase API
|
||||
API_ACCOUNT_AMOUNT = "amount"
|
||||
API_ACCOUNT_AVALIABLE = "available_balance"
|
||||
API_ACCOUNT_BALANCE = "balance"
|
||||
API_ACCOUNT_CURRENCY = "currency"
|
||||
API_ACCOUNT_CURRENCY_CODE = "code"
|
||||
API_ACCOUNT_HOLD = "hold"
|
||||
API_ACCOUNT_ID = "id"
|
||||
API_ACCOUNT_NATIVE_BALANCE = "balance"
|
||||
API_ACCOUNT_NAME = "name"
|
||||
API_ACCOUNTS_DATA = "data"
|
||||
API_ACCOUNT_VALUE = "value"
|
||||
API_ACCOUNTS = "accounts"
|
||||
API_DATA = "data"
|
||||
API_RATES = "rates"
|
||||
API_RATES_CURRENCY = "currency"
|
||||
API_RESOURCE_PATH = "resource_path"
|
||||
API_RESOURCE_TYPE = "type"
|
||||
API_TYPE_VAULT = "vault"
|
||||
API_USD = "USD"
|
||||
API_V3_ACCOUNT_ID = "uuid"
|
||||
API_V3_TYPE_VAULT = "ACCOUNT_TYPE_VAULT"
|
||||
|
||||
WALLETS = {
|
||||
"1INCH": "1INCH",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/coinbase",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["coinbase"],
|
||||
"requirements": ["coinbase==2.1.0"]
|
||||
"requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"]
|
||||
}
|
||||
|
||||
@@ -12,15 +12,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import CoinbaseData
|
||||
from .const import (
|
||||
ACCOUNT_IS_VAULT,
|
||||
API_ACCOUNT_AMOUNT,
|
||||
API_ACCOUNT_BALANCE,
|
||||
API_ACCOUNT_CURRENCY,
|
||||
API_ACCOUNT_CURRENCY_CODE,
|
||||
API_ACCOUNT_ID,
|
||||
API_ACCOUNT_NAME,
|
||||
API_RATES,
|
||||
API_RESOURCE_TYPE,
|
||||
API_TYPE_VAULT,
|
||||
CONF_CURRENCIES,
|
||||
CONF_EXCHANGE_PRECISION,
|
||||
CONF_EXCHANGE_PRECISION_DEFAULT,
|
||||
@@ -31,6 +28,7 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_NATIVE_BALANCE = "Balance in native currency"
|
||||
ATTR_API_VERSION = "API Version"
|
||||
|
||||
CURRENCY_ICONS = {
|
||||
"BTC": "mdi:currency-btc",
|
||||
@@ -56,9 +54,9 @@ async def async_setup_entry(
|
||||
entities: list[SensorEntity] = []
|
||||
|
||||
provided_currencies: list[str] = [
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
account[API_ACCOUNT_CURRENCY]
|
||||
for account in instance.accounts
|
||||
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
|
||||
if not account[ACCOUNT_IS_VAULT]
|
||||
]
|
||||
|
||||
desired_currencies: list[str] = []
|
||||
@@ -73,6 +71,11 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
for currency in desired_currencies:
|
||||
_LOGGER.debug(
|
||||
"Attempting to set up %s account sensor with %s API",
|
||||
currency,
|
||||
instance.api_version,
|
||||
)
|
||||
if currency not in provided_currencies:
|
||||
_LOGGER.warning(
|
||||
(
|
||||
@@ -85,12 +88,17 @@ async def async_setup_entry(
|
||||
entities.append(AccountSensor(instance, currency))
|
||||
|
||||
if CONF_EXCHANGE_RATES in config_entry.options:
|
||||
entities.extend(
|
||||
ExchangeRateSensor(
|
||||
instance, rate, exchange_base_currency, exchange_precision
|
||||
for rate in config_entry.options[CONF_EXCHANGE_RATES]:
|
||||
_LOGGER.debug(
|
||||
"Attempting to set up %s account sensor with %s API",
|
||||
rate,
|
||||
instance.api_version,
|
||||
)
|
||||
entities.append(
|
||||
ExchangeRateSensor(
|
||||
instance, rate, exchange_base_currency, exchange_precision
|
||||
)
|
||||
)
|
||||
for rate in config_entry.options[CONF_EXCHANGE_RATES]
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -105,26 +113,21 @@ class AccountSensor(SensorEntity):
|
||||
self._coinbase_data = coinbase_data
|
||||
self._currency = currency
|
||||
for account in coinbase_data.accounts:
|
||||
if (
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE] != currency
|
||||
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
|
||||
):
|
||||
if account[API_ACCOUNT_CURRENCY] != currency or account[ACCOUNT_IS_VAULT]:
|
||||
continue
|
||||
self._attr_name = f"Coinbase {account[API_ACCOUNT_NAME]}"
|
||||
self._attr_unique_id = (
|
||||
f"coinbase-{account[API_ACCOUNT_ID]}-wallet-"
|
||||
f"{account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]}"
|
||||
f"{account[API_ACCOUNT_CURRENCY]}"
|
||||
)
|
||||
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
|
||||
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY][
|
||||
API_ACCOUNT_CURRENCY_CODE
|
||||
]
|
||||
self._attr_native_value = account[API_ACCOUNT_AMOUNT]
|
||||
self._attr_native_unit_of_measurement = account[API_ACCOUNT_CURRENCY]
|
||||
self._attr_icon = CURRENCY_ICONS.get(
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE],
|
||||
account[API_ACCOUNT_CURRENCY],
|
||||
DEFAULT_COIN_ICON,
|
||||
)
|
||||
self._native_balance = round(
|
||||
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
|
||||
float(account[API_ACCOUNT_AMOUNT])
|
||||
/ float(coinbase_data.exchange_rates[API_RATES][currency]),
|
||||
2,
|
||||
)
|
||||
@@ -144,21 +147,26 @@ class AccountSensor(SensorEntity):
|
||||
"""Return the state attributes of the sensor."""
|
||||
return {
|
||||
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
|
||||
ATTR_API_VERSION: self._coinbase_data.api_version,
|
||||
}
|
||||
|
||||
def update(self) -> None:
|
||||
"""Get the latest state of the sensor."""
|
||||
_LOGGER.debug(
|
||||
"Updating %s account sensor with %s API",
|
||||
self._currency,
|
||||
self._coinbase_data.api_version,
|
||||
)
|
||||
self._coinbase_data.update()
|
||||
for account in self._coinbase_data.accounts:
|
||||
if (
|
||||
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
|
||||
!= self._currency
|
||||
or account[API_RESOURCE_TYPE] == API_TYPE_VAULT
|
||||
account[API_ACCOUNT_CURRENCY] != self._currency
|
||||
or account[ACCOUNT_IS_VAULT]
|
||||
):
|
||||
continue
|
||||
self._attr_native_value = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT]
|
||||
self._attr_native_value = account[API_ACCOUNT_AMOUNT]
|
||||
self._native_balance = round(
|
||||
float(account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT])
|
||||
float(account[API_ACCOUNT_AMOUNT])
|
||||
/ float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
|
||||
2,
|
||||
)
|
||||
@@ -202,8 +210,13 @@ class ExchangeRateSensor(SensorEntity):
|
||||
|
||||
def update(self) -> None:
|
||||
"""Get the latest state of the sensor."""
|
||||
_LOGGER.debug(
|
||||
"Updating %s rate sensor with %s API",
|
||||
self._currency,
|
||||
self._coinbase_data.api_version,
|
||||
)
|
||||
self._coinbase_data.update()
|
||||
self._attr_native_value = round(
|
||||
1 / float(self._coinbase_data.exchange_rates.rates[self._currency]),
|
||||
1 / float(self._coinbase_data.exchange_rates[API_RATES][self._currency]),
|
||||
self._precision,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/daikin",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydaikin"],
|
||||
"requirements": ["pydaikin==2.13.1"],
|
||||
"requirements": ["pydaikin==2.13.2"],
|
||||
"zeroconf": ["_dkapi._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -187,7 +195,7 @@ class ConfiguredDoorBird:
|
||||
title: str | None = data.get("title")
|
||||
if not title or not title.startswith("Home Assistant"):
|
||||
continue
|
||||
event = title.split("(")[1].strip(")")
|
||||
event = title.partition("(")[2].strip(")")
|
||||
if input_type := favorite_input_type.get(identifier):
|
||||
events.append(DoorbirdEvent(event, input_type))
|
||||
elif input_type := default_event_types.get(event):
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -431,39 +431,42 @@ def rename_old_gas_to_mbus(
|
||||
) -> None:
|
||||
"""Rename old gas sensor to mbus variant."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
|
||||
if device_entry_v1 is not None:
|
||||
device_id = device_entry_v1.id
|
||||
for dev_id in (mbus_device_id, entry.entry_id):
|
||||
device_entry_v1 = dev_reg.async_get_device(identifiers={(DOMAIN, dev_id)})
|
||||
if device_entry_v1 is not None:
|
||||
device_id = device_entry_v1.id
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
entries = er.async_entries_for_device(ent_reg, device_id)
|
||||
ent_reg = er.async_get(hass)
|
||||
entries = er.async_entries_for_device(ent_reg, device_id)
|
||||
|
||||
for entity in entries:
|
||||
if entity.unique_id.endswith("belgium_5min_gas_meter_reading"):
|
||||
try:
|
||||
ent_reg.async_update_entity(
|
||||
entity.entity_id,
|
||||
new_unique_id=mbus_device_id,
|
||||
device_id=mbus_device_id,
|
||||
)
|
||||
except ValueError:
|
||||
LOGGER.debug(
|
||||
"Skip migration of %s because it already exists",
|
||||
entity.entity_id,
|
||||
)
|
||||
else:
|
||||
LOGGER.debug(
|
||||
"Migrated entity %s from unique id %s to %s",
|
||||
entity.entity_id,
|
||||
entity.unique_id,
|
||||
mbus_device_id,
|
||||
)
|
||||
# Cleanup old device
|
||||
dev_entities = er.async_entries_for_device(
|
||||
ent_reg, device_id, include_disabled_entities=True
|
||||
)
|
||||
if not dev_entities:
|
||||
dev_reg.async_remove_device(device_id)
|
||||
for entity in entries:
|
||||
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,
|
||||
new_unique_id=mbus_device_id,
|
||||
device_id=mbus_device_id,
|
||||
)
|
||||
except ValueError:
|
||||
LOGGER.debug(
|
||||
"Skip migration of %s because it already exists",
|
||||
entity.entity_id,
|
||||
)
|
||||
else:
|
||||
LOGGER.debug(
|
||||
"Migrated entity %s from unique id %s to %s",
|
||||
entity.entity_id,
|
||||
entity.unique_id,
|
||||
mbus_device_id,
|
||||
)
|
||||
# Cleanup old device
|
||||
dev_entities = er.async_entries_for_device(
|
||||
ent_reg, device_id, include_disabled_entities=True
|
||||
)
|
||||
if not dev_entities:
|
||||
dev_reg.async_remove_device(device_id)
|
||||
|
||||
|
||||
def is_supported_description(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -653,8 +653,6 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
entities: list[er.RegistryEntry] = er.async_entries_for_config_entry(
|
||||
entity_reg, config_entry.entry_id
|
||||
)
|
||||
|
||||
orphan_macs: set[str] = set()
|
||||
for entity in entities:
|
||||
entry_mac = entity.unique_id.split("_")[0]
|
||||
if (
|
||||
@@ -662,17 +660,16 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
or "_internet_access" in entity.unique_id
|
||||
) and entry_mac not in device_hosts:
|
||||
_LOGGER.info("Removing orphan entity entry %s", entity.entity_id)
|
||||
orphan_macs.add(entry_mac)
|
||||
entity_reg.async_remove(entity.entity_id)
|
||||
|
||||
device_reg = dr.async_get(self.hass)
|
||||
orphan_connections = {
|
||||
(CONNECTION_NETWORK_MAC, dr.format_mac(mac)) for mac in orphan_macs
|
||||
valid_connections = {
|
||||
(CONNECTION_NETWORK_MAC, dr.format_mac(mac)) for mac in device_hosts
|
||||
}
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_reg, config_entry.entry_id
|
||||
):
|
||||
if any(con in device.connections for con in orphan_connections):
|
||||
if not any(con in device.connections for con in valid_connections):
|
||||
_LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||
device_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
|
||||
@@ -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==20240809.0"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ from homeassistant.components.notify import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SERVICE,
|
||||
CONF_ACTION,
|
||||
CONF_ENTITIES,
|
||||
CONF_SERVICE,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -36,11 +37,37 @@ from .entity import GroupEntity
|
||||
|
||||
CONF_SERVICES = "services"
|
||||
|
||||
|
||||
def _backward_compat_schema(value: Any | None) -> Any:
|
||||
"""Backward compatibility for notify service schemas."""
|
||||
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
|
||||
# `service` has been renamed to `action`
|
||||
if CONF_SERVICE in value:
|
||||
if CONF_ACTION in value:
|
||||
raise vol.Invalid(
|
||||
"Cannot specify both 'service' and 'action'. Please use 'action' only."
|
||||
)
|
||||
value[CONF_ACTION] = value.pop(CONF_SERVICE)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_SERVICES): vol.All(
|
||||
cv.ensure_list,
|
||||
[{vol.Required(ATTR_SERVICE): cv.slug, vol.Optional(ATTR_DATA): dict}],
|
||||
[
|
||||
vol.All(
|
||||
_backward_compat_schema,
|
||||
{
|
||||
vol.Required(CONF_ACTION): cv.slug,
|
||||
vol.Optional(ATTR_DATA): dict,
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -88,7 +115,7 @@ class GroupNotifyPlatform(BaseNotificationService):
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
self.hass.services.async_call(
|
||||
DOMAIN, entity[ATTR_SERVICE], sending_payload, blocking=True
|
||||
DOMAIN, entity[CONF_ACTION], sending_payload, blocking=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-homewizard-energy==v6.1.1"],
|
||||
"requirements": ["python-homewizard-energy==v6.2.0"],
|
||||
"zeroconf": ["_hwenergy._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==1.0.11"]
|
||||
"requirements": ["pyjvcprojector==1.0.12"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/monzo",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["monzopy==1.3.0"]
|
||||
"requirements": ["monzopy==1.3.2"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from pyopenweathermap import OWMClient
|
||||
from pyopenweathermap import create_owm_client
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -33,6 +33,7 @@ class OpenweathermapData:
|
||||
"""Runtime data definition."""
|
||||
|
||||
name: str
|
||||
mode: str
|
||||
coordinator: WeatherUpdateCoordinator
|
||||
|
||||
|
||||
@@ -52,7 +53,7 @@ async def async_setup_entry(
|
||||
else:
|
||||
async_delete_issue(hass, entry.entry_id)
|
||||
|
||||
owm_client = OWMClient(api_key, mode, lang=language)
|
||||
owm_client = create_owm_client(api_key, mode, lang=language)
|
||||
weather_coordinator = WeatherUpdateCoordinator(
|
||||
owm_client, latitude, longitude, hass
|
||||
)
|
||||
@@ -61,7 +62,7 @@ async def async_setup_entry(
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
entry.runtime_data = OpenweathermapData(name, weather_coordinator)
|
||||
entry.runtime_data = OpenweathermapData(name, mode, weather_coordinator)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -58,10 +58,17 @@ FORECAST_MODE_DAILY = "daily"
|
||||
FORECAST_MODE_FREE_DAILY = "freedaily"
|
||||
FORECAST_MODE_ONECALL_HOURLY = "onecall_hourly"
|
||||
FORECAST_MODE_ONECALL_DAILY = "onecall_daily"
|
||||
OWM_MODE_V25 = "v2.5"
|
||||
OWM_MODE_FREE_CURRENT = "current"
|
||||
OWM_MODE_FREE_FORECAST = "forecast"
|
||||
OWM_MODE_V30 = "v3.0"
|
||||
OWM_MODES = [OWM_MODE_V30, OWM_MODE_V25]
|
||||
DEFAULT_OWM_MODE = OWM_MODE_V30
|
||||
OWM_MODE_V25 = "v2.5"
|
||||
OWM_MODES = [
|
||||
OWM_MODE_FREE_CURRENT,
|
||||
OWM_MODE_FREE_FORECAST,
|
||||
OWM_MODE_V30,
|
||||
OWM_MODE_V25,
|
||||
]
|
||||
DEFAULT_OWM_MODE = OWM_MODE_FREE_CURRENT
|
||||
|
||||
LANGUAGES = [
|
||||
"af",
|
||||
|
||||
@@ -86,8 +86,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""Format the weather response correctly."""
|
||||
_LOGGER.debug("OWM weather response: %s", weather_report)
|
||||
|
||||
current_weather = (
|
||||
self._get_current_weather_data(weather_report.current)
|
||||
if weather_report.current is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
return {
|
||||
ATTR_API_CURRENT: self._get_current_weather_data(weather_report.current),
|
||||
ATTR_API_CURRENT: current_weather,
|
||||
ATTR_API_HOURLY_FORECAST: [
|
||||
self._get_hourly_forecast_weather_data(item)
|
||||
for item in weather_report.hourly_forecast
|
||||
@@ -122,6 +128,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
}
|
||||
|
||||
def _get_hourly_forecast_weather_data(self, forecast: HourlyWeatherForecast):
|
||||
uv_index = float(forecast.uv_index) if forecast.uv_index is not None else None
|
||||
|
||||
return Forecast(
|
||||
datetime=forecast.date_time.isoformat(),
|
||||
condition=self._get_condition(forecast.condition.id),
|
||||
@@ -134,12 +142,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
wind_speed=forecast.wind_speed,
|
||||
native_wind_gust_speed=forecast.wind_gust,
|
||||
wind_bearing=forecast.wind_bearing,
|
||||
uv_index=float(forecast.uv_index),
|
||||
uv_index=uv_index,
|
||||
precipitation_probability=round(forecast.precipitation_probability * 100),
|
||||
precipitation=self._calc_precipitation(forecast.rain, forecast.snow),
|
||||
)
|
||||
|
||||
def _get_daily_forecast_weather_data(self, forecast: DailyWeatherForecast):
|
||||
uv_index = float(forecast.uv_index) if forecast.uv_index is not None else None
|
||||
|
||||
return Forecast(
|
||||
datetime=forecast.date_time.isoformat(),
|
||||
condition=self._get_condition(forecast.condition.id),
|
||||
@@ -153,7 +163,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
|
||||
wind_speed=forecast.wind_speed,
|
||||
native_wind_gust_speed=forecast.wind_gust,
|
||||
wind_bearing=forecast.wind_bearing,
|
||||
uv_index=float(forecast.uv_index),
|
||||
uv_index=uv_index,
|
||||
precipitation_probability=round(forecast.precipitation_probability * 100),
|
||||
precipitation=round(forecast.rain + forecast.snow, 2),
|
||||
)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/openweathermap",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyopenweathermap"],
|
||||
"requirements": ["pyopenweathermap==0.0.9"]
|
||||
"requirements": ["pyopenweathermap==0.1.1"]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.const import (
|
||||
UnitOfVolumetricFlux,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -47,6 +48,7 @@ from .const import (
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
OWM_MODE_FREE_FORECAST,
|
||||
)
|
||||
from .coordinator import WeatherUpdateCoordinator
|
||||
|
||||
@@ -161,16 +163,23 @@ async def async_setup_entry(
|
||||
name = domain_data.name
|
||||
weather_coordinator = domain_data.coordinator
|
||||
|
||||
entities: list[AbstractOpenWeatherMapSensor] = [
|
||||
OpenWeatherMapSensor(
|
||||
name,
|
||||
f"{config_entry.unique_id}-{description.key}",
|
||||
description,
|
||||
weather_coordinator,
|
||||
if domain_data.mode == OWM_MODE_FREE_FORECAST:
|
||||
entity_registry = er.async_get(hass)
|
||||
entries = er.async_entries_for_config_entry(
|
||||
entity_registry, config_entry.entry_id
|
||||
)
|
||||
for entry in entries:
|
||||
entity_registry.async_remove(entry.entity_id)
|
||||
else:
|
||||
async_add_entities(
|
||||
OpenWeatherMapSensor(
|
||||
name,
|
||||
f"{config_entry.unique_id}-{description.key}",
|
||||
description,
|
||||
weather_coordinator,
|
||||
)
|
||||
for description in WEATHER_SENSOR_TYPES
|
||||
)
|
||||
for description in WEATHER_SENSOR_TYPES
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AbstractOpenWeatherMapSensor(SensorEntity):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyopenweathermap import OWMClient, RequestError
|
||||
from pyopenweathermap import RequestError, create_owm_client
|
||||
|
||||
from homeassistant.const import CONF_LANGUAGE, CONF_MODE
|
||||
|
||||
@@ -16,7 +16,7 @@ async def validate_api_key(api_key, mode):
|
||||
api_key_valid = None
|
||||
errors, description_placeholders = {}, {}
|
||||
try:
|
||||
owm_client = OWMClient(api_key, mode)
|
||||
owm_client = create_owm_client(api_key, mode)
|
||||
api_key_valid = await owm_client.validate_key()
|
||||
except RequestError as error:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.components.weather import (
|
||||
WeatherEntityFeature,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
UnitOfLength,
|
||||
UnitOfPrecipitationDepth,
|
||||
UnitOfPressure,
|
||||
UnitOfSpeed,
|
||||
@@ -29,6 +30,7 @@ from .const import (
|
||||
ATTR_API_HUMIDITY,
|
||||
ATTR_API_PRESSURE,
|
||||
ATTR_API_TEMPERATURE,
|
||||
ATTR_API_VISIBILITY_DISTANCE,
|
||||
ATTR_API_WIND_BEARING,
|
||||
ATTR_API_WIND_GUST,
|
||||
ATTR_API_WIND_SPEED,
|
||||
@@ -36,6 +38,9 @@ from .const import (
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
OWM_MODE_FREE_FORECAST,
|
||||
OWM_MODE_V25,
|
||||
OWM_MODE_V30,
|
||||
)
|
||||
from .coordinator import WeatherUpdateCoordinator
|
||||
|
||||
@@ -48,10 +53,11 @@ async def async_setup_entry(
|
||||
"""Set up OpenWeatherMap weather entity based on a config entry."""
|
||||
domain_data = config_entry.runtime_data
|
||||
name = domain_data.name
|
||||
mode = domain_data.mode
|
||||
weather_coordinator = domain_data.coordinator
|
||||
|
||||
unique_id = f"{config_entry.unique_id}"
|
||||
owm_weather = OpenWeatherMapWeather(name, unique_id, weather_coordinator)
|
||||
owm_weather = OpenWeatherMapWeather(name, unique_id, mode, weather_coordinator)
|
||||
|
||||
async_add_entities([owm_weather], False)
|
||||
|
||||
@@ -66,11 +72,13 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
|
||||
_attr_native_pressure_unit = UnitOfPressure.HPA
|
||||
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
|
||||
_attr_native_visibility_unit = UnitOfLength.METERS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
unique_id: str,
|
||||
mode: str,
|
||||
weather_coordinator: WeatherUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
@@ -83,59 +91,71 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
|
||||
manufacturer=MANUFACTURER,
|
||||
name=DEFAULT_NAME,
|
||||
)
|
||||
self._attr_supported_features = (
|
||||
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
|
||||
)
|
||||
|
||||
if mode in (OWM_MODE_V30, OWM_MODE_V25):
|
||||
self._attr_supported_features = (
|
||||
WeatherEntityFeature.FORECAST_DAILY
|
||||
| WeatherEntityFeature.FORECAST_HOURLY
|
||||
)
|
||||
elif mode == OWM_MODE_FREE_FORECAST:
|
||||
self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY
|
||||
|
||||
@property
|
||||
def condition(self) -> str | None:
|
||||
"""Return the current condition."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CONDITION]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_CONDITION)
|
||||
|
||||
@property
|
||||
def cloud_coverage(self) -> float | None:
|
||||
"""Return the Cloud coverage in %."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_CLOUDS]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_CLOUDS)
|
||||
|
||||
@property
|
||||
def native_apparent_temperature(self) -> float | None:
|
||||
"""Return the apparent temperature."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_FEELS_LIKE_TEMPERATURE]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(
|
||||
ATTR_API_FEELS_LIKE_TEMPERATURE
|
||||
)
|
||||
|
||||
@property
|
||||
def native_temperature(self) -> float | None:
|
||||
"""Return the temperature."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_TEMPERATURE]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_TEMPERATURE)
|
||||
|
||||
@property
|
||||
def native_pressure(self) -> float | None:
|
||||
"""Return the pressure."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_PRESSURE]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_PRESSURE)
|
||||
|
||||
@property
|
||||
def humidity(self) -> float | None:
|
||||
"""Return the humidity."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_HUMIDITY]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_HUMIDITY)
|
||||
|
||||
@property
|
||||
def native_dew_point(self) -> float | None:
|
||||
"""Return the dew point."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_DEW_POINT]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_DEW_POINT)
|
||||
|
||||
@property
|
||||
def native_wind_gust_speed(self) -> float | None:
|
||||
"""Return the wind gust speed."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_GUST]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_GUST)
|
||||
|
||||
@property
|
||||
def native_wind_speed(self) -> float | None:
|
||||
"""Return the wind speed."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_SPEED]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_SPEED)
|
||||
|
||||
@property
|
||||
def wind_bearing(self) -> float | str | None:
|
||||
"""Return the wind bearing."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT][ATTR_API_WIND_BEARING]
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_BEARING)
|
||||
|
||||
@property
|
||||
def visibility(self) -> float | str | None:
|
||||
"""Return visibility."""
|
||||
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_VISIBILITY_DISTANCE)
|
||||
|
||||
@callback
|
||||
def _async_forecast_daily(self) -> list[Forecast] | None:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -632,7 +632,7 @@ def _update_states_table_with_foreign_key_options(
|
||||
|
||||
def _drop_foreign_key_constraints(
|
||||
session_maker: Callable[[], Session], engine: Engine, table: str, column: str
|
||||
) -> list[tuple[str, str, ReflectedForeignKeyConstraint]]:
|
||||
) -> tuple[bool, list[tuple[str, str, ReflectedForeignKeyConstraint]]]:
|
||||
"""Drop foreign key constraints for a table on specific columns."""
|
||||
inspector = sqlalchemy.inspect(engine)
|
||||
dropped_constraints = [
|
||||
@@ -649,6 +649,7 @@ def _drop_foreign_key_constraints(
|
||||
if foreign_key["name"] and foreign_key["constrained_columns"] == [column]
|
||||
]
|
||||
|
||||
fk_remove_ok = True
|
||||
for drop in drops:
|
||||
with session_scope(session=session_maker()) as session:
|
||||
try:
|
||||
@@ -660,8 +661,9 @@ def _drop_foreign_key_constraints(
|
||||
TABLE_STATES,
|
||||
column,
|
||||
)
|
||||
fk_remove_ok = False
|
||||
|
||||
return dropped_constraints
|
||||
return fk_remove_ok, dropped_constraints
|
||||
|
||||
|
||||
def _restore_foreign_key_constraints(
|
||||
@@ -1481,7 +1483,7 @@ class _SchemaVersion44Migrator(_SchemaVersionMigrator, target_version=44):
|
||||
for column in columns
|
||||
for dropped_constraint in _drop_foreign_key_constraints(
|
||||
self.session_maker, self.engine, table, column
|
||||
)
|
||||
)[1]
|
||||
]
|
||||
_LOGGER.debug("Dropped foreign key constraints: %s", dropped_constraints)
|
||||
|
||||
@@ -1956,14 +1958,15 @@ def cleanup_legacy_states_event_ids(instance: Recorder) -> bool:
|
||||
if instance.dialect_name == SupportedDialect.SQLITE:
|
||||
# SQLite does not support dropping foreign key constraints
|
||||
# so we have to rebuild the table
|
||||
rebuild_sqlite_table(session_maker, instance.engine, States)
|
||||
fk_remove_ok = rebuild_sqlite_table(session_maker, instance.engine, States)
|
||||
else:
|
||||
_drop_foreign_key_constraints(
|
||||
fk_remove_ok, _ = _drop_foreign_key_constraints(
|
||||
session_maker, instance.engine, TABLE_STATES, "event_id"
|
||||
)
|
||||
_drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX)
|
||||
instance.use_legacy_events_index = False
|
||||
_mark_migration_done(session, EventIDPostMigration)
|
||||
if fk_remove_ok:
|
||||
_drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX)
|
||||
instance.use_legacy_events_index = False
|
||||
_mark_migration_done(session, EventIDPostMigration)
|
||||
|
||||
return True
|
||||
|
||||
@@ -2419,6 +2422,7 @@ class EventIDPostMigration(BaseRunTimeMigration):
|
||||
|
||||
migration_id = "event_id_post_migration"
|
||||
task = MigrationTask
|
||||
migration_version = 2
|
||||
|
||||
@staticmethod
|
||||
def migrate_data(instance: Recorder) -> bool:
|
||||
@@ -2469,7 +2473,7 @@ def _mark_migration_done(
|
||||
|
||||
def rebuild_sqlite_table(
|
||||
session_maker: Callable[[], Session], engine: Engine, table: type[Base]
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""Rebuild an SQLite table.
|
||||
|
||||
This must only be called after all migrations are complete
|
||||
@@ -2524,8 +2528,10 @@ def rebuild_sqlite_table(
|
||||
# Swallow the exception since we do not want to ever raise
|
||||
# an integrity error as it would cause the database
|
||||
# to be discarded and recreated from scratch
|
||||
return False
|
||||
else:
|
||||
_LOGGER.warning("Rebuilding SQLite table %s finished", orig_name)
|
||||
return True
|
||||
finally:
|
||||
with session_scope(session=session_maker()) as session:
|
||||
# Step 12 - Re-enable foreign keys
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiorussound"],
|
||||
"requirements": ["aiorussound==2.2.0"]
|
||||
"requirements": ["aiorussound==2.2.2"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user