Compare commits

...

127 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
d2cb3928e9 Homevolt select
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-27 10:46:46 +01:00
Maciej Bieniek
0fcfc3f070 Bump imgw_pib to 2.0.2 (#163940) 2026-02-24 19:15:41 +01:00
Przemko92
413506276c Add binary sensor for Compit (#161709) 2026-02-24 18:58:15 +01:00
Willem-Jan van Rootselaar
4a4e077d40 Add button platform for BSB-Lan integration (#160243) 2026-02-24 18:52:33 +01:00
Robin Lintermann
8f824b566e Add reauthentication flow to smarla (#163250) 2026-02-24 18:52:03 +01:00
Willem-Jan van Rootselaar
610aaa6eee Update BSB-LAN strings, error handling, and code cleanup (#163480) 2026-02-24 18:09:32 +01:00
Martin Arndt
ecb7ab238c Allow worxlandroid PIN to contain letters (#163266) 2026-02-24 18:07:15 +01:00
Simone Chemelli
9013b7835e Resolve pylance complaints for Fritz (#163313) 2026-02-24 18:06:19 +01:00
Erwin Douna
5363638c7e OAuth helper enhance response text logger (#163371)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-02-24 16:50:40 +01:00
Andreas Jakl
164b1cbb8c Add reconfiguration flow to NRGkick (#163828) 2026-02-24 16:46:23 +01:00
Mattias Michaux
b5a55ec032 Fix Sonos browse album art lookup for multi-segment A:ALBUM IDs (#163786) 2026-02-24 16:45:27 +01:00
Karl Beecken
0c6d635e83 Teltonika quality scale: mark unavailable rules done (#163705) 2026-02-24 16:43:48 +01:00
Christian Lackas
9259db0b85 Centralize ViCare error handling in base entity class (#162619) 2026-02-24 16:43:16 +01:00
Denis Shulyaka
6f1a021197 Add IQS to Anthropic (#163891) 2026-02-24 16:27:51 +01:00
Christian Lackas
8dbf7f7ad7 Add diagnostics support to homematicip_cloud (#163829) 2026-02-24 16:25:04 +01:00
Jamie Magee
3854c8e261 Econet friedrich support (#163904)
Co-authored-by: w1ll1am23 <6432770+w1ll1am23@users.noreply.github.com>
2026-02-24 16:20:35 +01:00
On Freund
7adfb0a40b Add bus support to MTA integration (#163220)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-24 16:11:13 +01:00
Zoltán Farkasdi
b4705e4a45 Fix flaky netatmo test (#163941) 2026-02-24 16:02:00 +01:00
Tom
a0176d18cf Add DHCP ip_addresses update to airOS (#163936) 2026-02-24 15:36:52 +01:00
Kevin Stillhammer
5543107f6c Allow to disable seconds in DurationSelector (#163803) 2026-02-24 15:11:26 +01:00
Klaas Schoute
6dc8840932 Rename Powerfox integration to Powerfox Cloud (#163723) 2026-02-24 14:42:43 +01:00
Stefan Agner
76902aa7fa Avoid adding Content-Type to non-body responses (#163885) 2026-02-24 14:31:04 +01:00
Erwin Douna
07b9877f64 Add button platform to Proxmox (#163791)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-24 14:24:20 +01:00
Erik Montnemery
40e2f79e60 Add support for reading backups using securetar v3 (#163920) 2026-02-24 14:23:00 +01:00
Christopher Fenner
aa707fcf41 Add gateway discovery via USB for EnOcean integration (#162756)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-24 11:58:01 +01:00
Willem-Jan van Rootselaar
4b53bc243d Add energy sensor to bsblan (#163879) 2026-02-24 11:56:27 +01:00
Robert Resch
220e94d029 Fix nightlies by reverting the builder to a version instead of a sha (#163935) 2026-02-24 11:48:19 +01:00
Erik Montnemery
b1f943ccda Replace discovery with user flow in Philips Hue BLE (#163924) 2026-02-24 11:06:31 +01:00
Brett Adams
e37d84049a Update Splunk integration to bronze quality scale (#163616)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-24 10:56:05 +01:00
Marc Mueller
209473e376 Remove myself as codeowner for fritzbox_callmonitor (#163927) 2026-02-24 10:45:58 +01:00
MoonDevLT
334c3af448 Bump lunatone-rest-api-client to 0.7.0 (#163594) 2026-02-24 10:10:04 +01:00
hanwg
5560139d24 Clean up duplicated code in Telegram bot (#163917) 2026-02-24 10:04:21 +01:00
Erik Montnemery
d4dec5d1d3 Improve backup_restore tests (#163921) 2026-02-24 10:03:42 +01:00
J. Nick Koston
6cb63a60bc Skip unknown entity types in ESPHome integration (#163887) 2026-02-24 08:48:27 +01:00
Franck Nijhof
991301e79e Merge branch 'master' into dev 2026-02-24 07:07:39 +00:00
andreimoraru
06e2b4633a Bump yt-dlp to 2026.2.21 (#163916) 2026-02-24 07:30:54 +01:00
Manu
048d8d217c Update strings in ntfy integration (#163912) 2026-02-24 06:24:18 +01:00
Kyle Johnson
3693bc5878 Make Google Assistant fan speed percent and step speeds mutually exclusive (#162770) 2026-02-23 22:26:09 +00:00
Franck Nijhof
9c640fe0fa 2026.2.3 (#163683) 2026-02-20 21:43:32 +01:00
Sid
62145e5f9e Bump eheimdigital to 1.6.0 (#161961) 2026-02-20 19:51:10 +00:00
Franck Nijhof
c0fc414bb9 Fix nrgkick tests for rc 2026-02-20 19:49:27 +00:00
Franck Nijhof
69411a05ff Bump version to 2026.2.3 2026-02-20 19:39:05 +00:00
Marc Mueller
06c9ec861d Fix hassfest requirements check (#163681) 2026-02-20 19:38:58 +00:00
Joost Lekkerkerker
946df1755f Bump pySmartThings to 3.5.3 (#163375)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-20 19:38:56 +00:00
Thomas Sejr Madsen
d0678e0641 Fix touchline_sl zone availability when alarm state is set (#163338) 2026-02-20 19:38:55 +00:00
Allen Porter
ec56f183da Bump pyrainbird to 6.0.5 (#163333) 2026-02-20 19:38:53 +00:00
Åke Strandberg
033005e0de Add Miele dishwasher program code (#163308) 2026-02-20 19:38:52 +00:00
Andreas Jakl
91f9f5a826 NRGkick: do not update vehicle connected timestamp when vehicle is not connected (#163292) 2026-02-20 19:38:51 +00:00
David Recordon
ac4fcab827 Fix Control4 HVAC action mapping for multi-stage and idle states (#163222) 2026-02-20 19:38:49 +00:00
Allen Porter
d0eea77178 Fix remote calendar event handling of events within the same update period (#163186)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 19:38:48 +00:00
Markus Adrario
fb38fa3844 Add Lux to homee units (#163180) 2026-02-20 19:38:47 +00:00
Allen Porter
440efb953e Bump ical to 13.2.0 (#163123) 2026-02-20 19:38:45 +00:00
Manu
7ce47cca0d Fix blocking call in Xbox config flow (#163122) 2026-02-20 19:38:44 +00:00
Andre Lengwenus
a5f607bb91 Bump pypck to 0.9.11 (#163043) 2026-02-20 19:38:42 +00:00
Andre Lengwenus
b03043aa6f Bump pypck to 0.9.10 (#162333) 2026-02-20 19:38:41 +00:00
Robert Resch
0f3c7ca277 Block redirect to localhost (#162941) 2026-02-20 19:37:03 +00:00
Martin Hjelmare
3abf7c22f3 Fix Z-Wave climate set preset (#162728) 2026-02-20 19:37:01 +00:00
hbludworth
292e1de126 Show progress indicator during backup stage of Core/App update (#162683) 2026-02-20 19:37:00 +00:00
Christian Lackas
2d776a8193 Fix HomematicIP entity recovery after access point cloud reconnect (#162575) 2026-02-20 19:36:58 +00:00
Sid
039bbbb48c Fix dynamic entity creation in eheimdigital (#161155) 2026-02-20 19:36:56 +00:00
Luke Lashley
ad5565df95 Add the ability to select region for Roborock (#160898) 2026-02-20 19:36:55 +00:00
Franck Nijhof
3e6bc29a6a 2026.2.2 (#162950) 2026-02-13 21:05:06 +01:00
Franck Nijhof
ec8067a5a8 Bump version to 2026.2.2 2026-02-13 19:25:16 +00:00
Josef Zweck
6f47716d0a Log remaining token duration in onedrive (#162933) 2026-02-13 19:24:25 +00:00
puddly
efba5c6bcc Bump ZHA to 0.0.90 (#162894) 2026-02-13 19:24:24 +00:00
Sammy [Andrei Marinache]
d10e78079f Add Miele TQ1000WP tumble dryer programs and program phases (#162871)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Åke Strandberg <ake@strandberg.eu>
2026-02-13 19:24:23 +00:00
Jon Seager
6d4581580f Bump pytouchlinesl to 0.6.0 (#162856) 2026-02-13 19:24:21 +00:00
Yoshi Walsh
0d9a41a540 Bump pydaikin to 2.17.2 (#162846) 2026-02-13 19:24:20 +00:00
Vicx
cd69e6db73 Bump slixmpp to 1.13.2 (#162837) 2026-02-13 19:24:19 +00:00
Xitee
1320367d0d Filter out transient zero values from qBittorrent alltime stats (#162821)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:24:18 +00:00
Joost Lekkerkerker
dfa4698887 Bump pySmartThings to 3.5.2 (#162809)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-13 19:24:17 +00:00
Robert Resch
b426115de7 Bump cryptography to 46.0.5 (#162783) 2026-02-13 19:24:15 +00:00
hanwg
fb79fa37f8 Fix bug in edit_message_media action for Telegram bot (#162762) 2026-02-13 19:24:14 +00:00
Simone Chemelli
6a5f7bf424 Fix image platform state for Vodafone Station (#162747)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-13 19:24:13 +00:00
Simone Chemelli
142ca6dec1 Fix alarm refresh warning for Comelit SimpleHome (#162710) 2026-02-13 19:24:12 +00:00
epenet
0f986c24d0 Fix unavailable status in Tuya (#162709) 2026-02-13 19:24:11 +00:00
Josef Zweck
01f2b7b6f6 Bump onedrive-personal-sdk to 0.1.2 (#162689) 2026-02-13 19:24:09 +00:00
Michael
b9469027f5 Fix handling when FRITZ!Box reboots in FRITZ!Box Tools (#162679) 2026-02-13 19:24:08 +00:00
Tomás Correia
fbb94af748 fix to cloudflare r2 setup screen info (#162677) 2026-02-13 19:24:07 +00:00
Michael
148bdf6e3a Fix handling when FRITZ!Box reboots in FRITZ!Smarthome (#162676) 2026-02-13 19:24:05 +00:00
starkillerOG
91999f8871 Bump reolink-aio to 0.19.0 (#162672) 2026-02-13 19:24:04 +00:00
Jeef
aecca4eb99 Bump intellifire4py to 4.3.1 (#162659) 2026-02-13 19:24:03 +00:00
Allen Porter
bf8aa49bae Improve MCP SSE fallback error handling (#162655) 2026-02-13 19:24:02 +00:00
Joost Lekkerkerker
4423425683 Pin setuptools to 81.0.0 (#162589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 19:24:01 +00:00
Aaron Godfrey
44202da53d Increase max tasks retrieved per page to prevent timeout (#162587) 2026-02-13 19:23:59 +00:00
Thomas55555
9f7dfb72c4 Bump aioautomower to 2.7.3 (#162583)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-13 19:23:58 +00:00
Michael
de07a69e4f Bump aioimmich to 0.12.0 (#162573)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-13 19:23:57 +00:00
Maikel Punie
bbf4c38115 migrate velbus config entries (#162565) 2026-02-13 19:23:56 +00:00
ElCruncharino
e1bb5d52ef Add timeout to B2 metadata downloads to prevent backup hang (#162562) 2026-02-13 19:23:54 +00:00
hanwg
eb64b6bdee Fix config flow bug for Telegram bot (#162555) 2026-02-13 19:23:53 +00:00
Andrea Turri
ecb288b735 Add new Miele mappings (#162544) 2026-02-13 19:23:52 +00:00
Norbert Rittel
a419c9c420 Sentence-case "speech-to-text" in google_cloud (#162534) 2026-02-13 19:23:51 +00:00
Brett Adams
dd29133324 Fix Tesla Fleet partner registration to use all regions (#162525)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:23:50 +00:00
Allen Porter
90f22ea516 Bump grpc to 1.78.0 (#162520) 2026-02-13 19:23:48 +00:00
Peter Grauvogel
9db1428265 Fix Green Planet Energy price unit conversion (#162511) 2026-02-13 19:23:47 +00:00
Denis Shulyaka
a696b05b0d Fix JSON serialization of time objects in Cloud conversation tool results (#162506) 2026-02-13 19:23:46 +00:00
Denis Shulyaka
77ddb63b73 Fix JSON serialization of time objects in Open Router tool results (#162505) 2026-02-13 19:23:44 +00:00
Denis Shulyaka
4180a6e176 Fix JSON serialization of time objects in Ollama tool results (#162502)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 19:23:43 +00:00
Denis Shulyaka
6d74c912d2 Fix JSON serialization of datetime objects in Google Generative AI tool results (#162495) 2026-02-13 19:23:42 +00:00
Denis Shulyaka
8a01dfcc00 Fix JSON serialization of time objects in OpenAI tool results (#162490) 2026-02-13 19:23:40 +00:00
Brett Adams
9722898dc6 Fix device_class of backup reserve sensor in Tessie (#162459) 2026-02-13 19:23:39 +00:00
Brett Adams
7438c71fcb Fix device_class of backup reserve sensor in teslemetry (#162458) 2026-02-13 19:23:38 +00:00
Christian Lackas
0b5e55b923 Fix absolute humidity sensor on HmIP-WGT glass thermostats (#162455) 2026-02-13 19:23:37 +00:00
ElCruncharino
61ed959e8e Fix AsyncIteratorReader blocking after stream exhaustion (#161731) 2026-02-13 19:17:20 +00:00
Jaap Pieroen
3989532465 Bump essent-dynamic-pricing to 0.3.1 (#160958)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-13 19:17:18 +00:00
Franck Nijhof
28027ddca4 2026.2.1 (#162450) 2026-02-06 22:44:07 +01:00
Franck Nijhof
fe0d7b3cca Bump version to 2026.2.1 2026-02-06 20:49:26 +00:00
jameson_uk
0dcc4e9527 dep: bump aioamazondevices to 11.1.3 (#162437) 2026-02-06 20:47:38 +00:00
Artur Pragacz
b13b189703 Make bad entity ID detection more lenient (#162425) 2026-02-06 20:47:37 +00:00
epenet
150829f599 Fix invalid yardian snaphots (#162422) 2026-02-06 20:47:36 +00:00
Joost Lekkerkerker
57dd9d9c23 Remove double unit of measurement for yardian (#162412) 2026-02-06 20:47:34 +00:00
Sab44
e2056cb12c Bump librehardwaremonitor-api to version 1.9.1 (#162409) 2026-02-06 20:47:33 +00:00
Joost Lekkerkerker
fa2c8992cf Remove entity id overwrite for ambient station (#162403) 2026-02-06 20:47:32 +00:00
Matt Zimmerman
ddf5c7fe3a Add missing config flow strings to SmartTub (#162375)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 20:47:31 +00:00
Matt Zimmerman
7034ed6d3f Bump python-smarttub to 0.0.47 (#162367)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 20:47:29 +00:00
Aaron Godfrey
9015b53c1b Fix conversion of data for todo.* actions (#162366) 2026-02-06 20:47:28 +00:00
Jordan Harvey
1cfa6561f7 Update pynintendoparental requirement to version 2.3.2.1 (#162362) 2026-02-06 20:47:27 +00:00
Shay Levy
eead02dcca Fix Shelly Linkedgo Thermostat status update (#162339) 2026-02-06 20:47:26 +00:00
Arie Catsman
456e51a221 Bump pyenphase to 2.4.5 (#162324) 2026-02-06 20:47:25 +00:00
Luo Chen
5d984ce186 Fix unicode escaping in MCP server tool response (#162319)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-06 20:47:24 +00:00
Oliver
61f45489ac Add mapping for stopped state to denonavr media player (#162283) 2026-02-06 20:47:23 +00:00
Tomás Correia
f72c643b38 Fix multipart upload to use consistent part sizes for R2/S3 (#162278) 2026-02-06 20:47:22 +00:00
Oliver
27bc26e886 Bump denonavr to 1.3.2 (#162271) 2026-02-06 20:47:20 +00:00
Thomas55555
0e9f03cbc1 Bump google_air_quality_api to 3.0.1 (#162233) 2026-02-06 20:47:19 +00:00
David Bonnes
9480c33fb0 Bump evohome-async to 1.1.3 (#162232) 2026-02-06 20:47:18 +00:00
Jonathan
3e6b8663e8 Fix device_class of backup reserve sensor (#161178) 2026-02-06 20:47:17 +00:00
epenet
1c69a83793 Fix redundant off preset in Tuya climate (#161040) 2026-02-06 20:47:16 +00:00
153 changed files with 6770 additions and 978 deletions

View File

@@ -321,7 +321,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@21bc64d76dad7a5184c67826aab41c6b6f89023a # 2025.11.0
uses: home-assistant/builder@2025.11.0 # zizmor: ignore[unpinned-uses]
with:
args: |
$BUILD_ARGS \

2
CODEOWNERS generated
View File

@@ -555,8 +555,6 @@ build.json @home-assistant/supervisor
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
/tests/components/fritzbox/ @mib1185 @flabbamann
/homeassistant/components/fritzbox_callmonitor/ @cdce8p
/tests/components/fritzbox_callmonitor/ @cdce8p
/homeassistant/components/fronius/ @farmio
/tests/components/fronius/ @farmio
/homeassistant/components/frontend/ @home-assistant/frontend

View File

@@ -34,11 +34,13 @@ from homeassistant.const import (
)
from homeassistant.data_entry_flow import section
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
DEFAULT_SSL,
@@ -392,6 +394,18 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
except asyncio.CancelledError:
pass
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Automatically handle a DHCP discovered IP change."""
ip_address = discovery_info.ip
# python-airos defaults to upper for derived mac_address
normalized_mac = format_mac(discovery_info.macaddress).upper()
await self.async_set_unique_id(normalized_mac)
self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address})
return self.async_abort(reason="unreachable")
async def async_step_discovery_no_devices(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@@ -3,6 +3,7 @@
"name": "Ubiquiti airOS",
"codeowners": ["@CoMPaTech"],
"config_flow": true,
"dhcp": [{ "registered_devices": true }],
"documentation": "https://www.home-assistant.io/integrations/airos",
"integration_type": "device",
"iot_class": "local_polling",

View File

@@ -8,5 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["anthropic==0.83.0"]
}

View File

@@ -16,6 +16,7 @@ from typing import IO, Any, cast
import aiohttp
from securetar import (
InvalidPasswordError,
SecureTarArchive,
SecureTarError,
SecureTarFile,
@@ -165,7 +166,7 @@ def validate_password(path: Path, password: str | None) -> bool:
):
# If we can read the tar file, the password is correct
return True
except tarfile.ReadError, SecureTarReadError:
except tarfile.ReadError, InvalidPasswordError, SecureTarReadError:
LOGGER.debug("Invalid password")
return False
except Exception: # noqa: BLE001
@@ -192,13 +193,14 @@ def validate_password_stream(
for obj in input_archive.tar:
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
continue
with input_archive.extract_tar(obj) as decrypted:
if decrypted.plaintext_size is None:
raise UnsupportedSecureTarVersion
try:
try:
with input_archive.extract_tar(obj) as decrypted:
if decrypted.plaintext_size is None:
raise UnsupportedSecureTarVersion
decrypted.read(1) # Read a single byte to trigger the decryption
except SecureTarReadError as err:
raise IncorrectPassword from err
except (InvalidPasswordError, SecureTarReadError) as err:
raise IncorrectPassword from err
else:
return
raise BackupEmpty

View File

@@ -1,4 +1,4 @@
"""The BSB-Lan integration."""
"""The BSB-LAN integration."""
import asyncio
import dataclasses
@@ -36,7 +36,7 @@ from .const import CONF_PASSKEY, DOMAIN
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -56,13 +56,13 @@ class BSBLanData:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the BSB-Lan integration."""
"""Set up the BSB-LAN integration."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
"""Set up BSB-Lan from a config entry."""
"""Set up BSB-LAN from a config entry."""
# create config using BSBLANConfig
config = BSBLANConfig(

View File

@@ -0,0 +1,59 @@
"""Button platform for BSB-Lan integration."""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BSBLanConfigEntry, BSBLanData
from .coordinator import BSBLanFastCoordinator
from .entity import BSBLanEntity
from .helpers import async_sync_device_time
PARALLEL_UPDATES = 1
BUTTON_DESCRIPTIONS: tuple[ButtonEntityDescription, ...] = (
ButtonEntityDescription(
key="sync_time",
translation_key="sync_time",
entity_category=EntityCategory.CONFIG,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: BSBLanConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BSB-Lan button entities from a config entry."""
data = entry.runtime_data
async_add_entities(
BSBLanButtonEntity(data.fast_coordinator, data, description)
for description in BUTTON_DESCRIPTIONS
)
class BSBLanButtonEntity(BSBLanEntity, ButtonEntity):
"""Defines a BSB-Lan button entity."""
entity_description: ButtonEntityDescription
def __init__(
self,
coordinator: BSBLanFastCoordinator,
data: BSBLanData,
description: ButtonEntityDescription,
) -> None:
"""Initialize BSB-Lan button entity."""
super().__init__(coordinator, data)
self.entity_description = description
self._attr_unique_id = f"{data.device.MAC}-{description.key}"
self._data = data
async def async_press(self) -> None:
"""Handle the button press."""
await async_sync_device_time(self._data.client, self._data.device.name)

View File

@@ -39,15 +39,15 @@ PRESET_MODES = [
PRESET_NONE,
]
# Mapping from Home Assistant HVACMode to BSB-Lan integer values
# BSB-Lan uses: 0=off, 1=auto, 2=eco/reduced, 3=heat/comfort
# Mapping from Home Assistant HVACMode to BSB-LAN integer values
# BSB-LAN uses: 0=off, 1=auto, 2=eco/reduced, 3=heat/comfort
HA_TO_BSBLAN_HVAC_MODE: Final[dict[HVACMode, int]] = {
HVACMode.OFF: 0,
HVACMode.AUTO: 1,
HVACMode.HEAT: 3,
}
# Mapping from BSB-Lan integer values to Home Assistant HVACMode
# Mapping from BSB-LAN integer values to Home Assistant HVACMode
BSBLAN_TO_HA_HVAC_MODE: Final[dict[int, HVACMode]] = {
0: HVACMode.OFF,
1: HVACMode.AUTO,
@@ -69,7 +69,6 @@ async def async_setup_entry(
class BSBLANClimate(BSBLanEntity, ClimateEntity):
"""Defines a BSBLAN climate device."""
_attr_has_entity_name = True
_attr_name = None
# Determine preset modes
_attr_supported_features = (
@@ -138,7 +137,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
# BSB-Lan mode 2 is eco/reduced mode
# BSB-LAN mode 2 is eco/reduced mode
if self._hvac_mode_value == 2:
return PRESET_ECO
return PRESET_NONE
@@ -163,7 +162,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
if ATTR_HVAC_MODE in kwargs:
data[ATTR_HVAC_MODE] = HA_TO_BSBLAN_HVAC_MODE[kwargs[ATTR_HVAC_MODE]]
if ATTR_PRESET_MODE in kwargs:
# eco preset uses BSB-Lan mode 2, none preset uses mode 1 (auto)
# eco preset uses BSB-LAN mode 2, none preset uses mode 1 (auto)
if kwargs[ATTR_PRESET_MODE] == PRESET_ECO:
data[ATTR_HVAC_MODE] = 2
elif kwargs[ATTR_PRESET_MODE] == PRESET_NONE:

View File

@@ -1,4 +1,4 @@
"""Config flow for BSB-Lan integration."""
"""Config flow for BSB-LAN integration."""
from __future__ import annotations

View File

@@ -1,4 +1,4 @@
"""Constants for the BSB-Lan integration."""
"""Constants for the BSB-LAN integration."""
from __future__ import annotations

View File

@@ -1,4 +1,4 @@
"""DataUpdateCoordinator for the BSB-Lan integration."""
"""DataUpdateCoordinator for the BSB-LAN integration."""
from __future__ import annotations
@@ -29,8 +29,13 @@ if TYPE_CHECKING:
# Filter lists for optimized API calls - only fetch parameters we actually use
# This significantly reduces response time (~0.2s per parameter saved)
STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"]
SENSOR_INCLUDE = ["current_temperature", "outside_temperature"]
STATE_INCLUDE = [
"current_temperature",
"target_temperature",
"hvac_mode",
"hvac_action",
]
SENSOR_INCLUDE = ["current_temperature", "outside_temperature", "total_energy"]
DHW_STATE_INCLUDE = [
"operating_mode",
"nominal_setpoint",
@@ -57,7 +62,7 @@ class BSBLanSlowData:
class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
"""Base BSB-Lan coordinator."""
"""Base BSB-LAN coordinator."""
config_entry: BSBLanConfigEntry
@@ -69,7 +74,7 @@ class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
name: str,
update_interval: timedelta,
) -> None:
"""Initialize the BSB-Lan coordinator."""
"""Initialize the BSB-LAN coordinator."""
super().__init__(
hass,
logger=LOGGER,
@@ -81,7 +86,7 @@ class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
"""The BSB-Lan fast update coordinator for frequently changing data."""
"""The BSB-LAN fast update coordinator for frequently changing data."""
def __init__(
self,
@@ -89,7 +94,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
config_entry: BSBLanConfigEntry,
client: BSBLAN,
) -> None:
"""Initialize the BSB-Lan fast coordinator."""
"""Initialize the BSB-LAN fast coordinator."""
super().__init__(
hass,
config_entry,
@@ -99,7 +104,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
)
async def _async_update_data(self) -> BSBLanFastData:
"""Fetch fast-changing data from the BSB-Lan device."""
"""Fetch fast-changing data from the BSB-LAN device."""
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use
@@ -110,12 +115,15 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
"Authentication failed for BSB-Lan device"
translation_domain=DOMAIN,
translation_key="coordinator_auth_error",
) from err
except BSBLANConnectionError as err:
host = self.config_entry.data[CONF_HOST]
raise UpdateFailed(
f"Error while establishing connection with BSB-Lan device at {host}"
translation_domain=DOMAIN,
translation_key="coordinator_connection_error",
translation_placeholders={"host": host},
) from err
return BSBLanFastData(
@@ -126,7 +134,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
"""The BSB-Lan slow update coordinator for infrequently changing data."""
"""The BSB-LAN slow update coordinator for infrequently changing data."""
def __init__(
self,
@@ -134,7 +142,7 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
config_entry: BSBLanConfigEntry,
client: BSBLAN,
) -> None:
"""Initialize the BSB-Lan slow coordinator."""
"""Initialize the BSB-LAN slow coordinator."""
super().__init__(
hass,
config_entry,
@@ -144,7 +152,7 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
)
async def _async_update_data(self) -> BSBLanSlowData:
"""Fetch slow-changing data from the BSB-Lan device."""
"""Fetch slow-changing data from the BSB-LAN device."""
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use

View File

@@ -32,6 +32,15 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
model=(
data.info.device_identification.value
if data.info.device_identification
and data.info.device_identification.value
else None
),
model_id=(
f"{data.info.controller_family.value}_{data.info.controller_variant.value}"
if data.info.controller_family
and data.info.controller_variant
and data.info.controller_family.value
and data.info.controller_variant.value
else None
),
sw_version=data.device.version,

View File

@@ -0,0 +1,42 @@
"""Helper functions for BSB-Lan integration."""
from __future__ import annotations
from bsblan import BSBLAN, BSBLANError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from .const import DOMAIN
async def async_sync_device_time(client: BSBLAN, device_name: str) -> None:
"""Synchronize BSB-LAN device time with Home Assistant.
Only updates if device time differs from Home Assistant time.
Args:
client: The BSB-LAN client instance.
device_name: The name of the device (used in error messages).
Raises:
HomeAssistantError: If the time sync operation fails.
"""
try:
device_time = await client.time()
current_time = dt_util.now()
current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S")
# Only sync if device time differs from HA time
if device_time.time.value != current_time_str:
await client.set_time(current_time_str)
except BSBLANError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="sync_time_failed",
translation_placeholders={
"device_name": device_name,
"error": str(err),
},
) from err

View File

@@ -1,4 +1,11 @@
{
"entity": {
"button": {
"sync_time": {
"default": "mdi:timer-sync-outline"
}
}
},
"services": {
"set_hot_water_schedule": {
"service": "mdi:calendar-clock"

View File

@@ -1,6 +1,6 @@
{
"domain": "bsblan",
"name": "BSB-Lan",
"name": "BSB-LAN",
"codeowners": ["@liudger"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bsblan",

View File

@@ -1,4 +1,4 @@
"""Support for BSB-Lan sensors."""
"""Support for BSB-LAN sensors."""
from __future__ import annotations
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.const import UnitOfEnergy, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -25,7 +25,7 @@ PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class BSBLanSensorEntityDescription(SensorEntityDescription):
"""Describes BSB-Lan sensor entity."""
"""Describes BSB-LAN sensor entity."""
value_fn: Callable[[BSBLanFastData], StateType]
exists_fn: Callable[[BSBLanFastData], bool] = lambda data: True
@@ -58,6 +58,19 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
),
exists_fn=lambda data: data.sensor.outside_temperature is not None,
),
BSBLanSensorEntityDescription(
key="total_energy",
translation_key="total_energy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda data: (
data.sensor.total_energy.value
if data.sensor.total_energy is not None
else None
),
exists_fn=lambda data: data.sensor.total_energy is not None,
),
)
@@ -66,7 +79,7 @@ async def async_setup_entry(
entry: BSBLanConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BSB-Lan sensor based on a config entry."""
"""Set up BSB-LAN sensor based on a config entry."""
data = entry.runtime_data
# Only create sensors for available data points
@@ -81,7 +94,7 @@ async def async_setup_entry(
class BSBLanSensor(BSBLanEntity, SensorEntity):
"""Defines a BSB-Lan sensor."""
"""Defines a BSB-LAN sensor."""
entity_description: BSBLanSensorEntityDescription
@@ -90,7 +103,7 @@ class BSBLanSensor(BSBLanEntity, SensorEntity):
data: BSBLanData,
description: BSBLanSensorEntityDescription,
) -> None:
"""Initialize BSB-Lan sensor."""
"""Initialize BSB-LAN sensor."""
super().__init__(data.fast_coordinator, data)
self.entity_description = description
self._attr_unique_id = f"{data.device.MAC}-{description.key}"

View File

@@ -1,4 +1,4 @@
"""Support for BSB-Lan services."""
"""Support for BSB-LAN services."""
from __future__ import annotations
@@ -13,9 +13,9 @@ from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .helpers import async_sync_device_time
if TYPE_CHECKING:
from . import BSBLanConfigEntry
@@ -192,7 +192,7 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None:
)
try:
# Call the BSB-Lan API to set the schedule
# Call the BSB-LAN API to set the schedule
await client.set_hot_water_schedule(dhw_schedule)
except BSBLANError as err:
raise HomeAssistantError(
@@ -245,25 +245,7 @@ async def async_sync_time(service_call: ServiceCall) -> None:
)
client = entry.runtime_data.client
try:
# Get current device time
device_time = await client.time()
current_time = dt_util.now()
current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S")
# Only sync if device time differs from HA time
if device_time.time.value != current_time_str:
await client.set_time(current_time_str)
except BSBLANError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="sync_time_failed",
translation_placeholders={
"device_name": device_entry.name or device_id,
"error": str(err),
},
) from err
await async_sync_device_time(client, device_entry.name or device_id)
SYNC_TIME_SCHEMA = vol.Schema(
@@ -275,7 +257,7 @@ SYNC_TIME_SCHEMA = vol.Schema(
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Register the BSB-Lan services."""
"""Register the BSB-LAN services."""
hass.services.async_register(
DOMAIN,
SERVICE_SET_HOT_WATER_SCHEDULE,

View File

@@ -22,8 +22,8 @@
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
},
"description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.",
"title": "BSB-Lan device discovered"
"description": "A BSB-LAN device was discovered at {host}. Please provide credentials if required.",
"title": "BSB-LAN device discovered"
},
"reauth_confirm": {
"data": {
@@ -36,7 +36,7 @@
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
},
"description": "The BSB-Lan integration needs to re-authenticate with {name}",
"description": "The BSB-LAN integration needs to re-authenticate with {name}",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
@@ -48,24 +48,32 @@
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of your BSB-Lan device.",
"passkey": "The passkey for your BSB-Lan device.",
"password": "The password for your BSB-Lan device.",
"port": "The port number of your BSB-Lan device.",
"username": "The username for your BSB-Lan device."
"host": "The hostname or IP address of your BSB-LAN device.",
"passkey": "The passkey for your BSB-LAN device.",
"password": "The password for your BSB-LAN device.",
"port": "The port number of your BSB-LAN device.",
"username": "The username for your BSB-LAN device."
},
"description": "Set up your BSB-Lan device to integrate with Home Assistant.",
"title": "Connect to the BSB-Lan device"
"description": "Set up your BSB-LAN device to integrate with Home Assistant.",
"title": "Connect to the BSB-LAN device"
}
}
},
"entity": {
"button": {
"sync_time": {
"name": "Sync time"
}
},
"sensor": {
"current_temperature": {
"name": "Current temperature"
},
"outside_temperature": {
"name": "Outside temperature"
},
"total_energy": {
"name": "Total energy"
}
}
},
@@ -73,6 +81,12 @@
"config_entry_not_loaded": {
"message": "The device `{device_name}` is not currently loaded or available"
},
"coordinator_auth_error": {
"message": "Authentication failed for BSB-LAN device"
},
"coordinator_connection_error": {
"message": "Error while establishing connection with BSB-LAN device at {host}"
},
"end_time_before_start_time": {
"message": "End time ({end_time}) must be after start time ({start_time})"
},
@@ -83,14 +97,11 @@
"message": "No configuration entry found for device: {device_id}"
},
"set_data_error": {
"message": "An error occurred while sending the data to the BSB-Lan device"
"message": "An error occurred while sending the data to the BSB-LAN device"
},
"set_operation_mode_error": {
"message": "An error occurred while setting the operation mode"
},
"set_preset_mode_error": {
"message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto"
},
"set_schedule_failed": {
"message": "Failed to set hot water schedule: {error}"
},
@@ -101,7 +112,7 @@
"message": "Authentication failed while retrieving static device data"
},
"setup_connection_error": {
"message": "Failed to retrieve static device data from BSB-Lan device at {host}"
"message": "Failed to retrieve static device data from BSB-LAN device at {host}"
},
"setup_general_error": {
"message": "An unknown error occurred while retrieving static device data"
@@ -150,7 +161,7 @@
"name": "Set hot water schedule"
},
"sync_time": {
"description": "Synchronize Home Assistant time to the BSB-Lan device. Only updates if device time differs from Home Assistant time.",
"description": "Synchronize Home Assistant time to the BSB-LAN device. Only updates if device time differs from Home Assistant time.",
"fields": {
"device_id": {
"description": "The BSB-LAN device to sync time for.",

View File

@@ -63,6 +63,7 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
"""Defines a BSBLAN water heater entity."""
_attr_name = None
_attr_operation_list = list(HA_TO_BSBLAN_OPERATION_MODE.keys())
_attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
@@ -73,7 +74,6 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
"""Initialize BSBLAN water heater."""
super().__init__(data.fast_coordinator, data.slow_coordinator, data)
self._attr_unique_id = format_mac(data.device.MAC)
self._attr_operation_list = list(HA_TO_BSBLAN_OPERATION_MODE.keys())
# Set temperature unit
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit

View File

@@ -10,6 +10,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SELECT,

View File

@@ -0,0 +1,189 @@
"""Binary sensor platform for Compit integration."""
from dataclasses import dataclass
from compit_inext_api.consts import CompitParameter
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER_NAME
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PARALLEL_UPDATES = 0
NO_SENSOR = "no_sensor"
ON_STATES = ["on", "yes", "charging", "alert", "exceeded"]
DESCRIPTIONS: dict[CompitParameter, BinarySensorEntityDescription] = {
CompitParameter.AIRING: BinarySensorEntityDescription(
key=CompitParameter.AIRING.value,
translation_key="airing",
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.BATTERY_CHARGE_STATUS: BinarySensorEntityDescription(
key=CompitParameter.BATTERY_CHARGE_STATUS.value,
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.CO2_ALERT: BinarySensorEntityDescription(
key=CompitParameter.CO2_ALERT.value,
translation_key="co2_alert",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.CO2_LEVEL: BinarySensorEntityDescription(
key=CompitParameter.CO2_LEVEL.value,
translation_key="co2_level",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.DUST_ALERT: BinarySensorEntityDescription(
key=CompitParameter.DUST_ALERT.value,
translation_key="dust_alert",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.PUMP_STATUS: BinarySensorEntityDescription(
key=CompitParameter.PUMP_STATUS.value,
translation_key="pump_status",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.TEMPERATURE_ALERT: BinarySensorEntityDescription(
key=CompitParameter.TEMPERATURE_ALERT.value,
translation_key="temperature_alert",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
}
@dataclass(frozen=True, kw_only=True)
class CompitDeviceDescription:
"""Class to describe a Compit device."""
name: str
parameters: dict[CompitParameter, BinarySensorEntityDescription]
DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
12: CompitDeviceDescription(
name="Nano Color",
parameters={
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
},
),
78: CompitDeviceDescription(
name="SPM - Nano Color 2",
parameters={
CompitParameter.DUST_ALERT: DESCRIPTIONS[CompitParameter.DUST_ALERT],
CompitParameter.TEMPERATURE_ALERT: DESCRIPTIONS[
CompitParameter.TEMPERATURE_ALERT
],
CompitParameter.CO2_ALERT: DESCRIPTIONS[CompitParameter.CO2_ALERT],
},
),
223: CompitDeviceDescription(
name="Nano Color 2",
parameters={
CompitParameter.AIRING: DESCRIPTIONS[CompitParameter.AIRING],
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
},
),
225: CompitDeviceDescription(
name="SPM - Nano Color",
parameters={
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
},
),
226: CompitDeviceDescription(
name="AF-1",
parameters={
CompitParameter.BATTERY_CHARGE_STATUS: DESCRIPTIONS[
CompitParameter.BATTERY_CHARGE_STATUS
],
CompitParameter.PUMP_STATUS: DESCRIPTIONS[CompitParameter.PUMP_STATUS],
},
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: CompitConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Compit binary sensor entities from a config entry."""
coordinator = entry.runtime_data
async_add_devices(
CompitBinarySensor(
coordinator,
device_id,
device_definition.name,
code,
entity_description,
)
for device_id, device in coordinator.connector.all_devices.items()
if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code))
for code, entity_description in device_definition.parameters.items()
if coordinator.connector.get_current_value(device_id, code) != NO_SENSOR
)
class CompitBinarySensor(
CoordinatorEntity[CompitDataUpdateCoordinator], BinarySensorEntity
):
"""Representation of a Compit binary sensor entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: CompitDataUpdateCoordinator,
device_id: int,
device_name: str,
parameter_code: CompitParameter,
entity_description: BinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor entity."""
super().__init__(coordinator)
self.device_id = device_id
self.entity_description = entity_description
self._attr_unique_id = f"{device_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(device_id))},
name=device_name,
manufacturer=MANUFACTURER_NAME,
model=device_name,
)
self.parameter_code = parameter_code
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.coordinator.connector.get_device(self.device_id) is not None
)
@property
def is_on(self) -> bool | None:
"""Return the state of the binary sensor."""
value = self.coordinator.connector.get_current_value(
self.device_id, self.parameter_code
)
if value is None:
return None
return value in ON_STATES

View File

@@ -1,5 +1,25 @@
{
"entity": {
"binary_sensor": {
"airing": {
"default": "mdi:window-open-variant"
},
"co2_alert": {
"default": "mdi:alert"
},
"co2_level": {
"default": "mdi:molecule-co2"
},
"dust_alert": {
"default": "mdi:alert"
},
"pump_status": {
"default": "mdi:pump"
},
"temperature_alert": {
"default": "mdi:alert"
}
},
"number": {
"boiler_target_temperature": {
"default": "mdi:water-boiler"

View File

@@ -33,6 +33,26 @@
}
},
"entity": {
"binary_sensor": {
"airing": {
"name": "Airing"
},
"co2_alert": {
"name": "CO2 alert"
},
"co2_level": {
"name": "CO2 level"
},
"dust_alert": {
"name": "Dust alert"
},
"pump_status": {
"name": "Pump status"
},
"temperature_alert": {
"name": "Temperature alert"
}
},
"number": {
"boiler_target_temperature": {
"name": "Boiler target temperature"

View File

@@ -28,6 +28,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,

View File

@@ -5,7 +5,7 @@ from typing import Any
from pyeconet.equipment import EquipmentType
from pyeconet.equipment.thermostat import (
Thermostat,
ThermostatFanMode,
ThermostatFanSpeed,
ThermostatOperationMode,
)
@@ -16,6 +16,7 @@ from homeassistant.components.climate import (
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
FAN_TOP,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
@@ -41,13 +42,16 @@ HA_STATE_TO_ECONET = {
if key != ThermostatOperationMode.EMERGENCY_HEAT
}
ECONET_FAN_STATE_TO_HA = {
ThermostatFanMode.AUTO: FAN_AUTO,
ThermostatFanMode.LOW: FAN_LOW,
ThermostatFanMode.MEDIUM: FAN_MEDIUM,
ThermostatFanMode.HIGH: FAN_HIGH,
ECONET_FAN_SPEED_TO_HA = {
ThermostatFanSpeed.AUTO: FAN_AUTO,
ThermostatFanSpeed.LOW: FAN_LOW,
ThermostatFanSpeed.MEDIUM: FAN_MEDIUM,
ThermostatFanSpeed.HIGH: FAN_HIGH,
ThermostatFanSpeed.MAX: FAN_TOP,
}
HA_FAN_STATE_TO_ECONET_FAN_SPEED = {
value: key for key, value in ECONET_FAN_SPEED_TO_HA.items()
}
HA_FAN_STATE_TO_ECONET = {value: key for key, value in ECONET_FAN_STATE_TO_HA.items()}
SUPPORT_FLAGS_THERMOSTAT = (
ClimateEntityFeature.TARGET_TEMPERATURE
@@ -103,7 +107,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
return self._econet.set_point
@property
def current_humidity(self) -> int:
def current_humidity(self) -> int | None:
"""Return the current humidity."""
return self._econet.humidity
@@ -149,7 +153,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool, mode.
"""Return hvac operation i.e. heat, cool, mode.
Needs to be one of HVAC_MODE_*.
"""
@@ -174,35 +178,35 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
@property
def fan_mode(self) -> str:
"""Return the current fan mode."""
econet_fan_mode = self._econet.fan_mode
econet_fan_speed = self._econet.fan_speed
# Remove this after we figure out how to handle med lo and med hi
if econet_fan_mode in [ThermostatFanMode.MEDHI, ThermostatFanMode.MEDLO]:
econet_fan_mode = ThermostatFanMode.MEDIUM
if econet_fan_speed in [ThermostatFanSpeed.MEDHI, ThermostatFanSpeed.MEDLO]:
econet_fan_speed = ThermostatFanSpeed.MEDIUM
_current_fan_mode = FAN_AUTO
if econet_fan_mode is not None:
_current_fan_mode = ECONET_FAN_STATE_TO_HA[econet_fan_mode]
return _current_fan_mode
_current_fan_speed = FAN_AUTO
if econet_fan_speed is not None:
_current_fan_speed = ECONET_FAN_SPEED_TO_HA[econet_fan_speed]
return _current_fan_speed
@property
def fan_modes(self) -> list[str]:
"""Return the fan modes."""
# Remove the MEDLO MEDHI once we figure out how to handle it
return [
ECONET_FAN_STATE_TO_HA[mode]
for mode in self._econet.fan_modes
# Remove the MEDLO MEDHI once we figure out how to handle it
ECONET_FAN_SPEED_TO_HA[mode]
for mode in self._econet.fan_speeds
if mode
not in [
ThermostatFanMode.UNKNOWN,
ThermostatFanMode.MEDLO,
ThermostatFanMode.MEDHI,
ThermostatFanSpeed.UNKNOWN,
ThermostatFanSpeed.MEDLO,
ThermostatFanSpeed.MEDHI,
]
]
def set_fan_mode(self, fan_mode: str) -> None:
"""Set the fan mode."""
self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode])
self._econet.set_fan_speed(HA_FAN_STATE_TO_ECONET_FAN_SPEED[fan_mode])
@property
def min_temp(self) -> float:

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["paho_mqtt", "pyeconet"],
"requirements": ["pyeconet==0.1.28"]
"requirements": ["pyeconet==0.2.1"]
}

View File

@@ -0,0 +1,53 @@
"""Support for Rheem EcoNet thermostats with variable fan speeds and fan modes."""
from __future__ import annotations
from pyeconet.equipment import EquipmentType
from pyeconet.equipment.thermostat import Thermostat, ThermostatFanMode
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EconetConfigEntry
from .entity import EcoNetEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the econet thermostat select entity."""
equipment = entry.runtime_data
async_add_entities(
EconetFanModeSelect(thermostat)
for thermostat in equipment[EquipmentType.THERMOSTAT]
if thermostat.supports_fan_mode
)
class EconetFanModeSelect(EcoNetEntity[Thermostat], SelectEntity):
"""Select entity."""
def __init__(self, thermostat: Thermostat) -> None:
"""Initialize EcoNet platform."""
super().__init__(thermostat)
self._attr_name = f"{thermostat.device_name} fan mode"
self._attr_unique_id = (
f"{thermostat.device_id}_{thermostat.device_name}_fan_mode"
)
@property
def options(self) -> list[str]:
"""Return available select options."""
return [e.value for e in self._econet.fan_modes]
@property
def current_option(self) -> str:
"""Return current select option."""
return self._econet.fan_mode.value
def select_option(self, option: str) -> None:
"""Set the selected option."""
self._econet.set_fan_mode(ThermostatFanMode.by_string(option))

View File

@@ -23,19 +23,20 @@ async def async_setup_entry(
entry: EconetConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat switch entity."""
"""Set up the econet thermostat switch entity."""
equipment = entry.runtime_data
async_add_entities(
EcoNetSwitchAuxHeatOnly(thermostat)
for thermostat in equipment[EquipmentType.THERMOSTAT]
if ThermostatOperationMode.EMERGENCY_HEAT in thermostat.modes
)
class EcoNetSwitchAuxHeatOnly(EcoNetEntity[Thermostat], SwitchEntity):
"""Representation of a aux_heat_only EcoNet switch."""
"""Representation of an aux_heat_only EcoNet switch."""
def __init__(self, thermostat: Thermostat) -> None:
"""Initialize EcoNet ventilator platform."""
"""Initialize EcoNet platform."""
super().__init__(thermostat)
self._attr_name = f"{thermostat.device_name} emergency heat"
self._attr_unique_id = (

View File

@@ -4,17 +4,23 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.components.usb import (
human_readable_device_name,
usb_unique_id_from_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE
from homeassistant.const import ATTR_MANUFACTURER, CONF_DEVICE, CONF_NAME
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from . import dongle
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER, MANUFACTURER
MANUAL_SCHEMA = vol.Schema(
{
@@ -31,8 +37,48 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the EnOcean config flow."""
self.dongle_path = None
self.discovery_info = None
self.data: dict[str, Any] = {}
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle usb discovery."""
unique_id = usb_unique_id_from_service_info(discovery_info)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(
updates={CONF_DEVICE: discovery_info.device}
)
discovery_info.device = await self.hass.async_add_executor_job(
usb.get_serial_by_id, discovery_info.device
)
self.data[CONF_DEVICE] = discovery_info.device
self.context["title_placeholders"] = {
CONF_NAME: human_readable_device_name(
discovery_info.device,
discovery_info.serial_number,
discovery_info.manufacturer,
discovery_info.description,
discovery_info.vid,
discovery_info.pid,
)
}
return await self.async_step_usb_confirm()
async def async_step_usb_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle USB Discovery confirmation."""
if user_input is not None:
return await self.async_step_manual({CONF_DEVICE: self.data[CONF_DEVICE]})
self._set_confirm_only()
return self.async_show_form(
step_id="usb_confirm",
description_placeholders={
ATTR_MANUFACTURER: MANUFACTURER,
CONF_DEVICE: self.data.get(CONF_DEVICE, ""),
},
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import a yaml configuration."""
@@ -104,4 +150,4 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
def create_enocean_entry(self, user_input):
"""Create an entry for the provided configuration."""
return self.async_create_entry(title="EnOcean", data=user_input)
return self.async_create_entry(title=MANUFACTURER, data=user_input)

View File

@@ -6,6 +6,8 @@ from homeassistant.const import Platform
DOMAIN = "enocean"
MANUFACTURER = "EnOcean"
ERROR_INVALID_DONGLE_PATH = "invalid_dongle_path"
SIGNAL_RECEIVE_MESSAGE = "enocean.receive_message"

View File

@@ -3,10 +3,19 @@
"name": "EnOcean",
"codeowners": [],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/enocean",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["enocean"],
"requirements": ["enocean==0.50"],
"single_config_entry": true
"single_config_entry": true,
"usb": [
{
"description": "*usb 300*",
"manufacturer": "*enocean*",
"pid": "6001",
"vid": "0403"
}
]
}

View File

@@ -25,6 +25,9 @@
"device": "[%key:component::enocean::config::step::detect::data_description::device%]"
},
"description": "Enter the path to your EnOcean USB dongle."
},
"usb_confirm": {
"description": "{manufacturer} USB dongle detected at {device}. Do you want to set up this device?"
}
}
},

View File

@@ -300,16 +300,23 @@ class RuntimeEntryData:
needed_platforms.add(Platform.BINARY_SENSOR)
needed_platforms.add(Platform.SELECT)
needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos)
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
# Make a dict of the EntityInfo by type and send
# them to the listeners for each specific EntityInfo type
info_types_to_platform = INFO_TYPE_TO_PLATFORM
infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict(
list
)
for info in infos:
infos_by_type[type(info)].append(info)
info_type = type(info)
if platform := info_types_to_platform.get(info_type):
needed_platforms.add(platform)
infos_by_type[info_type].append(info)
else:
_LOGGER.warning(
"Entity type %s is not supported in this version of Home Assistant",
info_type,
)
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
for type_, callbacks in self.entity_info_callbacks.items():
# If all entities for a type are removed, we

View File

@@ -12,11 +12,7 @@ import re
from typing import Any, TypedDict, cast
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import (
FritzActionError,
FritzConnectionException,
FritzSecurityError,
)
from fritzconnection.core.exceptions import FritzActionError
from fritzconnection.lib.fritzcall import FritzCall
from fritzconnection.lib.fritzhosts import FritzHosts
from fritzconnection.lib.fritzstatus import FritzStatus
@@ -47,6 +43,7 @@ from .const import (
DEFAULT_SSL,
DEFAULT_USERNAME,
DOMAIN,
FRITZ_AUTH_EXCEPTIONS,
FRITZ_EXCEPTIONS,
SCAN_INTERVAL,
MeshRoles,
@@ -425,12 +422,18 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
hosts_info: list[HostInfo] = []
try:
try:
hosts_attributes = await self.hass.async_add_executor_job(
self.fritz_hosts.get_hosts_attributes
hosts_attributes = cast(
list[HostAttributes],
await self.hass.async_add_executor_job(
self.fritz_hosts.get_hosts_attributes
),
)
except FritzActionError:
hosts_info = await self.hass.async_add_executor_job(
self.fritz_hosts.get_hosts_info
hosts_info = cast(
list[HostInfo],
await self.hass.async_add_executor_job(
self.fritz_hosts.get_hosts_info
),
)
except Exception as ex:
if not self.hass.is_stopping:
@@ -586,7 +589,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
topology := await self.hass.async_add_executor_job(
self.fritz_hosts.get_mesh_topology
)
):
) or not isinstance(topology, dict):
raise Exception("Mesh supported but empty topology reported") # noqa: TRY002
except FritzActionError:
self.mesh_role = MeshRoles.SLAVE
@@ -742,7 +745,7 @@ class AvmWrapper(FritzBoxTools):
**kwargs,
)
)
except FritzSecurityError:
except FRITZ_AUTH_EXCEPTIONS:
_LOGGER.exception(
"Authorization Error: Please check the provided credentials and"
" verify that you can log into the web interface"
@@ -755,12 +758,6 @@ class AvmWrapper(FritzBoxTools):
action_name,
)
return {}
except FritzConnectionException:
_LOGGER.exception(
"Connection Error: Please check the device is properly configured"
" for remote login"
)
return {}
return result
async def async_get_upnp_configuration(self) -> dict[str, Any]:

View File

@@ -1,7 +1,7 @@
{
"domain": "fritzbox_callmonitor",
"name": "FRITZ!Box Call Monitor",
"codeowners": ["@cdce8p"],
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor",
"integration_type": "device",

View File

@@ -1752,15 +1752,15 @@ class FanSpeedTrait(_Trait):
"""Initialize a trait for a state."""
super().__init__(hass, state, config)
if state.domain == fan.DOMAIN:
speed_count = min(
FAN_SPEED_MAX_SPEED_COUNT,
round(
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
),
speed_count = round(
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
)
self._ordered_speed = [
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
]
if speed_count <= FAN_SPEED_MAX_SPEED_COUNT:
self._ordered_speed = [
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
]
else:
self._ordered_speed = []
@staticmethod
def supported(domain, features, device_class, _):
@@ -1786,7 +1786,11 @@ class FanSpeedTrait(_Trait):
result.update(
{
"reversible": reversible,
"supportsFanSpeedPercent": True,
# supportsFanSpeedPercent is mutually exclusive with
# availableFanSpeeds, where supportsFanSpeedPercent takes
# precedence. Report it only when step speeds are not
# supported so Google renders a percent slider (1-100%).
"supportsFanSpeedPercent": not self._ordered_speed,
}
)
@@ -1832,10 +1836,12 @@ class FanSpeedTrait(_Trait):
if domain == fan.DOMAIN:
percent = attrs.get(fan.ATTR_PERCENTAGE) or 0
response["currentFanSpeedPercent"] = percent
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
self._ordered_speed, percent
)
if self._ordered_speed:
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
self._ordered_speed, percent
)
else:
response["currentFanSpeedPercent"] = percent
return response
@@ -1855,7 +1861,7 @@ class FanSpeedTrait(_Trait):
)
if domain == fan.DOMAIN:
if fan_speed := params.get("fanSpeed"):
if self._ordered_speed and (fan_speed := params.get("fanSpeed")):
fan_speed_percent = ordered_list_item_to_percentage(
self._ordered_speed, fan_speed
)

View File

@@ -181,8 +181,7 @@ class HassIOIngress(HomeAssistantView):
skip_auto_headers={hdrs.CONTENT_TYPE},
) as result:
headers = _response_header(result)
content_length_int = 0
content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED)
# Avoid parsing content_type in simple cases for better performance
if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE):
content_type: str = (maybe_content_type.partition(";"))[0].strip()
@@ -190,17 +189,30 @@ class HassIOIngress(HomeAssistantView):
# default value according to RFC 2616
content_type = "application/octet-stream"
# Empty body responses (304, 204, HEAD, etc.) should not be streamed,
# otherwise aiohttp < 3.9.0 may generate an invalid "0\r\n\r\n" chunk
# This also avoids setting content_type for empty responses.
if must_be_empty_body(request.method, result.status):
# If upstream contains content-type, preserve it (e.g. for HEAD requests)
# Note: This still is omitting content-length. We can't simply forward
# the upstream length since the proxy might change the body length
# (e.g. due to compression).
if maybe_content_type:
headers[hdrs.CONTENT_TYPE] = content_type
return web.Response(
headers=headers,
status=result.status,
)
# Simple request
if (empty_body := must_be_empty_body(result.method, result.status)) or (
content_length_int = 0
content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED)
if (
content_length is not UNDEFINED
and (content_length_int := int(content_length))
<= MAX_SIMPLE_RESPONSE_SIZE
):
# Return Response
if empty_body:
body = None
else:
body = await result.read()
body = await result.read()
simple_response = web.Response(
headers=headers,
status=result.status,

View File

@@ -0,0 +1,27 @@
"""Diagnostics support for HomematicIP Cloud."""
from __future__ import annotations
import json
from typing import Any
from homematicip.base.helpers import handle_config
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .hap import HomematicIPConfigEntry
TO_REDACT_CONFIG = {"city", "latitude", "longitude", "refreshToken"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: HomematicIPConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
hap = config_entry.runtime_data
json_state = await hap.home.download_configuration_async()
anonymized = handle_config(json_state, anonymize=True)
config = json.loads(anonymized)
return async_redact_data(config, TO_REDACT_CONFIG)

View File

@@ -10,7 +10,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.SENSOR,
Platform.SWITCH,
Platform.SELECT,
Platform.NUMBER,
]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:

View File

@@ -0,0 +1,132 @@
"""Support for Homevolt number entities."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
@dataclass(frozen=True, kw_only=True)
class HomevoltNumberEntityDescription(NumberEntityDescription):
"""Custom entity description for Homevolt numbers."""
set_value_fn: Any = None
value_fn: Any = None
NUMBER_DESCRIPTIONS: tuple[HomevoltNumberEntityDescription, ...] = (
HomevoltNumberEntityDescription(
key="setpoint",
translation_key="setpoint",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_charge",
translation_key="max_charge",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_discharge",
translation_key="max_discharge",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="min_soc",
translation_key="min_soc",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_soc",
translation_key="max_soc",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="grid_import_limit",
translation_key="grid_import_limit",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="grid_export_limit",
translation_key="grid_export_limit",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt number entities."""
coordinator = entry.runtime_data
entities: list[HomevoltNumberEntity] = []
for description in NUMBER_DESCRIPTIONS:
entities.append(HomevoltNumberEntity(coordinator, description))
async_add_entities(entities)
class HomevoltNumberEntity(HomevoltEntity, NumberEntity):
"""Representation of a Homevolt number entity."""
entity_description: HomevoltNumberEntityDescription
def __init__(
self,
coordinator: HomevoltDataUpdateCoordinator,
description: HomevoltNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.unique_id}_{description.key}"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def native_value(self) -> float | None:
"""Return the current value."""
value = self.coordinator.client.schedule.get(self.entity_description.key)
return float(value) if value is not None else None
@homevolt_exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
key = self.entity_description.key
await self.coordinator.client.set_battery_parameters(**{key: int(value)})
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,51 @@
"""Support for Homevolt select entities."""
from __future__ import annotations
from homevolt.const import SCHEDULE_TYPE
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
PARALLEL_UPDATES = 0 # Coordinator-based updates
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt select entities."""
coordinator = entry.runtime_data
async_add_entities([HomevoltModeSelect(coordinator)])
class HomevoltModeSelect(HomevoltEntity, SelectEntity):
"""Select entity for battery operational mode."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "battery_mode"
_attr_options = list(SCHEDULE_TYPE.values())
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
"""Initialize the select entity."""
self._attr_unique_id = f"{coordinator.data.unique_id}_battery_mode"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def current_option(self) -> str | None:
"""Return the current selected mode."""
mode_int = self.coordinator.client.schedule_mode
return SCHEDULE_TYPE.get(mode_int)
@homevolt_exception_handler
async def async_select_option(self, option: str) -> None:
"""Change the selected mode."""
await self.coordinator.client.set_battery_mode(mode=option)
await self.coordinator.async_request_refresh()

View File

@@ -54,6 +54,46 @@
}
},
"entity": {
"number": {
"grid_export_limit": {
"name": "Grid export limit"
},
"grid_import_limit": {
"name": "Grid import limit"
},
"max_charge": {
"name": "Maximum charge power"
},
"max_discharge": {
"name": "Maximum discharge power"
},
"max_soc": {
"name": "Maximum state of charge"
},
"min_soc": {
"name": "Minimum state of charge"
},
"setpoint": {
"name": "Power setpoint"
}
},
"select": {
"battery_mode": {
"name": "Battery mode",
"state": {
"frequency_reserve": "Frequency reserve",
"full_solar_export": "Full solar export",
"grid_charge": "Grid charge",
"grid_charge_discharge": "Grid charge/discharge",
"grid_discharge": "Grid discharge",
"idle": "Idle",
"inverter_charge": "Inverter charge",
"inverter_discharge": "Inverter discharge",
"solar_charge": "Solar charge",
"solar_charge_discharge": "Solar charge/discharge"
}
}
},
"sensor": {
"available_charging_energy": {
"name": "Available charging energy"

View File

@@ -6,6 +6,7 @@ from enum import Enum
import logging
from typing import Any
from bleak.backends.scanner import AdvertisementData
from HueBLE import ConnectionError, HueBleError, HueBleLight, PairingError
import voluptuous as vol
@@ -26,6 +27,17 @@ from .light import get_available_color_modes
_LOGGER = logging.getLogger(__name__)
SERVICE_UUID = SERVICE_DATA_UUID = "0000fe0f-0000-1000-8000-00805f9b34fb"
def device_filter(advertisement_data: AdvertisementData) -> bool:
"""Return True if the device is supported."""
return (
SERVICE_UUID in advertisement_data.service_uuids
and SERVICE_DATA_UUID in advertisement_data.service_data
)
async def validate_input(hass: HomeAssistant, address: str) -> Error | None:
"""Return error if cannot connect and validate."""
@@ -70,28 +82,66 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
unique_id = dr.format_mac(user_input[CONF_MAC])
# Don't raise on progress because there may be discovery flows
await self.async_set_unique_id(unique_id, raise_on_progress=False)
# Guard against the user selecting a device which has been configured by
# another flow.
self._abort_if_unique_id_configured()
self._discovery_info = self._discovered_devices[user_input[CONF_MAC]]
return await self.async_step_confirm()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in bluetooth.async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
or discovery.address in self._discovered_devices
or not device_filter(discovery.advertisement)
):
continue
self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
data_schema = vol.Schema(
{
vol.Required(CONF_MAC): vol.In(
{
service_info.address: (
f"{service_info.name} ({service_info.address})"
)
for service_info in self._discovered_devices.values()
}
),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)
async def async_step_bluetooth(
self, discovery_info: bluetooth.BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a flow initialized by the home assistant scanner."""
_LOGGER.debug(
"HA found light %s. Will show in UI but not auto connect",
"HA found light %s. Use user flow to show in UI and connect",
discovery_info.name,
)
unique_id = dr.format_mac(discovery_info.address)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
name = f"{discovery_info.name} ({discovery_info.address})"
self.context.update({"title_placeholders": {CONF_NAME: name}})
self._discovery_info = discovery_info
return await self.async_step_confirm()
return self.async_abort(reason="discovery_unsupported")
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
@@ -103,7 +153,10 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
unique_id = dr.format_mac(self._discovery_info.address)
await self.async_set_unique_id(unique_id)
# Don't raise on progress because there may be discovery flows
await self.async_set_unique_id(unique_id, raise_on_progress=False)
# Guard against the user selecting a device which has been configured by
# another flow.
self._abort_if_unique_id_configured()
error = await validate_input(self.hass, unique_id)
if error:

View File

@@ -2,7 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"not_implemented": "This integration can only be set up via discovery."
"discovery_unsupported": "Discovery flow is not supported by the Hue BLE integration.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -14,7 +15,16 @@
},
"step": {
"confirm": {
"description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
"description": "Do you want to set up {name} ({mac})?\nMake sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
},
"user": {
"data": {
"mac": "[%key:common::config_flow::data::device%]"
},
"data_description": {
"mac": "Select the Hue device you want to set up"
},
"description": "[%key:component::bluetooth::config::step::user::description%]"
}
}
}

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==2.0.1"]
"requirements": ["imgw_pib==2.0.2"]
}

View File

@@ -24,6 +24,7 @@
"hydrological_alert": {
"name": "Hydrological alert",
"state": {
"exceeding_the_alarm_level": "Exceeding the alarm level",
"exceeding_the_warning_level": "Exceeding the warning level",
"hydrological_drought": "Hydrological drought",
"no_alert": "No alert",

View File

@@ -109,14 +109,18 @@ class LunatoneLight(
return self._device is not None and self._device.is_on
@property
def brightness(self) -> int:
def brightness(self) -> int | None:
"""Return the brightness of this light between 0..255."""
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
return (
value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
if self._device.brightness is not None
else None
)
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if self._device is not None and self._device.is_dimmable:
if self._device is not None and self._device.brightness is not None:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@@ -149,7 +153,8 @@ class LunatoneLight(
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
if brightness_supported(self.supported_color_modes):
self._last_brightness = self.brightness
if self.brightness:
self._last_brightness = self.brightness
await self._device.fade_to_brightness(0)
else:
await self._device.switch_off()

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.6.3"]
"requirements": ["lunatone-rest-api-client==0.7.0"]
}

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp[default]==2026.02.04"],
"requirements": ["yt-dlp[default]==2026.02.21"],
"single_config_entry": true
}

View File

@@ -2,10 +2,12 @@
from __future__ import annotations
import asyncio
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN as DOMAIN
from .const import DOMAIN as DOMAIN, SUBENTRY_TYPE_BUS, SUBENTRY_TYPE_SUBWAY
from .coordinator import MTAConfigEntry, MTADataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
@@ -13,16 +15,36 @@ PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool:
"""Set up MTA from a config entry."""
coordinator = MTADataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
coordinators: dict[str, MTADataUpdateCoordinator] = {}
entry.runtime_data = coordinator
for subentry_id, subentry in entry.subentries.items():
if subentry.subentry_type not in (SUBENTRY_TYPE_SUBWAY, SUBENTRY_TYPE_BUS):
continue
coordinators[subentry_id] = MTADataUpdateCoordinator(hass, entry, subentry)
# Refresh all coordinators in parallel
await asyncio.gather(
*(
coordinator.async_config_entry_first_refresh()
for coordinator in coordinators.values()
)
)
entry.runtime_data = coordinators
entry.async_on_unload(entry.add_update_listener(async_update_entry))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_update_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> None:
"""Handle config entry update (e.g., subentry changes)."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -2,22 +2,43 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from pymta import LINE_TO_FEED, MTAFeedError, SubwayFeed
from pymta import LINE_TO_FEED, BusFeed, MTAFeedError, SubwayFeed
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import aiohttp_client
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN
from .const import (
CONF_LINE,
CONF_ROUTE,
CONF_STOP_ID,
CONF_STOP_NAME,
DOMAIN,
SUBENTRY_TYPE_BUS,
SUBENTRY_TYPE_SUBWAY,
)
_LOGGER = logging.getLogger(__name__)
@@ -28,17 +49,79 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
self.stops: dict[str, str] = {}
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {
SUBENTRY_TYPE_SUBWAY: SubwaySubentryFlowHandler,
SUBENTRY_TYPE_BUS: BusSubentryFlowHandler,
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
api_key = user_input.get(CONF_API_KEY)
self._async_abort_entries_match({CONF_API_KEY: api_key})
if api_key:
# Test the API key by trying to fetch bus data
session = async_get_clientsession(self.hass)
bus_feed = BusFeed(api_key=api_key, session=session)
try:
# Try to get stops for a known route to validate the key
await bus_feed.get_stops(route_id="M15")
except MTAFeedError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error validating API key")
errors["base"] = "unknown"
if not errors:
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_KEY: api_key or None},
)
return self.async_create_entry(
title="MTA",
data={CONF_API_KEY: api_key or None},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
),
errors=errors,
)
async def async_step_reauth(
self, _entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth when user wants to add or update API key."""
return await self.async_step_user()
class SubwaySubentryFlowHandler(ConfigSubentryFlow):
"""Handle subway stop subentry flow."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
self.data: dict[str, Any] = {}
self.stops: dict[str, str] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the line selection step."""
if user_input is not None:
self.data[CONF_LINE] = user_input[CONF_LINE]
return await self.async_step_stop()
@@ -58,13 +141,12 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
),
}
),
errors=errors,
)
async def async_step_stop(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the stop step."""
) -> SubentryFlowResult:
"""Handle the stop selection step."""
errors: dict[str, str] = {}
if user_input is not None:
@@ -74,25 +156,30 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
self.data[CONF_STOP_NAME] = stop_name
unique_id = f"{self.data[CONF_LINE]}_{stop_id}"
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
# Test connection to real-time GTFS-RT feed (different from static GTFS used by get_stops)
# Check for duplicate subentries across all entries
for entry in self.hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
# Test connection to real-time GTFS-RT feed
try:
await self._async_test_connection()
except MTAFeedError:
errors["base"] = "cannot_connect"
else:
title = f"{self.data[CONF_LINE]} Line - {stop_name}"
title = f"{self.data[CONF_LINE]} - {stop_name}"
return self.async_create_entry(
title=title,
data=self.data,
unique_id=unique_id,
)
try:
self.stops = await self._async_get_stops(self.data[CONF_LINE])
except MTAFeedError:
_LOGGER.exception("Error fetching stops for line %s", self.data[CONF_LINE])
_LOGGER.debug("Error fetching stops for line %s", self.data[CONF_LINE])
return self.async_abort(reason="cannot_connect")
if not self.stops:
@@ -123,7 +210,7 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
async def _async_get_stops(self, line: str) -> dict[str, str]:
"""Get stops for a line from the library."""
feed_id = SubwayFeed.get_feed_id_for_route(line)
session = aiohttp_client.async_get_clientsession(self.hass)
session = async_get_clientsession(self.hass)
subway_feed = SubwayFeed(feed_id=feed_id, session=session)
stops_list = await subway_feed.get_stops(route_id=line)
@@ -141,7 +228,7 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
async def _async_test_connection(self) -> None:
"""Test connection to MTA feed."""
feed_id = SubwayFeed.get_feed_id_for_route(self.data[CONF_LINE])
session = aiohttp_client.async_get_clientsession(self.hass)
session = async_get_clientsession(self.hass)
subway_feed = SubwayFeed(feed_id=feed_id, session=session)
await subway_feed.get_arrivals(
@@ -149,3 +236,133 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
stop_id=self.data[CONF_STOP_ID],
max_arrivals=1,
)
class BusSubentryFlowHandler(ConfigSubentryFlow):
"""Handle bus stop subentry flow."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
self.data: dict[str, Any] = {}
self.stops: dict[str, str] = {}
def _get_api_key(self) -> str:
"""Get API key from parent entry."""
return self._get_entry().data.get(CONF_API_KEY) or ""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the route input step."""
errors: dict[str, str] = {}
if user_input is not None:
route = user_input[CONF_ROUTE].upper().strip()
self.data[CONF_ROUTE] = route
# Validate route by fetching stops
try:
self.stops = await self._async_get_stops(route)
if not self.stops:
errors["base"] = "invalid_route"
else:
return await self.async_step_stop()
except MTAFeedError:
_LOGGER.debug("Error fetching stops for route %s", route)
errors["base"] = "invalid_route"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ROUTE): TextSelector(),
}
),
errors=errors,
)
async def async_step_stop(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the stop selection step."""
errors: dict[str, str] = {}
if user_input is not None:
stop_id = user_input[CONF_STOP_ID]
self.data[CONF_STOP_ID] = stop_id
stop_name = self.stops.get(stop_id, stop_id)
self.data[CONF_STOP_NAME] = stop_name
unique_id = f"bus_{self.data[CONF_ROUTE]}_{stop_id}"
# Check for duplicate subentries across all entries
for entry in self.hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
# Test connection to real-time feed
try:
await self._async_test_connection()
except MTAFeedError:
errors["base"] = "cannot_connect"
else:
title = f"{self.data[CONF_ROUTE]} - {stop_name}"
return self.async_create_entry(
title=title,
data=self.data,
unique_id=unique_id,
)
stop_options = [
SelectOptionDict(value=stop_id, label=stop_name)
for stop_id, stop_name in sorted(self.stops.items(), key=lambda x: x[1])
]
return self.async_show_form(
step_id="stop",
data_schema=vol.Schema(
{
vol.Required(CONF_STOP_ID): SelectSelector(
SelectSelectorConfig(
options=stop_options,
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
description_placeholders={"route": self.data[CONF_ROUTE]},
)
async def _async_get_stops(self, route: str) -> dict[str, str]:
"""Get stops for a bus route from the library."""
session = async_get_clientsession(self.hass)
api_key = self._get_api_key()
bus_feed = BusFeed(api_key=api_key, session=session)
stops_list = await bus_feed.get_stops(route_id=route)
stops = {}
for stop in stops_list:
stop_id = stop["stop_id"]
stop_name = stop["stop_name"]
# Add direction if available (e.g., "to South Ferry")
if direction := stop.get("direction_name"):
stops[stop_id] = f"{stop_name} (to {direction})"
else:
stops[stop_id] = stop_name
return stops
async def _async_test_connection(self) -> None:
"""Test connection to MTA bus feed."""
session = async_get_clientsession(self.hass)
api_key = self._get_api_key()
bus_feed = BusFeed(api_key=api_key, session=session)
await bus_feed.get_arrivals(
route_id=self.data[CONF_ROUTE],
stop_id=self.data[CONF_STOP_ID],
max_arrivals=1,
)

View File

@@ -7,5 +7,9 @@ DOMAIN = "mta"
CONF_LINE = "line"
CONF_STOP_ID = "stop_id"
CONF_STOP_NAME = "stop_name"
CONF_ROUTE = "route"
SUBENTRY_TYPE_SUBWAY = "subway"
SUBENTRY_TYPE_BUS = "bus"
UPDATE_INTERVAL = timedelta(seconds=30)

View File

@@ -6,22 +6,30 @@ from dataclasses import dataclass
from datetime import datetime
import logging
from pymta import MTAFeedError, SubwayFeed
from pymta import BusFeed, MTAFeedError, SubwayFeed
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, UPDATE_INTERVAL
from .const import (
CONF_LINE,
CONF_ROUTE,
CONF_STOP_ID,
DOMAIN,
SUBENTRY_TYPE_BUS,
UPDATE_INTERVAL,
)
_LOGGER = logging.getLogger(__name__)
@dataclass
class MTAArrival:
"""Represents a single train arrival."""
"""Represents a single transit arrival."""
arrival_time: datetime
minutes_until: int
@@ -36,7 +44,7 @@ class MTAData:
arrivals: list[MTAArrival]
type MTAConfigEntry = ConfigEntry[MTADataUpdateCoordinator]
type MTAConfigEntry = ConfigEntry[dict[str, MTADataUpdateCoordinator]]
class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]):
@@ -44,35 +52,48 @@ class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]):
config_entry: MTAConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: MTAConfigEntry) -> None:
def __init__(
self,
hass: HomeAssistant,
config_entry: MTAConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize."""
self.line = config_entry.data[CONF_LINE]
self.stop_id = config_entry.data[CONF_STOP_ID]
self.subentry = subentry
self.stop_id = subentry.data[CONF_STOP_ID]
self.feed_id = SubwayFeed.get_feed_id_for_route(self.line)
session = async_get_clientsession(hass)
self.subway_feed = SubwayFeed(feed_id=self.feed_id, session=session)
if subentry.subentry_type == SUBENTRY_TYPE_BUS:
api_key = config_entry.data.get(CONF_API_KEY) or ""
self.feed: BusFeed | SubwayFeed = BusFeed(api_key=api_key, session=session)
self.route_id = subentry.data[CONF_ROUTE]
else:
# Subway feed
line = subentry.data[CONF_LINE]
feed_id = SubwayFeed.get_feed_id_for_route(line)
self.feed = SubwayFeed(feed_id=feed_id, session=session)
self.route_id = line
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
name=f"{DOMAIN}_{subentry.subentry_id}",
update_interval=UPDATE_INTERVAL,
)
async def _async_update_data(self) -> MTAData:
"""Fetch data from MTA."""
_LOGGER.debug(
"Fetching data for line=%s, stop=%s, feed=%s",
self.line,
"Fetching data for route=%s, stop=%s",
self.route_id,
self.stop_id,
self.feed_id,
)
try:
library_arrivals = await self.subway_feed.get_arrivals(
route_id=self.line,
library_arrivals = await self.feed.get_arrivals(
route_id=self.route_id,
stop_id=self.stop_id,
max_arrivals=3,
)

View File

@@ -38,9 +38,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: No authentication required.
reauthentication-flow: done
test-coverage: done
# Gold

View File

@@ -11,12 +11,13 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN
from .const import CONF_LINE, CONF_ROUTE, CONF_STOP_NAME, DOMAIN, SUBENTRY_TYPE_BUS
from .coordinator import MTAArrival, MTAConfigEntry, MTADataUpdateCoordinator
PARALLEL_UPDATES = 0
@@ -97,16 +98,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MTA sensor based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
MTASensor(coordinator, entry, description)
for description in SENSOR_DESCRIPTIONS
)
for subentry_id, coordinator in entry.runtime_data.items():
subentry = entry.subentries[subentry_id]
async_add_entities(
(
MTASensor(coordinator, subentry, description)
for description in SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry_id,
)
class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity):
"""Sensor for MTA train arrivals."""
"""Sensor for MTA transit arrivals."""
_attr_has_entity_name = True
entity_description: MTASensorEntityDescription
@@ -114,24 +118,32 @@ class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity):
def __init__(
self,
coordinator: MTADataUpdateCoordinator,
entry: MTAConfigEntry,
subentry: ConfigSubentry,
description: MTASensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
line = entry.data[CONF_LINE]
stop_id = entry.data[CONF_STOP_ID]
stop_name = entry.data.get(CONF_STOP_NAME, stop_id)
self._attr_unique_id = f"{entry.unique_id}-{description.key}"
is_bus = subentry.subentry_type == SUBENTRY_TYPE_BUS
if is_bus:
route = subentry.data[CONF_ROUTE]
model = "Bus"
else:
route = subentry.data[CONF_LINE]
model = "Subway"
stop_name = subentry.data.get(CONF_STOP_NAME, subentry.subentry_id)
unique_id = subentry.unique_id or subentry.subentry_id
self._attr_unique_id = f"{unique_id}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=f"{line} Line - {stop_name} ({stop_id})",
identifiers={(DOMAIN, unique_id)},
name=f"{route} - {stop_name}",
manufacturer="MTA",
model="Subway",
model=model,
entry_type=DeviceEntryType.SERVICE,
)

View File

@@ -2,32 +2,95 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_stops": "No stops found for this line. The line may not be currently running."
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"stop": {
"data": {
"stop_id": "Stop and direction"
},
"data_description": {
"stop_id": "Select the stop and direction you want to track"
},
"description": "Choose a stop on the {line} line. The direction is included with each stop.",
"title": "Select stop and direction"
},
"user": {
"data": {
"line": "Line"
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"line": "The subway line to track"
"api_key": "API key from MTA Bus Time. Required for bus tracking, optional for subway only."
},
"description": "Choose the subway line you want to track.",
"title": "Select subway line"
"description": "Enter your MTA Bus Time API key to enable bus tracking. Leave blank if you only want to track subways."
}
}
},
"config_subentries": {
"bus": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"entry_type": "Bus stop",
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_route": "Invalid bus route. Please check the route name and try again."
},
"initiate_flow": {
"user": "Add bus stop"
},
"step": {
"stop": {
"data": {
"stop_id": "Stop"
},
"data_description": {
"stop_id": "Select the stop you want to track"
},
"description": "Choose a stop on the {route} route.",
"title": "Select stop"
},
"user": {
"data": {
"route": "Route"
},
"data_description": {
"route": "The bus route identifier"
},
"description": "Enter the bus route you want to track (for example, M15, B46, Q10).",
"title": "Enter bus route"
}
}
},
"subway": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_stops": "No stops found for this line. The line may not be currently running."
},
"entry_type": "Subway stop",
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"initiate_flow": {
"user": "Add subway stop"
},
"step": {
"stop": {
"data": {
"stop_id": "Stop and direction"
},
"data_description": {
"stop_id": "Select the stop and direction you want to track"
},
"description": "Choose a stop on the {line} line. The direction is included with each stop.",
"title": "Select stop and direction"
},
"user": {
"data": {
"line": "Line"
},
"data_description": {
"line": "The subway line to track"
},
"description": "Choose the subway line you want to track.",
"title": "Select subway line"
}
}
}
},

View File

@@ -120,6 +120,31 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered_name: str | None = None
self._pending_host: str | None = None
async def _async_validate_host(
self,
host: str,
errors: dict[str, str],
) -> tuple[dict[str, Any] | None, bool]:
"""Validate host connection and populate errors dict on failure.
Returns (info, needs_auth). When needs_auth is True, the caller
should store the host and redirect to the appropriate auth step.
"""
try:
return await validate_input(self.hass, host), False
except NRGkickApiClientApiDisabledError:
errors["base"] = "json_api_disabled"
except NRGkickApiClientAuthenticationError:
return None, True
except NRGkickApiClientInvalidResponseError:
errors["base"] = "invalid_response"
except NRGkickApiClientCommunicationError:
errors["base"] = "cannot_connect"
except NRGkickApiClientError:
_LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
return None, False
async def _async_validate_credentials(
self,
host: str,
@@ -156,21 +181,11 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
except vol.Invalid:
errors["base"] = "cannot_connect"
else:
try:
info = await validate_input(self.hass, host)
except NRGkickApiClientApiDisabledError:
errors["base"] = "json_api_disabled"
except NRGkickApiClientAuthenticationError:
info, needs_auth = await self._async_validate_host(host, errors)
if needs_auth:
self._pending_host = host
return await self.async_step_user_auth()
except NRGkickApiClientInvalidResponseError:
errors["base"] = "invalid_response"
except NRGkickApiClientCommunicationError:
errors["base"] = "cannot_connect"
except NRGkickApiClientError:
_LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
else:
if info:
await self.async_set_unique_id(
info["serial"], raise_on_progress=False
)
@@ -257,6 +272,81 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
try:
host = _normalize_host(user_input[CONF_HOST])
except vol.Invalid:
errors["base"] = "cannot_connect"
else:
info, needs_auth = await self._async_validate_host(host, errors)
if needs_auth:
self._pending_host = host
return await self.async_step_reconfigure_auth()
if info:
await self.async_set_unique_id(
info["serial"], raise_on_progress=False
)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={CONF_HOST: host},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
reconfigure_entry.data,
),
errors=errors,
)
async def async_step_reconfigure_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration authentication step."""
errors: dict[str, str] = {}
if TYPE_CHECKING:
assert self._pending_host is not None
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
if info := await self._async_validate_credentials(
self._pending_host,
errors,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
CONF_HOST: self._pending_host,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="reconfigure_auth",
data_schema=self.add_suggested_values_to_schema(
STEP_AUTH_DATA_SCHEMA,
reconfigure_entry.data,
),
errors=errors,
description_placeholders={
"device_ip": self._pending_host,
},
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -321,21 +411,13 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
assert self._discovered_name is not None
if user_input is not None:
try:
info = await validate_input(self.hass, self._discovered_host)
except NRGkickApiClientApiDisabledError:
errors["base"] = "json_api_disabled"
except NRGkickApiClientAuthenticationError:
info, needs_auth = await self._async_validate_host(
self._discovered_host, errors
)
if needs_auth:
self._pending_host = self._discovered_host
return await self.async_step_user_auth()
except NRGkickApiClientInvalidResponseError:
errors["base"] = "invalid_response"
except NRGkickApiClientCommunicationError:
errors["base"] = "cannot_connect"
except NRGkickApiClientError:
_LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
else:
if info:
return self.async_create_entry(
title=info["title"], data={CONF_HOST: self._discovered_host}
)

View File

@@ -68,7 +68,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices:
status: exempt

View File

@@ -6,6 +6,7 @@
"json_api_disabled": "JSON API is disabled on the device. Enable it in the NRGkick mobile app under Extended \u2192 Local API \u2192 API Variants.",
"no_serial_number": "Device does not provide a serial number",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device does not match the previous device"
},
"error": {
@@ -28,6 +29,26 @@
},
"description": "Reauthenticate with your NRGkick device.\n\nGet your username and password in the NRGkick mobile app:\n1. Open the NRGkick mobile app \u2192 Extended \u2192 Local API\n2. Under Authentication (JSON), check or set your username and password"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::nrgkick::config::step::user::data_description::host%]"
},
"description": "Reconfigure your NRGkick device. This allows you to change the IP address or hostname of your NRGkick device."
},
"reconfigure_auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::nrgkick::config::step::user_auth::data_description::password%]",
"username": "[%key:component::nrgkick::config::step::user_auth::data_description::username%]"
},
"description": "[%key:component::nrgkick::config::step::user_auth::description%]"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"

View File

@@ -394,10 +394,10 @@
"name": "Delete notification"
},
"publish": {
"description": "Publishes a notification message to a ntfy topic",
"description": "Publishes a notification message to a ntfy topic.",
"fields": {
"actions": {
"description": "Up to three actions ('view', 'broadcast', or 'http') can be added as buttons below the notification. Actions are executed when the corresponding button is tapped or clicked.",
"description": "Up to three actions (`view`, `broadcast`, `http`, or `copy`) can be added as buttons below the notification. Actions are executed when the corresponding button is tapped or clicked.",
"name": "Action buttons"
},
"attach": {

View File

@@ -1,6 +1,6 @@
{
"domain": "powerfox",
"name": "Powerfox",
"name": "Powerfox Cloud",
"codeowners": ["@klaasnicolaas"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/powerfox",

View File

@@ -37,7 +37,10 @@ from .const import (
)
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator
PLATFORMS = [Platform.BINARY_SENSOR]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
]
CONFIG_SCHEMA = vol.Schema(

View File

@@ -0,0 +1,339 @@
"""Button platform for Proxmox VE."""
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from proxmoxer import AuthenticationError
from proxmoxer.core import ResourceException
import requests
from requests.exceptions import ConnectTimeout, SSLError
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
@dataclass(frozen=True, kw_only=True)
class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription):
"""Class to hold Proxmox node button description."""
press_action: Callable[[ProxmoxCoordinator, str], None]
@dataclass(frozen=True, kw_only=True)
class ProxmoxVMButtonEntityDescription(ButtonEntityDescription):
"""Class to hold Proxmox VM button description."""
press_action: Callable[[ProxmoxCoordinator, str, int], None]
@dataclass(frozen=True, kw_only=True)
class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription):
"""Class to hold Proxmox container button description."""
press_action: Callable[[ProxmoxCoordinator, str, int], None]
NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
ProxmoxNodeButtonNodeEntityDescription(
key="reboot",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
).status.post(command="reboot"),
entity_category=EntityCategory.CONFIG,
device_class=ButtonDeviceClass.RESTART,
),
ProxmoxNodeButtonNodeEntityDescription(
key="shutdown",
translation_key="shutdown",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
).status.post(command="shutdown"),
entity_category=EntityCategory.CONFIG,
),
ProxmoxNodeButtonNodeEntityDescription(
key="start_all",
translation_key="start_all",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
).startall.post(),
entity_category=EntityCategory.CONFIG,
),
ProxmoxNodeButtonNodeEntityDescription(
key="stop_all",
translation_key="stop_all",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
).stopall.post(),
entity_category=EntityCategory.CONFIG,
),
)
VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = (
ProxmoxVMButtonEntityDescription(
key="start",
translation_key="start",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.start.post()
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxVMButtonEntityDescription(
key="stop",
translation_key="stop",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.stop.post()
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxVMButtonEntityDescription(
key="restart",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.restart.post()
),
entity_category=EntityCategory.CONFIG,
device_class=ButtonDeviceClass.RESTART,
),
ProxmoxVMButtonEntityDescription(
key="hibernate",
translation_key="hibernate",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.hibernate.post()
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxVMButtonEntityDescription(
key="reset",
translation_key="reset",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.reset.post()
),
entity_category=EntityCategory.CONFIG,
),
)
CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = (
ProxmoxContainerButtonEntityDescription(
key="start",
translation_key="start",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).lxc(vmid).status.start.post()
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxContainerButtonEntityDescription(
key="stop",
translation_key="stop",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).lxc(vmid).status.stop.post()
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxContainerButtonEntityDescription(
key="restart",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).lxc(vmid).status.restart.post()
),
entity_category=EntityCategory.CONFIG,
device_class=ButtonDeviceClass.RESTART,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ProxmoxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ProxmoxVE buttons."""
coordinator = entry.runtime_data
def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None:
"""Add new node buttons."""
async_add_entities(
ProxmoxNodeButtonEntity(coordinator, entity_description, node)
for node in nodes
for entity_description in NODE_BUTTONS
)
def _async_add_new_vms(
vms: list[tuple[ProxmoxNodeData, dict[str, Any]]],
) -> None:
"""Add new VM buttons."""
async_add_entities(
ProxmoxVMButtonEntity(coordinator, entity_description, vm, node_data)
for (node_data, vm) in vms
for entity_description in VM_BUTTONS
)
def _async_add_new_containers(
containers: list[tuple[ProxmoxNodeData, dict[str, Any]]],
) -> None:
"""Add new container buttons."""
async_add_entities(
ProxmoxContainerButtonEntity(
coordinator, entity_description, container, node_data
)
for (node_data, container) in containers
for entity_description in CONTAINER_BUTTONS
)
coordinator.new_nodes_callbacks.append(_async_add_new_nodes)
coordinator.new_vms_callbacks.append(_async_add_new_vms)
coordinator.new_containers_callbacks.append(_async_add_new_containers)
_async_add_new_nodes(
[
node_data
for node_data in coordinator.data.values()
if node_data.node["node"] in coordinator.known_nodes
]
)
_async_add_new_vms(
[
(node_data, vm_data)
for node_data in coordinator.data.values()
for vmid, vm_data in node_data.vms.items()
if (node_data.node["node"], vmid) in coordinator.known_vms
]
)
_async_add_new_containers(
[
(node_data, container_data)
for node_data in coordinator.data.values()
for vmid, container_data in node_data.containers.items()
if (node_data.node["node"], vmid) in coordinator.known_containers
]
)
class ProxmoxBaseButton(ButtonEntity):
"""Common base for Proxmox buttons. Basically to ensure the async_press logic isn't duplicated."""
entity_description: ButtonEntityDescription
coordinator: ProxmoxCoordinator
@abstractmethod
async def _async_press_call(self) -> None:
"""Abstract method used per Proxmox button class."""
async def async_press(self) -> None:
"""Trigger the Proxmox button press service."""
try:
await self._async_press_call()
except AuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_no_details",
) from err
except SSLError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth_no_details",
) from err
except ConnectTimeout as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect_no_details",
) from err
except (ResourceException, requests.exceptions.ConnectionError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error_no_details",
) from err
class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton):
"""Represents a Proxmox Node button entity."""
entity_description: ProxmoxNodeButtonNodeEntityDescription
def __init__(
self,
coordinator: ProxmoxCoordinator,
entity_description: ProxmoxNodeButtonNodeEntityDescription,
node_data: ProxmoxNodeData,
) -> None:
"""Initialize the Proxmox Node button entity."""
self.entity_description = entity_description
super().__init__(coordinator, node_data)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}"
async def _async_press_call(self) -> None:
"""Execute the node button action via executor."""
await self.hass.async_add_executor_job(
self.entity_description.press_action,
self.coordinator,
self._node_data.node["node"],
)
class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton):
"""Represents a Proxmox VM button entity."""
entity_description: ProxmoxVMButtonEntityDescription
def __init__(
self,
coordinator: ProxmoxCoordinator,
entity_description: ProxmoxVMButtonEntityDescription,
vm_data: dict[str, Any],
node_data: ProxmoxNodeData,
) -> None:
"""Initialize the Proxmox VM button entity."""
self.entity_description = entity_description
super().__init__(coordinator, vm_data, node_data)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
async def _async_press_call(self) -> None:
"""Execute the VM button action via executor."""
await self.hass.async_add_executor_job(
self.entity_description.press_action,
self.coordinator,
self._node_name,
self.vm_data["vmid"],
)
class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton):
"""Represents a Proxmox Container button entity."""
entity_description: ProxmoxContainerButtonEntityDescription
def __init__(
self,
coordinator: ProxmoxCoordinator,
entity_description: ProxmoxContainerButtonEntityDescription,
container_data: dict[str, Any],
node_data: ProxmoxNodeData,
) -> None:
"""Initialize the Proxmox Container button entity."""
self.entity_description = entity_description
super().__init__(coordinator, container_data, node_data)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
async def _async_press_call(self) -> None:
"""Execute the container button action via executor."""
await self.hass.async_add_executor_job(
self.entity_description.press_action,
self.coordinator,
self._node_name,
self.container_data["vmid"],
)

View File

@@ -0,0 +1,18 @@
{
"entity": {
"button": {
"hibernate": {
"default": "mdi:power-sleep"
},
"reset": {
"default": "mdi:restart"
},
"start": {
"default": "mdi:play"
},
"stop": {
"default": "mdi:stop"
}
}
}
}

View File

@@ -54,15 +54,47 @@
"status": {
"name": "Status"
}
},
"button": {
"hibernate": {
"name": "Hibernate"
},
"reset": {
"name": "Reset"
},
"shutdown": {
"name": "Shutdown"
},
"start": {
"name": "Start"
},
"start_all": {
"name": "Start all"
},
"stop": {
"name": "Stop"
},
"stop_all": {
"name": "Stop all"
}
}
},
"exceptions": {
"api_error_no_details": {
"message": "An error occurred while communicating with the Proxmox VE instance."
},
"cannot_connect": {
"message": "An error occurred while trying to connect to the Proxmox VE instance: {error}"
},
"cannot_connect_no_details": {
"message": "Could not connect to the Proxmox VE instance."
},
"invalid_auth": {
"message": "An error occurred while trying to authenticate: {error}"
},
"invalid_auth_no_details": {
"message": "Authentication failed for the Proxmox VE instance."
},
"no_nodes_found": {
"message": "No active nodes were found on the Proxmox VE server."
},
@@ -71,6 +103,9 @@
},
"timeout_connect": {
"message": "A timeout occurred while trying to connect to the Proxmox VE instance: {error}"
},
"timeout_connect_no_details": {
"message": "A timeout occurred while trying to connect to the Proxmox VE instance."
}
},
"issues": {

View File

@@ -9,7 +9,7 @@ from pysmarlaapi.connection.exceptions import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import HOST, PLATFORMS
@@ -23,16 +23,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -
# Check if token still has access
try:
await connection.refresh_token()
except (ConnectionException, AuthenticationException) as e:
raise ConfigEntryError("Invalid authentication") from e
except AuthenticationException as e:
raise ConfigEntryAuthFailed("Invalid authentication") from e
except ConnectionException as e:
raise ConfigEntryNotReady("Unable to connect to server") from e
federwiege = Federwiege(hass.loop, connection)
async def on_auth_failure():
entry.async_start_reauth(hass)
federwiege = Federwiege(hass.loop, connection, on_auth_failure)
federwiege.register()
entry.runtime_data = federwiege
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Starts a task to keep reconnecting, e.g. when device gets unreachable.
# When an authentication error occurs, it automatically stops and calls
# the on_auth_failure function.
federwiege.connect()
return True

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from pysmarlaapi import Connection
@@ -11,12 +12,12 @@ from pysmarlaapi.connection.exceptions import (
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from .const import DOMAIN, HOST
STEP_USER_DATA_SCHEMA = vol.Schema({CONF_ACCESS_TOKEN: str})
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
class SmarlaConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -24,45 +25,89 @@ class SmarlaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
async def _handle_token(self, token: str) -> tuple[dict[str, str], str | None]:
"""Handle the token input."""
errors: dict[str, str] = {}
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.errors: dict[str, str] = {}
async def _handle_token(self, token: str) -> str | None:
"""Handle the token input."""
try:
conn = Connection(url=HOST, token_b64=token)
except ValueError:
errors["base"] = "malformed_token"
return errors, None
self.errors["base"] = "malformed_token"
return None
try:
await conn.refresh_token()
except ConnectionException, AuthenticationException:
errors["base"] = "invalid_auth"
return errors, None
except ConnectionException:
self.errors["base"] = "cannot_connect"
return None
except AuthenticationException:
self.errors["base"] = "invalid_auth"
return None
return errors, conn.token.serialNumber
return conn.token.serialNumber
async def _validate_input(
self, user_input: dict[str, Any]
) -> dict[str, Any] | None:
"""Validate the user input."""
token = user_input[CONF_ACCESS_TOKEN]
serial_number = await self._handle_token(token=token)
if serial_number is not None:
await self.async_set_unique_id(serial_number)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured()
return {"token": token, "serial_number": serial_number}
return None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
self.errors = {}
if user_input is not None:
raw_token = user_input[CONF_ACCESS_TOKEN]
errors, serial_number = await self._handle_token(token=raw_token)
if not errors and serial_number is not None:
await self.async_set_unique_id(serial_number)
self._abort_if_unique_id_configured()
validated_info = await self._validate_input(user_input)
if validated_info is not None:
return self.async_create_entry(
title=serial_number,
data={CONF_ACCESS_TOKEN: raw_token},
title=validated_info["serial_number"],
data={CONF_ACCESS_TOKEN: validated_info["token"]},
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
errors=self.errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
self.errors = {}
if user_input is not None:
validated_info = await self._validate_input(user_input)
if validated_info is not None:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_ACCESS_TOKEN: validated_info["token"]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_USER_DATA_SCHEMA,
errors=self.errors,
)

View File

@@ -28,7 +28,7 @@ rules:
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold

View File

@@ -1,13 +1,24 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"malformed_token": "Malformed access token"
},
"step": {
"reauth_confirm": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
},
"data_description": {
"access_token": "[%key:component::smarla::config::step::user::data_description::access_token%]"
}
},
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"

View File

@@ -585,10 +585,30 @@ def get_media(
item_id = "A:ALBUMARTIST/" + "/".join(item_id.split("/")[2:])
if item_id.startswith("A:ALBUM/") or search_type == "tracks":
search_term = urllib.parse.unquote(item_id.split("/")[-1])
# Some Sonos libraries return album ids in the shape:
# A:ALBUM/<album>/<artist>, where the artist part disambiguates results.
# Use the album segment for searching.
if item_id.startswith("A:ALBUM/"):
splits = item_id.split("/")
search_term = urllib.parse.unquote(splits[1]) if len(splits) > 1 else ""
album_title: str | None = search_term
else:
search_term = urllib.parse.unquote(item_id.split("/")[-1])
album_title = None
matches = media_library.get_music_library_information(
search_type, search_term=search_term, full_album_art_uri=True
)
if item_id.startswith("A:ALBUM/") and len(matches) > 1:
if result := next(
(item for item in matches if item_id == item.item_id), None
):
matches = [result]
elif album_title:
if result := next(
(item for item in matches if album_title == item.title), None
):
matches = [result]
elif search_type == SONOS_SHARE:
# In order to get the MusicServiceItem, we browse the parent folder
# and find one that matches on item_id.

View File

@@ -7,7 +7,7 @@
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["hass_splunk"],
"quality_scale": "legacy",
"quality_scale": "bronze",
"requirements": ["hass-splunk==0.1.4"],
"single_config_entry": true
}

View File

@@ -18,18 +18,9 @@ rules:
status: exempt
comment: |
Integration does not provide custom actions.
docs-high-level-description:
status: todo
comment: |
Verify integration docs at https://www.home-assistant.io/integrations/splunk/ include a high-level description of Splunk with a link to https://www.splunk.com/ and explain the integration's purpose for users unfamiliar with Splunk.
docs-installation-instructions:
status: todo
comment: |
Verify integration docs include clear prerequisites and step-by-step setup instructions including how to configure Splunk HTTP Event Collector and obtain the required token.
docs-removal-instructions:
status: todo
comment: |
Verify integration docs include instructions on how to remove the integration and clarify what happens to data already in Splunk.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |

View File

@@ -2,7 +2,7 @@
from abc import abstractmethod
import asyncio
from collections.abc import Callable, Sequence
from collections.abc import Awaitable, Callable, Sequence
import io
import logging
import os
@@ -430,48 +430,35 @@ class TelegramNotificationService:
params[ATTR_PARSER] = None
return params
async def _send_msgs(
async def _send_msg_formatted(
self,
func_send: Callable,
func_send: Callable[..., Awaitable[Message]],
message_tag: str | None,
*args_msg: Any,
context: Context | None = None,
**kwargs_msg: Any,
) -> dict[str, JsonValueType]:
"""Sends a message to each of the targets.
If there is only 1 targtet, an error is raised if the send fails.
For multiple targets, errors are logged and the caller is responsible for checking which target is successful/failed based on the return value.
"""Sends a message and formats the response.
:return: dict with chat_id keys and message_id values for successful sends
"""
chat_ids = [kwargs_msg.pop(ATTR_CHAT_ID)]
msg_ids: dict[str, JsonValueType] = {}
for chat_id in chat_ids:
_LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id)
chat_id: int = kwargs_msg.pop(ATTR_CHAT_ID)
_LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id)
for file_type in _FILE_TYPES:
if file_type in kwargs_msg and isinstance(
kwargs_msg[file_type], io.BytesIO
):
kwargs_msg[file_type].seek(0)
response: Message = await self._send_msg(
func_send,
message_tag,
chat_id,
*args_msg,
context=context,
**kwargs_msg,
)
response: Message = await self._send_msg(
func_send,
message_tag,
chat_id,
*args_msg,
context=context,
**kwargs_msg,
)
if response:
msg_ids[str(chat_id)] = response.id
return msg_ids
return {str(chat_id): response.id}
async def _send_msg(
self,
func_send: Callable,
func_send: Callable[..., Awaitable[Any]],
message_tag: str | None,
*args_msg: Any,
context: Context | None = None,
@@ -518,7 +505,7 @@ class TelegramNotificationService:
title = kwargs.get(ATTR_TITLE)
text = f"{title}\n{message}" if title else message
params = self._get_msg_kwargs(kwargs)
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_message,
params[ATTR_MESSAGE_TAG],
text,
@@ -759,7 +746,7 @@ class TelegramNotificationService:
)
if file_type == SERVICE_SEND_PHOTO:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_photo,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -775,7 +762,7 @@ class TelegramNotificationService:
)
if file_type == SERVICE_SEND_STICKER:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_sticker,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -789,7 +776,7 @@ class TelegramNotificationService:
)
if file_type == SERVICE_SEND_VIDEO:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_video,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -805,7 +792,7 @@ class TelegramNotificationService:
)
if file_type == SERVICE_SEND_DOCUMENT:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_document,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -821,7 +808,7 @@ class TelegramNotificationService:
)
if file_type == SERVICE_SEND_VOICE:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_voice,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -836,7 +823,7 @@ class TelegramNotificationService:
)
# SERVICE_SEND_ANIMATION
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_animation,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -861,7 +848,7 @@ class TelegramNotificationService:
stickerid = kwargs.get(ATTR_STICKER_ID)
if stickerid:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_sticker,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -886,7 +873,7 @@ class TelegramNotificationService:
latitude = float(latitude)
longitude = float(longitude)
params = self._get_msg_kwargs(kwargs)
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_location,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -911,7 +898,7 @@ class TelegramNotificationService:
"""Send a poll."""
params = self._get_msg_kwargs(kwargs)
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_poll,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],

View File

@@ -32,9 +32,9 @@ rules:
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done

View File

@@ -12,14 +12,7 @@ from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -233,18 +226,5 @@ class ViCareBinarySensor(ViCareEntity, BinarySensorEntity):
def update(self) -> None:
"""Update state of sensor."""
try:
with suppress(PyViCareNotSupportedFeatureError):
self._attr_is_on = self.entity_description.value_getter(self._api)
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError):
self._attr_is_on = self.entity_description.value_getter(self._api)

View File

@@ -8,14 +8,7 @@ import logging
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
@@ -104,18 +97,5 @@ class ViCareButton(ViCareEntity, ButtonEntity):
def press(self) -> None:
"""Handle the button press."""
try:
with suppress(PyViCareNotSupportedFeatureError):
self.entity_description.value_setter(self._api)
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError):
self.entity_description.value_setter(self._api)

View File

@@ -11,13 +11,8 @@ from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import HeatingCircuit as PyViCareHeatingCircuit
from PyViCare.PyViCareUtils import (
PyViCareCommandError,
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
import voluptuous as vol
from homeassistant.components.climate import (
@@ -160,7 +155,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity):
def update(self) -> None:
"""Let HA know there has been an update from the ViCare API."""
try:
with self.vicare_api_handler():
_room_temperature = None
with suppress(PyViCareNotSupportedFeatureError):
self._attributes["room_temperature"] = _room_temperature = (
@@ -216,19 +211,6 @@ class ViCareClimate(ViCareEntity, ClimateEntity):
self._current_action or compressor.getActive()
)
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
@property
def hvac_mode(self) -> HVACMode | None:
"""Return current hvac mode."""

View File

@@ -1,22 +1,53 @@
"""Entities for the ViCare integration."""
from collections.abc import Generator
from contextlib import contextmanager
import logging
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareRateLimitError,
)
from requests.exceptions import ConnectionError as RequestConnectionError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, VIESSMANN_DEVELOPER_PORTAL
_LOGGER = logging.getLogger(__name__)
class ViCareEntity(Entity):
"""Base class for ViCare entities."""
_attr_has_entity_name = True
@contextmanager
def vicare_api_handler(self) -> Generator[None]:
"""Handle common ViCare API errors."""
try:
yield
except RequestConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as err:
_LOGGER.error("ViCare API rate limit exceeded: %s", err)
except PyViCareInvalidDataError as err:
_LOGGER.error("Invalid data from ViCare server: %s", err)
except PyViCareDeviceCommunicationError as err:
_LOGGER.warning("Device communication error: %s", err)
except PyViCareInternalServerError as err:
_LOGGER.warning("ViCare server error: %s", err)
def __init__(
self,
unique_id_suffix: str,

View File

@@ -9,14 +9,7 @@ from typing import Any
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
from requests.exceptions import ConnectionError as RequestConnectionError
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
@@ -173,7 +166,7 @@ class ViCareFan(ViCareEntity, FanEntity):
def update(self) -> None:
"""Update state of fan."""
level: str | None = None
try:
with self.vicare_api_handler():
with suppress(PyViCareNotSupportedFeatureError):
self._attr_preset_mode = VentilationMode.from_vicare_mode(
self._api.getActiveVentilationMode()
@@ -187,18 +180,6 @@ class ViCareFan(ViCareEntity, FanEntity):
)
else:
self._attr_percentage = 0
except RequestConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
@property
def is_on(self) -> bool | None:

View File

@@ -13,14 +13,7 @@ from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
from requests.exceptions import ConnectionError as RequestConnectionError
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.number import (
NumberDeviceClass,
@@ -437,38 +430,23 @@ class ViCareNumber(ViCareEntity, NumberEntity):
def update(self) -> None:
"""Update state of number."""
try:
with suppress(PyViCareNotSupportedFeatureError):
self._attr_native_value = self.entity_description.value_getter(
self._api
)
with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError):
self._attr_native_value = self.entity_description.value_getter(self._api)
if min_value := _get_value(
self.entity_description.min_value_getter, self._api
):
self._attr_native_min_value = min_value
if min_value := _get_value(
self.entity_description.min_value_getter, self._api
):
self._attr_native_min_value = min_value
if max_value := _get_value(
self.entity_description.max_value_getter, self._api
):
self._attr_native_max_value = max_value
if max_value := _get_value(
self.entity_description.max_value_getter, self._api
):
self._attr_native_max_value = max_value
if stepping_value := _get_value(
self.entity_description.stepping_getter, self._api
):
self._attr_native_step = stepping_value
except RequestConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
if stepping_value := _get_value(
self.entity_description.stepping_getter, self._api
):
self._attr_native_step = stepping_value
def _get_value(

View File

@@ -12,14 +12,7 @@ from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import (
HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent,
)
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -1556,26 +1549,11 @@ class ViCareSensor(ViCareEntity, SensorEntity):
def update(self) -> None:
"""Update state of sensor."""
vicare_unit = None
try:
with suppress(PyViCareNotSupportedFeatureError):
self._attr_native_value = self.entity_description.value_getter(
self._api
)
with self.vicare_api_handler(), suppress(PyViCareNotSupportedFeatureError):
self._attr_native_value = self.entity_description.value_getter(self._api)
if self.entity_description.unit_getter:
vicare_unit = self.entity_description.unit_getter(self._api)
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
if self.entity_description.unit_getter:
vicare_unit = self.entity_description.unit_getter(self._api)
if vicare_unit is not None:
if (

View File

@@ -9,14 +9,7 @@ from typing import Any
from PyViCare.PyViCareDevice import Device as PyViCareDevice
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
from PyViCare.PyViCareHeatingDevice import HeatingCircuit as PyViCareHeatingCircuit
from PyViCare.PyViCareUtils import (
PyViCareDeviceCommunicationError,
PyViCareInternalServerError,
PyViCareInvalidDataError,
PyViCareNotSupportedFeatureError,
PyViCareRateLimitError,
)
import requests
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError
from homeassistant.components.water_heater import (
WaterHeaterEntity,
@@ -120,7 +113,7 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity):
def update(self) -> None:
"""Let HA know there has been an update from the ViCare API."""
try:
with self.vicare_api_handler():
with suppress(PyViCareNotSupportedFeatureError):
self._attr_current_temperature = (
self._api.getDomesticHotWaterStorageTemperature()
@@ -137,19 +130,6 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity):
with suppress(PyViCareNotSupportedFeatureError):
self._dhw_active = self._api.getDomesticHotWaterActive()
except requests.exceptions.ConnectionError:
_LOGGER.error("Unable to retrieve data from ViCare server")
except PyViCareRateLimitError as limit_exception:
_LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception)
except ValueError:
_LOGGER.error("Unable to decode data from ViCare server")
except PyViCareInvalidDataError as invalid_data_exception:
_LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception)
except PyViCareDeviceCommunicationError as comm_exception:
_LOGGER.warning("Device communication error: %s", comm_exception)
except PyViCareInternalServerError as server_exception:
_LOGGER.warning("Vicare server error: %s", server_exception)
def set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperatures."""
if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None:

View File

@@ -28,7 +28,7 @@ DEFAULT_TIMEOUT = 5
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PIN): vol.All(vol.Coerce(str), vol.Match(r"\d{4}")),
vol.Required(CONF_PIN): cv.string,
vol.Optional(CONF_ALLOW_UNREACHABLE, default=True): cv.boolean,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
}

View File

@@ -17,6 +17,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "airobot",
"hostname": "airobot-thermostat-*",
},
{
"domain": "airos",
"registered_devices": True,
},
{
"domain": "airthings",
"hostname": "airthings-view",

View File

@@ -905,7 +905,7 @@
"iot_class": "local_polling"
},
"bsblan": {
"name": "BSB-Lan",
"name": "BSB-LAN",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
@@ -5302,7 +5302,7 @@
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Powerfox"
"name": "Powerfox Cloud"
},
"powerfox_local": {
"integration_type": "device",

View File

@@ -4,6 +4,13 @@ To update, run python3 -m script.hassfest
"""
USB = [
{
"description": "*usb 300*",
"domain": "enocean",
"manufacturer": "*enocean*",
"pid": "6001",
"vid": "0403",
},
{
"description": "*zbt-2*",
"domain": "homeassistant_connect_zbt2",

View File

@@ -15,7 +15,7 @@ import base64
from collections.abc import Awaitable, Callable
import hashlib
from http import HTTPStatus
from json import JSONDecodeError
import json
import logging
import secrets
import time
@@ -248,19 +248,24 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
try:
resp = await session.post(self.token_url, data=data)
if resp.status >= 400:
error_body = ""
try:
error_response = await resp.json()
except ClientError, JSONDecodeError:
error_response = {}
error_code = error_response.get("error", "unknown")
error_description = error_response.get(
"error_description", "unknown error"
)
_LOGGER.error(
error_body = await resp.text()
error_data = json.loads(error_body)
error_code = error_data.get("error", "unknown error")
error_description = error_data.get("error_description")
detail = (
f"{error_code}: {error_description}"
if error_description
else error_code
)
except ClientError, ValueError, AttributeError:
detail = error_body[:200] if error_body else "unknown error"
_LOGGER.debug(
"Token request for %s failed (%s): %s",
self.domain,
error_code,
error_description,
resp.status,
detail,
)
resp.raise_for_status()
except ClientResponseError as err:

View File

@@ -838,6 +838,7 @@ class DurationSelectorConfig(BaseSelectorConfig, total=False):
"""Class to represent a duration selector config."""
enable_day: bool
enable_second: bool
enable_millisecond: bool
allow_negative: bool
@@ -853,6 +854,8 @@ class DurationSelector(Selector[DurationSelectorConfig]):
# Enable day field in frontend. A selection with `days` set is allowed
# even if `enable_day` is not set
vol.Optional("enable_day"): cv.boolean,
# Enable seconds field in frontend.
vol.Optional("enable_second", default=True): cv.boolean,
# Enable millisecond field in frontend.
vol.Optional("enable_millisecond"): cv.boolean,
# Allow negative durations.

8
requirements_all.txt generated
View File

@@ -1301,7 +1301,7 @@ ihcsdk==2.8.5
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
imgw_pib==2.0.1
imgw_pib==2.0.2
# homeassistant.components.incomfort
incomfort-client==0.6.12
@@ -1452,7 +1452,7 @@ loqedAPI==2.1.10
luftdaten==0.7.4
# homeassistant.components.lunatone
lunatone-rest-api-client==0.6.3
lunatone-rest-api-client==0.7.0
# homeassistant.components.lupusec
lupupy==0.3.2
@@ -2046,7 +2046,7 @@ pyebox==1.1.4
pyecoforest==0.4.0
# homeassistant.components.econet
pyeconet==0.1.28
pyeconet==0.2.1
# homeassistant.components.ista_ecotrend
pyecotrend-ista==3.4.0
@@ -3326,7 +3326,7 @@ youless-api==2.2.0
youtubeaio==2.1.1
# homeassistant.components.media_extractor
yt-dlp[default]==2026.02.04
yt-dlp[default]==2026.02.21
# homeassistant.components.zabbix
zabbix-utils==2.0.3

View File

@@ -1150,7 +1150,7 @@ igloohome-api==0.1.1
imeon_inverter_api==0.4.0
# homeassistant.components.imgw_pib
imgw_pib==2.0.1
imgw_pib==2.0.2
# homeassistant.components.incomfort
incomfort-client==0.6.12
@@ -1271,7 +1271,7 @@ loqedAPI==2.1.10
luftdaten==0.7.4
# homeassistant.components.lunatone
lunatone-rest-api-client==0.6.3
lunatone-rest-api-client==0.7.0
# homeassistant.components.lupusec
lupupy==0.3.2
@@ -1750,7 +1750,7 @@ pydroplet==2.3.4
pyecoforest==0.4.0
# homeassistant.components.econet
pyeconet==0.1.28
pyeconet==0.2.1
# homeassistant.components.ista_ecotrend
pyecotrend-ista==3.4.0
@@ -2799,7 +2799,7 @@ youless-api==2.2.0
youtubeaio==2.1.1
# homeassistant.components.media_extractor
yt-dlp[default]==2026.02.04
yt-dlp[default]==2026.02.21
# homeassistant.components.zamg
zamg==0.3.6

View File

@@ -1132,7 +1132,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"anel_pwrctrl",
"anova",
"anthemav",
"anthropic",
"aosmith",
"apache_kafka",
"apple_tv",
@@ -1895,7 +1894,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"spc",
"speedtestdotnet",
"spider",
"splunk",
"spotify",
"sql",
"srp_energy",

View File

@@ -22,7 +22,7 @@ from homeassistant.components.airos.const import (
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
)
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_RECONFIGURE, SOURCE_USER
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@@ -32,6 +32,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from tests.common import MockConfigEntry
@@ -680,3 +681,72 @@ async def test_configure_device_flow_exceptions(
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
async def test_dhcp_ip_changed_updates_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""DHCP event with new IP should update the config entry and reload."""
mock_config_entry.add_to_hass(hass)
macaddress = mock_config_entry.unique_id.lower().replace(":", "").replace("-", "")
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip="1.1.1.2",
hostname="airos",
macaddress=macaddress,
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_HOST] == "1.1.1.2"
async def test_dhcp_mac_mismatch(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""DHCP event with non-matching MAC should abort."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip="1.1.1.2",
hostname="airos",
macaddress="aabbccddeeff",
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unreachable"
async def test_dhcp_ip_unchanged(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""DHCP event with same IP should abort."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DhcpServiceInfo(
ip=mock_config_entry.data[CONF_HOST],
hostname="airos",
macaddress=mock_config_entry.unique_id.lower()
.replace(":", "")
.replace("-", ""),
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

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