Compare commits

...

120 Commits

Author SHA1 Message Date
Franck Nijhof ae4fc9504a 2024.8.1 (#123544) 2024-08-10 19:32:02 +02:00
Franck Nijhof 2ef337ec2e Bump version to 2024.8.1 2024-08-10 18:41:57 +02:00
cnico 723b7bd532 Upgrade chacon_dio_api to version 1.2.0 (#123528)
Upgrade api version 1.2.0 with the first user feedback improvement
2024-08-10 18:41:39 +02:00
Joost Lekkerkerker 4fdb11b0d8 Bump AirGradient to 0.8.0 (#123527) 2024-08-10 18:41:36 +02:00
Matt Way fe2e6c37f4 Bump pydaikin to 2.13.2 (#123519) 2024-08-10 18:41:32 +02:00
Michael 4a75c55a8f Fix cleanup of old orphan device entries in AVM Fritz!Tools (#123516)
fix cleanup of old orphan device entries
2024-08-10 18:41:29 +02:00
Duco Sebel dfb59469cf Bumb python-homewizard-energy to 6.2.0 (#123514) 2024-08-10 18:41:26 +02:00
David F. Mulcahey bdb2e1e2e9 Bump zha lib to 0.0.30 (#123499) 2024-08-10 18:41:22 +02:00
Franck Nijhof c4f6f1e3d8 Update frontend to 20240809.0 (#123485) 2024-08-10 18:41:19 +02:00
Louis Christ fb3eae54ea Fix startup blocked by bluesound integration (#123483) 2024-08-10 18:41:16 +02:00
Jake Martin d3f8fce788 Bump monzopy to 1.3.2 (#123480) 2024-08-10 18:41:13 +02:00
Steve Easley 44e58a8c87 Bump pyjvcprojector to 1.0.12 to fix blocking call (#123473) 2024-08-10 18:41:09 +02:00
puddly 3d3879b0db Bump ZHA library to 0.0.29 (#123464)
* Bump zha to 0.0.29

* Pass the Core timezone to ZHA

* Add a unit test
2024-08-10 18:41:06 +02:00
Franck Nijhof a8b1eb34f3 Support action YAML syntax in old-style notify groups (#123457) 2024-08-10 18:41:03 +02:00
Matrix fd77058def Bump YoLink API to 0.4.7 (#123441) 2024-08-10 18:41:00 +02:00
Brett Adams b147ca6c5b Add missing logger to Tessie (#123413) 2024-08-10 18:40:57 +02:00
dupondje 670c4cacfa Also migrate dsmr entries for devices with correct serial (#123407)
dsmr: also migrate entries for devices with correct serial

When the dsmr code could not find the serial_nr for the gas meter,
it creates the gas meter device with the entry_id as identifier.

But when there is a correct serial_nr, it will use that as identifier
for the dsmr gas device.

Now the migration code did not take this into account, so migration to
the new name failed since it didn't look for the device with correct
serial_nr.

This commit fixes this and adds a test for this.
2024-08-10 18:40:53 +02:00
J. Nick Koston 1ed0a89303 Bump aiohttp to 3.10.2 (#123394) 2024-08-10 18:40:50 +02:00
J. Nick Koston ab0597da7b Ensure legacy event foreign key is removed from the states table when a previous rebuild failed (#123388)
* Ensure legacy event foreign key is removed from the states table

If the system ran out of disk space removing the FK, it would
fail. #121938 fixed that to try again, however that PR was made
ineffective by #122069 since it will never reach the check.

To solve this, the migration version is incremented to 2, and
the migration is no longer marked as done unless the rebuild
/fk removal is successful.

* fix logic for mysql

* fix test

* asserts

* coverage

* coverage

* narrow test

* fixes

* split tests

* should have skipped

* fixture must be used
2024-08-10 18:40:47 +02:00
Erik Montnemery a3db6bc8fa Revert "Fix blocking I/O while validating config schema" (#123377) 2024-08-10 18:40:44 +02:00
Noah Husby 9bfc8f6e27 Bump aiorussound to 2.2.2 (#123319) 2024-08-10 18:40:41 +02:00
J. Nick Koston 6fddef2dc5 Fix doorbird with externally added events (#123313) 2024-08-10 18:40:38 +02:00
fustom ec08a85aa0 Fix limit and order property for transmission integration (#123305) 2024-08-10 18:40:35 +02:00
Evgeny de7af575c5 Bump OpenWeatherMap to 0.1.1 (#120178)
* add owm modes

* fix tests

* fix modes

* remove sensors

* Update homeassistant/components/openweathermap/sensor.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-08-10 18:40:32 +02:00
Tom Brien d3831bae4e Add support for v3 Coinbase API (#116345)
* Add support for v3 Coinbase API

* Add deps

* Move tests
2024-08-10 18:40:28 +02:00
Franck Nijhof 86722ba05e 2024.8.0 (#123276) 2024-08-07 20:20:43 +02:00
Franck Nijhof be4810731a Bump version to 2024.8.0 2024-08-07 19:04:33 +02:00
Franck Nijhof ac6abb363c Bump version to 2024.8.0b9 2024-08-07 18:24:15 +02:00
Michael Hansen 5367886732 Bump intents to 2024.8.7 (#123295) 2024-08-07 18:24:08 +02:00
Stefan Agner 7a51d4ff62 Drop Matter Microwave Oven Mode select entity (#123294) 2024-08-07 18:24:05 +02:00
ashalita ef564c537d Revert "Upgrade pycoolmasternet-async to 0.2.0" (#123286) 2024-08-07 18:24:02 +02:00
Franck Nijhof 082290b092 Bump version to 2024.8.0b8 2024-08-07 13:15:23 +02:00
Franck Nijhof 4a212791a2 Update wled to 0.20.1 (#123283) 2024-08-07 13:15:12 +02:00
Brett Adams 6bb55ce79e Add missing application credential to Tesla Fleet (#123271)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
2024-08-07 13:15:04 +02:00
Franck Nijhof 782ff12e6e Bump version to 2024.8.0b7 2024-08-07 11:26:03 +02:00
lunmay af6f78a784 Fix typo on one of islamic_prayer_times calculation_method option (#123281) 2024-08-07 11:25:55 +02:00
Paulus Schoutsen db32460f3b Reload conversation entries on update (#123279) 2024-08-07 11:25:52 +02:00
Erwin Douna 270990fe39 Tado change repair issue (#123256) 2024-08-07 11:25:48 +02:00
Franck Nijhof a10fed9d72 Bump version to 2024.8.0b6 2024-08-07 10:22:39 +02:00
tronikos cc5699bf08 Fix Google Cloud TTS not respecting config values (#123275) 2024-08-07 10:22:30 +02:00
Jesse Hills ad674a1c2b Update ESPHome voice assistant pipeline log warning (#123269) 2024-08-07 10:22:27 +02:00
J. Nick Koston b0269faae4 Allow non-admins to subscribe to newer registry update events (#123267) 2024-08-07 10:22:24 +02:00
starkillerOG 1143efedc5 Bump reolink-aio to 0.9.7 (#123263) 2024-08-07 10:22:21 +02:00
Matthias Alphart 9e75b63925 Update knx-frontend to 2024.8.6.211307 (#123261) 2024-08-07 10:22:18 +02:00
puddly 940327dccf Bump ZHA to 0.0.28 (#123259)
* Bump ZHA to 0.0.28

* Drop redundant radio schema conversion
2024-08-07 10:22:14 +02:00
Steve Repsher 0270026f7c Adapt static resource handler to aiohttp 3.10 (#123166) 2024-08-07 10:22:11 +02:00
Franck Nijhof b636096ac3 Bump version to 2024.8.0b5 2024-08-06 18:08:19 +02:00
Franck Nijhof a243ed5b23 Update frontend to 20240806.1 (#123252) 2024-08-06 18:07:49 +02:00
Joost Lekkerkerker 3cf3780587 Bump mficlient to 0.5.0 (#123250) 2024-08-06 18:06:50 +02:00
Robert Resch 3d0a0cf376 Bump deebot-client to 8.3.0 (#123249) 2024-08-06 18:05:00 +02:00
J. Nick Koston 7aae9d9ad3 Fix sense doing blocking I/O in the event loop (#123247) 2024-08-06 18:04:57 +02:00
Franck Nijhof 870bb7efd4 Mark FFmpeg integration as system type (#123241) 2024-08-06 18:04:53 +02:00
Robert Resch 35a6679ae9 Delete mobile_app cloudhook if not logged into the cloud (#123234) 2024-08-06 18:04:49 +02:00
Yehazkel a09d0117b1 Fix Tami4 device name is None (#123156)
Co-authored-by: Robert Resch <robert@resch.dev>
2024-08-06 18:04:44 +02:00
Franck Nijhof e9fe98f7f9 Bump version to 2024.8.0b4 2024-08-06 13:22:46 +02:00
Franck Nijhof 5b2e188b52 Mark Google Assistant integration as system type (#123233) 2024-08-06 13:22:03 +02:00
Franck Nijhof c1953e938d Mark Alexa integration as system type (#123232) 2024-08-06 13:21:59 +02:00
Franck Nijhof 77bcbbcf53 Update frontend to 20240806.0 (#123230) 2024-08-06 12:51:24 +02:00
Joost Lekkerkerker 97587fae08 Bump yt-dlp to 2023.08.06 (#123229) 2024-08-06 12:51:21 +02:00
Matthias Alphart 01b54fe1a9 Update knx-frontend to 2024.8.6.85349 (#123226) 2024-08-06 12:51:17 +02:00
Clifford Roche f796950493 Update greeclimate to 2.1.0 (#123210) 2024-08-06 12:51:14 +02:00
flopp999 495fd946bc Fix growatt server tlx battery api key (#123191) 2024-08-06 12:51:10 +02:00
Jesse Hills 6af1e25d7e Show project version as sw_version in ESPHome (#123183) 2024-08-06 12:51:07 +02:00
Jesse Hills 6d47a4d7e4 Add support for ESPHome update entities to be checked on demand (#123161) 2024-08-06 12:51:04 +02:00
Petro31 fd5533d719 Fix yamaha legacy receivers (#122985) 2024-08-06 12:50:59 +02:00
Franck Nijhof d530137bec Bump version to 2024.8.0b3 2024-08-05 21:12:09 +02:00
Franck Nijhof 4f722e864c Mark webhook as a system integration type (#123204) 2024-08-05 21:11:46 +02:00
Franck Nijhof 62d38e786d Mark assist_pipeline as a system integration type (#123202) 2024-08-05 21:10:49 +02:00
Franck Nijhof 859874487e Mark tag to be an entity component (#123200) 2024-08-05 21:09:50 +02:00
Bram Kragten b16bf29819 Update frontend to 20240805.1 (#123196) 2024-08-05 21:09:46 +02:00
Marius 6b10dbb38c Fix state icon for closed valve entities (#123190) 2024-08-05 21:09:43 +02:00
Joost Lekkerkerker ea20c4b375 Fix MPD issue creation (#123187) 2024-08-05 21:09:40 +02:00
musapinar 0427aeccb0 Add Matter Leedarson RGBTW Bulb to the transition blocklist (#123182) 2024-08-05 21:09:37 +02:00
Matthias Alphart 4898ba932d Use KNX UI entity platform controller class (#123128) 2024-08-05 21:09:32 +02:00
Franck Nijhof 35a3d2306c Bump version to 2024.8.0b2 2024-08-05 12:22:03 +02:00
Calvin Walton cdb378066c Add Govee H612B to the Matter transition blocklist (#123163) 2024-08-05 12:21:40 +02:00
Brett Adams 85700fd80f Fix class attribute condition in Tesla Fleet (#123162) 2024-08-05 12:21:37 +02:00
J. Nick Koston 73a2ad7304 Bump aiohttp to 3.10.1 (#123159) 2024-08-05 12:21:34 +02:00
dupondje f6c4b6b045 dsmr: migrate hourly_gas_meter_reading to mbus device (#123149) 2024-08-05 12:21:30 +02:00
Steve Repsher 0b4d921762 Restore old service worker URL (#123131) 2024-08-05 12:21:27 +02:00
David F. Mulcahey c8a0e5228d Bump ZHA lib to 0.0.27 (#123125) 2024-08-05 12:21:23 +02:00
Kim de Vos 832bac8c63 Use slugify to create id for UniFi WAN latency (#123108)
Use slugify to create id for latency
2024-08-05 12:21:20 +02:00
Arie Catsman eccce7017f Bump pyenphase to 1.22.0 (#123103) 2024-08-05 12:21:16 +02:00
Louis Christ fdb1baadbe Fix wrong DeviceInfo in bluesound integration (#123101)
Fix bluesound device info
2024-08-05 12:21:12 +02:00
Shay Levy 7623ee49e4 Ignore Shelly IPv6 address in zeroconf (#123081) 2024-08-05 12:20:20 +02:00
Mr. Bubbles fa241dcd04 Catch exception in coordinator setup of IronOS integration (#123079) 2024-08-05 12:20:17 +02:00
Denis Shulyaka bee77041e8 Change enum type to string for Google Generative AI Conversation (#123069) 2024-08-05 12:20:13 +02:00
Paulus Schoutsen 50b7eb44d1 Add CONTROL supported feature to Google conversation when API access (#123046)
* Add CONTROL supported feature to Google conversation when API access

* Better function name

* Handle entry update inline

* Reload instead of update
2024-08-05 12:20:10 +02:00
Clifford Roche 7b1bf82e3c Update greeclimate to 2.0.0 (#121030)
Co-authored-by: Joostlek <joostlek@outlook.com>
2024-08-05 12:20:01 +02:00
Franck Nijhof fe82e7f24d Bump version to 2024.8.0b1 2024-08-02 17:46:01 +02:00
Bram Kragten 433c1a57e7 Update frontend to 20240802.0 (#123072) 2024-08-02 17:45:50 +02:00
Joost Lekkerkerker b36059fc64 Do not raise repair issue about missing integration in safe mode (#123066) 2024-08-02 17:45:47 +02:00
Philip Vanloo 13c9d69440 Add additional items to REPEAT_MAP in LinkPlay (#123063)
* Upgrade python-linkplay, add items to REPEAT_MAP

* Undo dependency bump
2024-08-02 17:45:43 +02:00
Philip Vanloo 9c7134a865 LinkPlay: Bump python-linkplay to 0.0.6 (#123062)
Bump python-linkplay to 0.0.6
2024-08-02 17:45:39 +02:00
Erik Montnemery d7cc2a7e9a Correct squeezebox service (#123060) 2024-08-02 17:45:36 +02:00
Fabian f9276e28b0 Add device class (#123059) 2024-08-02 17:45:32 +02:00
H. Árkosi Róbert 15ad6db1a7 Add LinkPlay models (#123056)
* Add some LinkPlay models

* Update utils.py

* Update utils.py

* Update utils.py

* Update homeassistant/components/linkplay/utils.py

* Update homeassistant/components/linkplay/utils.py

* Update utils.py

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-08-02 17:45:29 +02:00
Erik Montnemery c1043ada22 Correct type annotation for EntityPlatform.async_register_entity_service (#123054)
Correct type annotation for EntityPlatform.async_register_entity_service

Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
2024-08-02 17:45:26 +02:00
Paulus Schoutsen d141122008 Ollama implement CONTROL supported feature (#123049) 2024-08-02 17:45:23 +02:00
Paulus Schoutsen abeba39842 OpenAI make supported features reflect the config entry options (#123047) 2024-08-02 17:45:19 +02:00
Matthias Alphart bb597a908d Use freezer in KNX tests (#123044)
use freezer in tests
2024-08-02 17:45:16 +02:00
Matthias Alphart dcae2f35ce Mitigate breaking change for KNX climate schema (#123043) 2024-08-02 17:45:12 +02:00
Matthias Alphart b06a5af069 Address post-merge reviews for KNX integration (#123038) 2024-08-02 17:45:09 +02:00
J. Nick Koston a624ada8d6 Fix doorbird models are missing the schedule API (#123033)
* Fix doorbird models are missing the schedule API

fixes #122997

* cover
2024-08-02 17:45:06 +02:00
David F. Mulcahey d87366b1e7 Make ZHA load quirks earlier (#123027) 2024-08-02 17:45:03 +02:00
Michael Hansen 5ce8a2d974 Standardize assist pipelines on 10ms chunk size (#123024)
* Make chunk size always 10ms

* Fix voip
2024-08-02 17:44:59 +02:00
Robert Resch a42615add0 Fix and improve tedee lock states (#123022)
Improve tedee lock states
2024-08-02 17:44:56 +02:00
Matrix ecbff61332 Bump yolink api to 0.4.6 (#123012) 2024-08-02 17:44:53 +02:00
Paulus Schoutsen e9bfe82582 Make the Android timer notification high priority (#123006) 2024-08-02 17:44:50 +02:00
Ivan Belokobylskiy 55abe68a5f Bump aioymaps to 1.2.5 (#123005)
Bump aiomaps, fix sessionId parsing
2024-08-02 17:44:46 +02:00
amccook acf523b5fb Fix handling of directory type playlists in Plex (#122990)
Ignore type directory
2024-08-02 17:44:43 +02:00
Matrix 0216455137 Fix yolink protocol changed (#122989) 2024-08-02 17:44:40 +02:00
J. Nick Koston cb37ae6608 Update doorbird error notification to be a repair flow (#122987) 2024-08-02 17:44:37 +02:00
karwosts 3b462906d9 Restrict nws.get_forecasts_extra selector to nws weather entities (#122986) 2024-08-02 17:44:34 +02:00
Matrix dfb4e9c159 Yolink device model adaptation (#122824) 2024-08-02 17:44:31 +02:00
karwosts 6a6814af61 Use text/multiple selector for input_select.set_options (#122539) 2024-08-02 17:44:27 +02:00
Denis Shulyaka 1a7085b068 Add aliases to script llm tool description (#122380)
* Add aliases to script llm tool description

* Also add name
2024-08-02 17:44:24 +02:00
Christopher Fenner 804d7aa4c0 Fix translation key for power exchange sensor in ViCare (#122339) 2024-08-02 17:44:21 +02:00
DeerMaximum 1b1d86409c Velux use node id as fallback for unique id (#117508)
Co-authored-by: Robert Resch <robert@resch.dev>
2024-08-02 17:44:18 +02:00
Ryan Mattson 2520fcd284 Lyric: Properly tie room accessories to the data coordinator (#115902)
* properly tie lyric accessories to the data coordinator so sensors recieve updates

* only check for accessories for LCC devices

* revert: meant to give it its own branch and PR
2024-08-02 17:44:13 +02:00
200 changed files with 2987 additions and 1187 deletions
+6
View File
@@ -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"]
}
+84 -19
View File
@@ -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
+10 -1
View File
@@ -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"]
}
+41 -28
View File
@@ -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."]
}
+23 -14
View File
@@ -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)
+12 -4
View File
@@ -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": {
+34 -31
View File
@@ -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."
+4 -4
View File
@@ -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"
],
+9 -2
View File
@@ -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."""
+2
View File
@@ -18,3 +18,5 @@ FAN_MEDIUM_HIGH = "medium high"
MAX_ERRORS = 2
TARGET_TEMPERATURE_STEP = 1
UPDATE_INTERVAL = 60
+55 -10
View File
@@ -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."""
+1 -1
View File
@@ -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"]
}
+30 -3
View File
@@ -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."]
}
+22 -57
View File
@@ -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"]
}
+1 -1
View File
@@ -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)
+10 -8
View File
@@ -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)
+12 -6
View File
@@ -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:
+9 -8
View File
@@ -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
+12 -6
View File
@@ -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)
+10 -6
View File
@@ -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)
+9 -8
View File
@@ -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
+64 -10
View File
@@ -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."""
+31 -26
View File
@@ -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
+1 -1
View File
@@ -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
}
+11 -7
View File
@@ -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)
+10 -6
View File
@@ -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,
+4 -2
View File
@@ -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",
+9 -8
View File
@@ -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 = (
+5 -2
View File
@@ -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
+10 -6
View File
@@ -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]
+12 -9
View File
@@ -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
]
+42 -37
View File
@@ -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
+10 -6
View File
@@ -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
+12 -9
View File
@@ -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__,
)
+12 -6
View File
@@ -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
+18 -4
View File
@@ -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
)
+1 -2
View File
@@ -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)
+9 -7
View File
@@ -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"),
)
-13
View File
@@ -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
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -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))
+15 -9
View File
@@ -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"]
}
+10 -2
View File
@@ -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