Compare commits

..

65 Commits

Author SHA1 Message Date
tronikos
ed3f70bc3f Bump androidtvremote2 to 0.3.1 (#166045) 2026-03-20 08:15:04 +01:00
tronikos
008eb39c3b Bump opower to 0.17.1 (#166044) 2026-03-20 08:14:22 +01:00
Erik Montnemery
a085d91a0d Remove useless string split from triggers (#166034) 2026-03-20 07:56:55 +01:00
Logan Rosen
6395a0abd0 Reject entity/number price for external statistics in energy config (#165582)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 08:34:40 +02:00
Erwin Douna
0de2e689f1 Add pause/resume buttons to Portainer (#166028) 2026-03-19 22:35:53 +01:00
Hai-Nam Nguyen
21d06fdace Fix unit when plant power is above 1000W in Hypontech (#165959)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-19 21:52:59 +01:00
AlCalzone
c8cf13ba19 Do not use moving states for Multilevel Switch CC v1-3 Z-Wave covers (#165909) 2026-03-19 21:30:26 +01:00
johanzander
d9a29bd486 growatt_server: add translation keys to all raised exceptions (#165927)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-03-19 21:08:15 +01:00
Norbert Rittel
bd0145cb8d Fix spelling of "Wi-Fi" trademark in user-facing string of sfr_box (#166019) 2026-03-19 20:43:16 +01:00
wollew
d002b48335 Replace deprecated library call in Velux integration (#165996)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-19 19:29:35 +01:00
Norbert Rittel
c66daf13d3 Fix spelling of "Wi-Fi" in user-facing strings of shelly (#166017) 2026-03-19 19:17:23 +01:00
Christian Lackas
1cae0e3cd3 Bump homematicip to 2.7.0 (#166012) 2026-03-19 17:53:12 +00:00
Paul Tarjan
de93d1d52a Skip unmapped and watchdog event types in Hikvision NVR event injection (#165009)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 18:39:28 +01:00
Tucker Kern
c67438c515 Snapcast: Fix incorrect identifier extraction in async_join_players (#165020) 2026-03-19 18:36:42 +01:00
Linkplay2020
fa57f72f37 Add WiiM media player integration (#148948)
Co-authored-by: Tao Jiang <tao.jiang@linkplay.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-19 18:33:53 +01:00
Tom Matheussen
29309d1315 Add reconfigure flow to Satel Integra (#164938)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-19 18:31:46 +01:00
Robin Lintermann
130e0db742 Change codeowner of smarla integration (#166015) 2026-03-19 18:30:24 +01:00
Willem-Jan van Rootselaar
450d46f652 Fix optional static values in bsblan (#165488) 2026-03-19 17:07:49 +00:00
DeerMaximum
625603839c Remove DeerMaximum from velux codeowners (#166014) 2026-03-19 17:39:55 +01:00
Michael Hansen
fb66d766a8 Ensure STT metadata enums are passed (#165220) 2026-03-19 17:38:43 +01:00
Paul Bottein
e5f13b4126 Add state_attr_translated template filter and function (#165317)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-19 17:21:43 +01:00
Raj Laud
4a22f2c93e Add reauth flow and auto-trigger to victron_ble integration (#165729)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-19 17:01:04 +01:00
Mike Degatano
a5c48b190a Remove get_issues_info from hassio __all__ (#165929) 2026-03-19 16:58:20 +01:00
epenet
5e1a0e2152 Use annotationlib.get_annotations in entity helper (#165331) 2026-03-19 15:27:42 +01:00
Hai-Nam Nguyen
9a5516bb1d Bump hyponcloud from 0.3.0 to 0.9.0 (#166005) 2026-03-19 15:25:44 +01:00
J. Diego Rodríguez Royo
b9172cf4a8 Add 3D heating, air fry, and grill programs to Home Connect (#166006) 2026-03-19 15:21:20 +01:00
Ariel Ebersberger
8e4dc29226 Fix backblaze_b2 tests for Python 3.14.3 (#165930) 2026-03-19 14:01:27 +01:00
epenet
b152f2f9a6 Add test fixture for Tuya WiFi smart online 8 in 1 tester (#166003) 2026-03-19 13:27:42 +01:00
epenet
abca80dc13 Simplify mocking of Tuya device notifications (#165998) 2026-03-19 13:24:10 +01:00
Ville Skyttä
6869369ab2 Add some EZVIZ sensor icons (#166000) 2026-03-19 13:23:33 +01:00
Brett Adams
c2dde06713 Fix mixed-language Splunk setup errors in exception translations (#165974) 2026-03-19 13:21:45 +01:00
Retha Runolfsson
e455c05721 Added exception handling when switchbot account login. (#165978) 2026-03-19 13:15:45 +01:00
Ariel Ebersberger
085df1de19 Fix Home Asssitant Cloud test for Python 3.14.3 (#165937) 2026-03-19 12:11:26 +00:00
J. Diego Rodríguez Royo
91a1237965 Bump aiohomeconnect to 0.33.0 (#166001) 2026-03-19 13:07:57 +01:00
Raj Laud
680a6bc856 Add sensor tests for missing victron_ble device types (#165498)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-19 12:06:14 +01:00
dependabot[bot]
152912c258 Bump actions/download-artifact from 8.0.0 to 8.0.1 (#165982)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 11:20:30 +01:00
wollew
40e8a1b11a Bump pyvlx to 0.2.32 (#165990) 2026-03-19 11:14:57 +01:00
johanzander
69dc354669 growatt_server: add diagnostics support (#165923)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-19 11:09:25 +01:00
Christopher Fenner
bbe1bf14ae Bump PyViCare to 2.58.1 (#165965) 2026-03-19 10:19:48 +01:00
Joost Lekkerkerker
5470d8f8a7 Add switch for microfilter bypass mode to SmartThings (#165919) 2026-03-19 09:29:48 +01:00
Joost Lekkerkerker
99fe4b10d0 Add sensors for microfilter to SmartThings (#165922) 2026-03-19 08:57:51 +01:00
Brett Adams
886b6b08ac Source Tessie phantom drain and battery sensors from state data (#165970) 2026-03-19 08:24:32 +01:00
Robert Svensson
6a1e7c1cca Switch over to aiohttp on the Axis integration (#165963) 2026-03-19 08:23:06 +01:00
Josef Zweck
d17df13055 Manually update values instead of sending an event in mold_indicator (#165891) 2026-03-19 08:17:07 +01:00
J. Nick Koston
f73502c77a Bump ulid-transform to 2.2.0 (#165964) 2026-03-18 23:15:26 +01:00
Dan Raper
2c37a86bc9 Bump ohme to 1.7.1 (#165951) 2026-03-18 21:47:48 +00:00
tronikos
fa8e976de7 Add exception translations to Google Weather (#165935)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 13:25:58 -07:00
Andres Ruiz
877bca28ad Stop manually assigning an entity_id in waterfurnace sensors (#165954) 2026-03-18 20:58:36 +01:00
tronikos
a57c65f512 Add reconfigure flow in Google Drive (#165926) 2026-03-18 12:46:43 -07:00
tronikos
7140826dbb Do not abbreviate "reauthentication" in Google Drive (#165941) 2026-03-18 20:38:49 +01:00
Bouwe Westerdijk
5fea8d69d7 Add live firmware update detection to Plugwise (#165936)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-18 20:37:57 +01:00
Paul Tarjan
98e3b9962e Log Withings webhook URL warning only once (#164551)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 20:21:38 +01:00
Kurt Chrisford
afe19147f8 Test coverage for the Actron Air integration (#164446) 2026-03-18 20:20:51 +01:00
Willem-Jan van Rootselaar
0e7c25488c Add reconfigure flow to BSB-LAN (#164070) 2026-03-18 20:19:50 +01:00
Jan Čermák
412e85203d Add issue and repair for NTP sync failure (#165463)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-03-18 20:16:46 +01:00
Abílio Costa
55ec4a95fd Update renault snapshots (#165948) 2026-03-18 19:59:39 +01:00
Artur Pragacz
6ea9e9a161 Remove targets from intent response (#165434)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-18 18:35:30 +00:00
tronikos
b56e6d1ff7 Update Google Drive quality scale rules to match #156167 (#165916) 2026-03-18 19:34:55 +01:00
Eduardo Tsen
b502cdd15b Add buttons for controlling dishwasher operation (#160269)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-18 19:32:58 +01:00
Mike Ryan
b7ba85192d Add Trigger Motion Activity button to fully kiosk browser (#164499)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-18 14:24:51 -04:00
Erik Montnemery
04d45c8ada Add schedule conditions (#165913)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-18 18:55:47 +01:00
tronikos
ba0804fefa Add exception translations to Google Drive (#165932) 2026-03-18 18:51:07 +01:00
Erik Montnemery
538b817bf1 Adjust inheritance tree of EntitySelectorConfig (#165915) 2026-03-18 18:32:44 +01:00
Brandon Rothweiler
7efa2d3cac Add Dropbox backup integration (#155644) 2026-03-18 17:58:57 +01:00
Erik Montnemery
3f872fd196 Allow specifying attribute in state selector (#165928) 2026-03-18 17:54:36 +01:00
286 changed files with 12699 additions and 2220 deletions

View File

@@ -182,7 +182,7 @@ jobs:
fi
- name: Download translations
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
@@ -490,7 +490,7 @@ jobs:
python-version-file: ".python-version"
- name: Download translations
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations

View File

@@ -978,7 +978,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: pytest_buckets
- name: Compile English translations
@@ -1387,7 +1387,7 @@ jobs:
with:
persist-credentials: false
- name: Download all coverage artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1558,7 +1558,7 @@ jobs:
with:
persist-credentials: false
- name: Download all coverage artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1587,7 +1587,7 @@ jobs:
&& needs.info.outputs.skip_coverage != 'true' && !cancelled()
steps:
- name: Download all coverage artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: test-results-*
- name: Upload test results to Codecov

View File

@@ -121,12 +121,12 @@ jobs:
persist-credentials: false
- name: Download env_file
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: env_file
- name: Download requirements_diff
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: requirements_diff
@@ -172,17 +172,17 @@ jobs:
persist-credentials: false
- name: Download env_file
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: env_file
- name: Download requirements_diff
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: requirements_diff
- name: Download requirements_all_wheels
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: requirements_all_wheels

View File

@@ -173,6 +173,7 @@ homeassistant.components.dnsip.*
homeassistant.components.doorbird.*
homeassistant.components.dormakaba_dkey.*
homeassistant.components.downloader.*
homeassistant.components.dropbox.*
homeassistant.components.droplet.*
homeassistant.components.dsmr.*
homeassistant.components.duckdns.*

12
CODEOWNERS generated
View File

@@ -397,6 +397,8 @@ build.json @home-assistant/supervisor
/tests/components/dremel_3d_printer/ @tkdrob
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
/homeassistant/components/dropbox/ @bdr99
/tests/components/dropbox/ @bdr99
/homeassistant/components/droplet/ @sarahseidman
/tests/components/droplet/ @sarahseidman
/homeassistant/components/dsmr/ @Robbie1221
@@ -1561,8 +1563,8 @@ build.json @home-assistant/supervisor
/tests/components/sma/ @kellerza @rklomp @erwindouna
/homeassistant/components/smappee/ @bsmappee
/tests/components/smappee/ @bsmappee
/homeassistant/components/smarla/ @explicatis @rlint-explicatis
/tests/components/smarla/ @explicatis @rlint-explicatis
/homeassistant/components/smarla/ @explicatis @johannes-exp
/tests/components/smarla/ @explicatis @johannes-exp
/homeassistant/components/smart_meter_texas/ @grahamwetzler
/tests/components/smart_meter_texas/ @grahamwetzler
/homeassistant/components/smartthings/ @joostlek
@@ -1829,8 +1831,8 @@ build.json @home-assistant/supervisor
/tests/components/vegehub/ @thulrus
/homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/components/velbus/ @Cereal2nd @brefra
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
/homeassistant/components/velux/ @Julius2342 @pawlizio @wollew
/tests/components/velux/ @Julius2342 @pawlizio @wollew
/homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/versasense/ @imstevenxyz
@@ -1913,6 +1915,8 @@ build.json @home-assistant/supervisor
/tests/components/whois/ @frenck
/homeassistant/components/wiffi/ @mampfes
/tests/components/wiffi/ @mampfes
/homeassistant/components/wiim/ @Linkplay2020
/tests/components/wiim/ @Linkplay2020
/homeassistant/components/wilight/ @leofig-rj
/tests/components/wilight/ @leofig-rj
/homeassistant/components/window/ @home-assistant/core

View File

@@ -120,7 +120,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="timeout",
)
del self.login_task
self.login_task = None
return await self.async_step_user()
async def async_step_reauth(

View File

@@ -12,6 +12,6 @@
"documentation": "https://www.home-assistant.io/integrations/actron_air",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.4.1"]
}

View File

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

View File

@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
"quality_scale": "platinum",
"requirements": ["androidtvremote2==0.2.3"],
"requirements": ["androidtvremote2==0.3.1"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}

View File

@@ -135,6 +135,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"motion",
"occupancy",
"person",
"schedule",
"siren",
"switch",
"vacuum",

View File

@@ -15,7 +15,7 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from ..const import LOGGER
from ..errors import AuthenticationRequired, CannotConnect
@@ -26,7 +26,7 @@ async def get_axis_api(
config: Mapping[str, Any],
) -> axis.AxisDevice:
"""Create a Axis device API."""
session = get_async_client(hass, verify_ssl=False)
session = async_get_clientsession(hass, verify_ssl=False)
api = axis.AxisDevice(
Configuration(

View File

@@ -32,7 +32,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_PASSKEY, DOMAIN
from .const import CONF_PASSKEY, DOMAIN, LOGGER
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
@@ -52,7 +52,7 @@ class BSBLanData:
client: BSBLAN
device: Device
info: Info
static: StaticState
static: StaticState | None
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -82,11 +82,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# the connection by fetching firmware version
await bsblan.initialize()
# Fetch device metadata in parallel for faster startup
device, info, static = await asyncio.gather(
# Fetch required device metadata in parallel for faster startup
device, info = await asyncio.gather(
bsblan.device(),
bsblan.info(),
bsblan.static_values(),
)
except BSBLANConnectionError as err:
raise ConfigEntryNotReady(
@@ -111,6 +110,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
translation_key="setup_general_error",
) from err
try:
static = await bsblan.static_values()
except (BSBLANError, TimeoutError) as err:
LOGGER.debug(
"Static values not available for %s: %s",
entry.data[CONF_HOST],
err,
)
static = None
# Create coordinators with the already-initialized client
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)

View File

@@ -90,10 +90,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
# Set temperature range if available, otherwise use Home Assistant defaults
if data.static.min_temp is not None and data.static.min_temp.value is not None:
self._attr_min_temp = data.static.min_temp.value
if data.static.max_temp is not None and data.static.max_temp.value is not None:
self._attr_max_temp = data.static.max_temp.value
if (static := data.static) is not None:
if (min_temp := static.min_temp) is not None and min_temp.value is not None:
self._attr_min_temp = min_temp.value
if (max_temp := static.max_temp) is not None and max_temp.value is not None:
self._attr_max_temp = max_temp.value
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@property

View File

@@ -183,90 +183,122 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
existing_entry = self._get_reauth_entry()
if user_input is None:
# Preserve existing values as defaults
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=existing_entry.data.get(
CONF_PASSKEY, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_USERNAME,
default=existing_entry.data.get(
CONF_USERNAME, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
data_schema=self._build_credentials_schema(existing_entry.data),
)
# Combine existing data with the user's new input for validation.
# This correctly handles adding, changing, and clearing credentials.
config_data = existing_entry.data.copy()
config_data.update(user_input)
# Merge existing data with user input for validation
validate_data = {**existing_entry.data, **user_input}
errors = await self._async_validate_credentials(validate_data)
self.host = config_data[CONF_HOST]
self.port = config_data[CONF_PORT]
self.passkey = config_data.get(CONF_PASSKEY)
self.username = config_data.get(CONF_USERNAME)
self.password = config_data.get(CONF_PASSWORD)
if errors:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self._build_credentials_schema(user_input),
errors=errors,
)
return self.async_update_reload_and_abort(
existing_entry, data_updates=user_input, reason="reauth_successful"
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration flow."""
existing_entry = self._get_reconfigure_entry()
if user_input is None:
return self.async_show_form(
step_id="reconfigure",
data_schema=self._build_connection_schema(existing_entry.data),
)
# Merge existing data with user input for validation
validate_data = {**existing_entry.data, **user_input}
errors = await self._async_validate_credentials(validate_data)
if errors:
return self.async_show_form(
step_id="reconfigure",
data_schema=self._build_connection_schema(user_input),
errors=errors,
)
# Prevent reconfiguring to a different physical device
# it gets the unique ID from the device info when it validates credentials
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
existing_entry,
data_updates=user_input,
reason="reconfigure_successful",
)
async def _async_validate_credentials(self, data: dict[str, Any]) -> dict[str, str]:
"""Validate connection credentials and return errors dict."""
self.host = data[CONF_HOST]
self.port = data.get(CONF_PORT, DEFAULT_PORT)
self.passkey = data.get(CONF_PASSKEY)
self.username = data.get(CONF_USERNAME)
self.password = data.get(CONF_PASSWORD)
errors: dict[str, str] = {}
try:
await self._get_bsblan_info(raise_on_progress=False, is_reauth=True)
except BSBLANAuthError:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "invalid_auth"},
)
errors["base"] = "invalid_auth"
except BSBLANError:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "cannot_connect"},
)
errors["base"] = "cannot_connect"
return errors
# Update only the fields that were provided by the user
return self.async_update_reload_and_abort(
existing_entry, data_updates=user_input, reason="reauth_successful"
@callback
def _build_credentials_schema(self, defaults: Mapping[str, Any]) -> vol.Schema:
"""Build schema for credentials-only forms (reauth)."""
return vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED,
): str,
vol.Optional(
CONF_USERNAME,
default=defaults.get(CONF_USERNAME) or vol.UNDEFINED,
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
)
@callback
def _build_connection_schema(self, defaults: Mapping[str, Any]) -> vol.Schema:
"""Build schema for full connection forms (user and reconfigure)."""
return vol.Schema(
{
vol.Required(
CONF_HOST,
default=defaults.get(CONF_HOST, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PORT,
default=defaults.get(CONF_PORT, DEFAULT_PORT),
): int,
vol.Optional(
CONF_PASSKEY,
default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED,
): str,
vol.Optional(
CONF_USERNAME,
default=defaults.get(CONF_USERNAME) or vol.UNDEFINED,
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
)
@callback
@@ -274,32 +306,9 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self, errors: dict | None = None, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the setup form to the user."""
# Preserve user input if provided, otherwise use defaults
defaults = user_input or {}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED)
): str,
vol.Optional(
CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)
): int,
vol.Optional(
CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED)
): str,
vol.Optional(
CONF_USERNAME,
default=defaults.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=defaults.get(CONF_PASSWORD, vol.UNDEFINED),
): str,
}
),
data_schema=self._build_connection_schema(user_input or {}),
errors=errors or {},
)

View File

@@ -24,7 +24,7 @@ async def async_get_config_entry_diagnostics(
"sensor": data.fast_coordinator.data.sensor.model_dump(),
"dhw": data.fast_coordinator.data.dhw.model_dump(),
},
"static": data.static.model_dump(),
"static": data.static.model_dump() if data.static is not None else None,
}
# Add DHW config and schedule from slow coordinator if available

View File

@@ -58,7 +58,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |

View File

@@ -3,7 +3,9 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device you are trying to reconfigure is not the same as the one previously configured."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -39,6 +41,24 @@
"description": "The BSB-LAN integration needs to re-authenticate with {name}",
"title": "[%key:common::config_flow::title::reauth%]"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "[%key:component::bsblan::config::step::user::data_description::host%]",
"passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]",
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
"port": "[%key:component::bsblan::config::step::user::data_description::port%]",
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
},
"description": "Update connection settings for your BSB-LAN device.",
"title": "Reconfigure BSB-LAN"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",

View File

@@ -53,7 +53,6 @@ from .const import ( # noqa: F401
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TARGET_TEMP_STEP,
ATTR_TEMPERATURE_UNIT,
DOMAIN,
FAN_AUTO,
FAN_DIFFUSE,
@@ -358,7 +357,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
ATTR_CURRENT_TEMPERATURE: show_temp(
hass, self.current_temperature, temperature_unit, precision
),
ATTR_TEMPERATURE_UNIT: hass.config.units.temperature_unit,
}
if ClimateEntityFeature.TARGET_TEMPERATURE in supported_features:

View File

@@ -118,8 +118,6 @@ ATTR_TARGET_HUMIDITY_STEP = "target_humidity_step"
ATTR_TARGET_TEMP_HIGH = "target_temp_high"
ATTR_TARGET_TEMP_LOW = "target_temp_low"
ATTR_TARGET_TEMP_STEP = "target_temp_step"
ATTR_TEMPERATURE_UNIT = "temperature_unit"
DEFAULT_MIN_TEMP = 7
DEFAULT_MAX_TEMP = 35

View File

@@ -1,7 +1,7 @@
"""Provides triggers for covers."""
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
@@ -13,14 +13,14 @@ class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
def _get_value(self, state: State) -> str | bool | None:
"""Extract the relevant value from state based on domain spec."""
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is not None:
return state.attributes.get(domain_spec.value_source)
return state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the state matches the target cover state."""
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
domain_spec = self._domain_specs[state.domain]
return self._get_value(state) == domain_spec.target_value
def is_valid_transition(self, from_state: State, to_state: State) -> bool:

View File

@@ -0,0 +1,64 @@
"""The Dropbox integration."""
from __future__ import annotations
from python_dropbox_api import (
DropboxAPIClient,
DropboxAuthException,
DropboxUnknownException,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from .auth import DropboxConfigEntryAuth
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
type DropboxConfigEntry = ConfigEntry[DropboxAPIClient]
async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
"""Set up Dropbox from a config entry."""
try:
oauth2_implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
oauth2_session = OAuth2Session(hass, entry, oauth2_implementation)
auth = DropboxConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass), oauth2_session
)
client = DropboxAPIClient(auth)
try:
await client.get_account_info()
except DropboxAuthException as err:
raise ConfigEntryAuthFailed from err
except (DropboxUnknownException, TimeoutError) as err:
raise ConfigEntryNotReady from err
entry.runtime_data = client
def async_notify_backup_listeners() -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
return True
async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
"""Unload a config entry."""
return True

View File

@@ -0,0 +1,38 @@
"""Application credentials platform for the Dropbox integration."""
from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
AbstractOAuth2Implementation,
LocalOAuth2ImplementationWithPkce,
)
from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> AbstractOAuth2Implementation:
"""Return custom auth implementation."""
return DropboxOAuth2Implementation(
hass,
auth_domain,
credential.client_id,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
credential.client_secret,
)
class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
"""Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters."""
@property
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
data: dict = {
"token_access_type": "offline",
"scope": " ".join(OAUTH2_SCOPES),
}
data.update(super().extra_authorize_data)
return data

View File

@@ -0,0 +1,44 @@
"""Authentication for Dropbox."""
from typing import cast
from aiohttp import ClientSession
from python_dropbox_api import Auth
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
class DropboxConfigEntryAuth(Auth):
"""Provide Dropbox authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: OAuth2Session,
) -> None:
"""Initialize DropboxConfigEntryAuth."""
super().__init__(websession)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])
class DropboxConfigFlowAuth(Auth):
"""Provide authentication tied to a fixed token for the config flow."""
def __init__(
self,
websession: ClientSession,
token: str,
) -> None:
"""Initialize DropboxConfigFlowAuth."""
super().__init__(websession)
self._token = token
async def async_get_access_token(self) -> str:
"""Return the fixed access token."""
return self._token

View File

@@ -0,0 +1,230 @@
"""Backup platform for the Dropbox integration."""
from collections.abc import AsyncIterator, Callable, Coroutine
from functools import wraps
import json
import logging
from typing import Any, Concatenate
from python_dropbox_api import (
DropboxAPIClient,
DropboxAuthException,
DropboxFileOrFolderNotFoundException,
DropboxUnknownException,
)
from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupAgentError,
BackupNotFound,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
from . import DropboxConfigEntry
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
_LOGGER = logging.getLogger(__name__)
def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
"""Return the suggested filenames for the backup and metadata."""
base_name = suggested_filename(backup).rsplit(".", 1)[0]
return f"{base_name}.tar", f"{base_name}.metadata.json"
async def _async_string_iterator(content: str) -> AsyncIterator[bytes]:
"""Yield a string as a single bytes chunk."""
yield content.encode()
def handle_backup_errors[_R, **P](
func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]:
"""Handle backup errors."""
@wraps(func)
async def wrapper(
self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs
) -> _R:
try:
return await func(self, *args, **kwargs)
except DropboxFileOrFolderNotFoundException as err:
raise BackupNotFound(
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
) from err
except DropboxAuthException as err:
self._entry.async_start_reauth(self._hass)
raise BackupAgentError("Authentication error") from err
except DropboxUnknownException as err:
_LOGGER.error(
"Error during %s: %s",
func.__name__,
err,
)
_LOGGER.debug("Full error: %s", err, exc_info=True)
raise BackupAgentError(
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
) from err
return wrapper
async def async_get_backup_agents(
hass: HomeAssistant,
**kwargs: Any,
) -> list[BackupAgent]:
"""Return a list of backup agents."""
entries = hass.config_entries.async_loaded_entries(DOMAIN)
return [DropboxBackupAgent(hass, entry) for entry in entries]
@callback
def async_register_backup_agents_listener(
hass: HomeAssistant,
*,
listener: Callable[[], None],
**kwargs: Any,
) -> Callable[[], None]:
"""Register a listener to be called when agents are added or removed.
:return: A function to unregister the listener.
"""
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
@callback
def remove_listener() -> None:
"""Remove the listener."""
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
del hass.data[DATA_BACKUP_AGENT_LISTENERS]
return remove_listener
class DropboxBackupAgent(BackupAgent):
"""Backup agent for the Dropbox integration."""
domain = DOMAIN
def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None:
"""Initialize the backup agent."""
super().__init__()
self._hass = hass
self._entry = entry
self.name = entry.title
assert entry.unique_id
self.unique_id = entry.unique_id
self._api: DropboxAPIClient = entry.runtime_data
async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]:
"""Get backups and their corresponding file names."""
files = await self._api.list_folder("")
tar_files = {f.name for f in files if f.name.endswith(".tar")}
metadata_files = [f for f in files if f.name.endswith(".metadata.json")]
backups: list[tuple[AgentBackup, str]] = []
for metadata_file in metadata_files:
tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar"
if tar_name not in tar_files:
_LOGGER.warning(
"Found metadata file '%s' without matching backup file",
metadata_file.name,
)
continue
metadata_stream = self._api.download_file(f"/{metadata_file.name}")
raw = b"".join([chunk async for chunk in metadata_stream])
try:
data = json.loads(raw)
backup = AgentBackup.from_dict(data)
except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err:
_LOGGER.warning(
"Skipping invalid metadata file '%s': %s",
metadata_file.name,
err,
)
continue
backups.append((backup, tar_name))
return backups
@handle_backup_errors
async def async_upload_backup(
self,
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
**kwargs: Any,
) -> None:
"""Upload a backup."""
backup_filename, metadata_filename = _suggested_filenames(backup)
backup_path = f"/{backup_filename}"
metadata_path = f"/{metadata_filename}"
file_stream = await open_stream()
await self._api.upload_file(backup_path, file_stream)
metadata_stream = _async_string_iterator(json.dumps(backup.as_dict()))
try:
await self._api.upload_file(metadata_path, metadata_stream)
except (
DropboxAuthException,
DropboxUnknownException,
):
await self._api.delete_file(backup_path)
raise
@handle_backup_errors
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
return [backup for backup, _ in await self._async_get_backups()]
@handle_backup_errors
async def async_download_backup(
self,
backup_id: str,
**kwargs: Any,
) -> AsyncIterator[bytes]:
"""Download a backup file."""
backups = await self._async_get_backups()
for backup, filename in backups:
if backup.backup_id == backup_id:
return self._api.download_file(f"/{filename}")
raise BackupNotFound(f"Backup {backup_id} not found")
@handle_backup_errors
async def async_get_backup(
self,
backup_id: str,
**kwargs: Any,
) -> AgentBackup:
"""Return a backup."""
backups = await self._async_get_backups()
for backup, _ in backups:
if backup.backup_id == backup_id:
return backup
raise BackupNotFound(f"Backup {backup_id} not found")
@handle_backup_errors
async def async_delete_backup(
self,
backup_id: str,
**kwargs: Any,
) -> None:
"""Delete a backup file."""
backups = await self._async_get_backups()
for backup, tar_filename in backups:
if backup.backup_id == backup_id:
metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json"
await self._api.delete_file(f"/{tar_filename}")
await self._api.delete_file(f"/{metadata_filename}")
return
raise BackupNotFound(f"Backup {backup_id} not found")

View File

@@ -0,0 +1,60 @@
"""Config flow for Dropbox."""
from collections.abc import Mapping
import logging
from typing import Any
from python_dropbox_api import DropboxAPIClient
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .auth import DropboxConfigFlowAuth
from .const import DOMAIN
class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle Dropbox OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow, or update existing entry."""
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token)
client = DropboxAPIClient(auth)
account_info = await client.get_account_info()
await self.async_set_unique_id(account_info.account_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=account_info.email, data=data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth 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:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()

View File

@@ -0,0 +1,19 @@
"""Constants for the Dropbox integration."""
from collections.abc import Callable
from homeassistant.util.hass_dict import HassKey
DOMAIN = "dropbox"
OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize"
OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token"
OAUTH2_SCOPES = [
"account_info.read",
"files.content.read",
"files.content.write",
]
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)

View File

@@ -0,0 +1,13 @@
{
"domain": "dropbox",
"name": "Dropbox",
"after_dependencies": ["backup"],
"codeowners": ["@bdr99"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/dropbox",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["python-dropbox-api==0.1.3"]
}

View File

@@ -0,0 +1,112 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register any actions.
appropriate-polling:
status: exempt
comment: Integration does not poll.
brands: done
common-modules:
status: exempt
comment: Integration does not have any entities or coordinators.
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register any actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not have any entities.
entity-unique-id:
status: exempt
comment: Integration does not have any entities.
has-entity-name:
status: exempt
comment: Integration does not have any entities.
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register any actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not have any configuration parameters.
docs-installation-parameters: done
entity-unavailable:
status: exempt
comment: Integration does not have any entities.
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: exempt
comment: Integration does not make any entity updates.
reauthentication-flow: done
test-coverage: done
# Gold
devices:
status: exempt
comment: Integration does not have any entities.
diagnostics:
status: exempt
comment: Integration does not have any data to diagnose.
discovery-update-info:
status: exempt
comment: Integration is a service.
discovery:
status: exempt
comment: Integration is a service.
docs-data-update:
status: exempt
comment: Integration does not update any data.
docs-examples:
status: exempt
comment: Integration only provides backup functionality.
docs-known-limitations: todo
docs-supported-devices:
status: exempt
comment: Integration does not support any devices.
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Integration does not use any devices.
entity-category:
status: exempt
comment: Integration does not have any entities.
entity-device-class:
status: exempt
comment: Integration does not have any entities.
entity-disabled-by-default:
status: exempt
comment: Integration does not have any entities.
entity-translations:
status: exempt
comment: Integration does not have any entities.
exception-translations: todo
icon-translations:
status: exempt
comment: Integration does not have any entities.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: Integration does not have any repairs.
stale-devices:
status: exempt
comment: Integration does not have any devices.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,35 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"wrong_account": "Wrong account: Please authenticate with the correct account."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"description": "The Dropbox integration needs to re-authenticate your account.",
"title": "[%key:common::config_flow::title::reauth%]"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -9,7 +9,7 @@ from typing import Any, Literal, NotRequired, TypedDict
import voluptuous as vol
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback, valid_entity_id
from homeassistant.helpers import config_validation as cv, singleton, storage
from .const import DOMAIN
@@ -244,6 +244,38 @@ class EnergyPreferencesUpdate(EnergyPreferences, total=False):
"""all types optional."""
def _reject_price_for_external_stat(
*,
stat_key: str,
entity_price_key: str = "entity_energy_price",
number_price_key: str = "number_energy_price",
cost_stat_key: str = "stat_cost",
) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Return a validator that rejects entity/number price for external statistics.
Only rejects when the cost/compensation stat is not already set, since
price fields are ignored when a cost stat is provided.
"""
def validate(val: dict[str, Any]) -> dict[str, Any]:
stat_id = val.get(stat_key)
if stat_id is not None and not valid_entity_id(stat_id):
if val.get(cost_stat_key) is not None:
# Cost stat is already set; price fields are ignored, so allow.
return val
if (
val.get(entity_price_key) is not None
or val.get(number_price_key) is not None
):
raise vol.Invalid(
"Entity or number price is not supported for external"
f" statistics. Use {cost_stat_key} instead"
)
return val
return validate
def _flow_from_ensure_single_price(
val: FlowFromGridSourceType,
) -> FlowFromGridSourceType:
@@ -268,19 +300,25 @@ FLOW_FROM_GRID_SOURCE_SCHEMA = vol.All(
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
),
_reject_price_for_external_stat(stat_key="stat_energy_from"),
_flow_from_ensure_single_price,
)
FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("stat_energy_to"): str,
vol.Optional("stat_compensation"): vol.Any(str, None),
# entity_energy_to was removed in HA Core 2022.10
vol.Remove("entity_energy_to"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
FLOW_TO_GRID_SOURCE_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("stat_energy_to"): str,
vol.Optional("stat_compensation"): vol.Any(str, None),
# entity_energy_to was removed in HA Core 2022.10
vol.Remove("entity_energy_to"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
),
_reject_price_for_external_stat(
stat_key="stat_energy_to", cost_stat_key="stat_compensation"
),
)
@@ -419,6 +457,13 @@ GRID_SOURCE_SCHEMA = vol.All(
vol.Required("cost_adjustment_day"): vol.Coerce(float),
}
),
_reject_price_for_external_stat(stat_key="stat_energy_from"),
_reject_price_for_external_stat(
stat_key="stat_energy_to",
entity_price_key="entity_energy_price_export",
number_price_key="number_energy_price_export",
cost_stat_key="stat_compensation",
),
_grid_ensure_single_price_import,
_grid_ensure_single_price_export,
_grid_ensure_at_least_one_stat,
@@ -442,27 +487,35 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
}
)
GAS_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "gas",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
# entity_energy_from was removed in HA Core 2022.10
vol.Remove("entity_energy_from"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
GAS_SOURCE_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("type"): "gas",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
# entity_energy_from was removed in HA Core 2022.10
vol.Remove("entity_energy_from"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
),
_reject_price_for_external_stat(stat_key="stat_energy_from"),
)
WATER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "water",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
WATER_SOURCE_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("type"): "water",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
),
_reject_price_for_external_stat(stat_key="stat_energy_from"),
)

View File

@@ -23,6 +23,23 @@
"alarm_sound_mode": {
"default": "mdi:alarm"
}
},
"sensor": {
"alarm_sound_mode": {
"default": "mdi:alarm"
},
"last_alarm_type_code": {
"default": "mdi:alarm"
},
"last_alarm_type_name": {
"default": "mdi:alarm"
},
"local_ip": {
"default": "mdi:ip"
},
"wan_ip": {
"default": "mdi:ip"
}
}
},
"services": {

View File

@@ -27,6 +27,7 @@ class FullyButtonEntityDescription(ButtonEntityDescription):
"""Fully Kiosk Browser button description."""
press_action: Callable[[FullyKiosk], Any]
refresh_after_press: bool = True
BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
@@ -68,6 +69,13 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
press_action=lambda fully: fully.clearCache(),
),
FullyButtonEntityDescription(
key="triggerMotion",
translation_key="trigger_motion",
entity_category=EntityCategory.CONFIG,
press_action=lambda fully: fully.triggerMotion(),
refresh_after_press=False,
),
)
@@ -102,4 +110,5 @@ class FullyButtonEntity(FullyKioskEntity, ButtonEntity):
async def async_press(self) -> None:
"""Set the value of the entity."""
await self.entity_description.press_action(self.coordinator.fully)
await self.coordinator.async_refresh()
if self.entity_description.refresh_after_press:
await self.coordinator.async_refresh()

View File

@@ -88,6 +88,9 @@
},
"to_foreground": {
"name": "Bring to foreground"
},
"trigger_motion": {
"name": "Trigger motion activity"
}
},
"image": {

View File

@@ -12,6 +12,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import instance_id
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
@@ -30,11 +31,17 @@ _PLATFORMS = (Platform.SENSOR,)
async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool:
"""Set up Google Drive from a config entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
auth = AsyncConfigEntryAuth(
async_get_clientsession(hass),
OAuth2Session(
hass, entry, await async_get_config_entry_implementation(hass, entry)
),
OAuth2Session(hass, entry, implementation),
)
# Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not
@@ -46,7 +53,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry)
try:
folder_id, _ = await client.async_create_ha_root_folder_if_not_exists()
except GoogleDriveApiError as err:
raise ConfigEntryNotReady from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": "Home Assistant"},
) from err
def async_notify_backup_listeners() -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):

View File

@@ -22,6 +22,8 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600
_UPLOAD_MAX_RETRIES = 20
@@ -61,14 +63,21 @@ class AsyncConfigEntryAuth(AbstractAuth):
):
if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
translation_domain=DOMAIN,
translation_key="authentication_not_valid",
) from ex
raise ConfigEntryNotReady from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from ex
if hasattr(ex, "status") and ex.status == 400:
self._oauth_session.config_entry.async_start_reauth(
self._oauth_session.hass
)
raise HomeAssistantError(ex) from ex
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from ex
return str(self._oauth_session.token[CONF_ACCESS_TOKEN])

View File

@@ -8,7 +8,11 @@ from typing import Any, cast
from google_drive_api.exceptions import GoogleDriveApiError
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlowResult,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow, instance_id
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -44,6 +48,12 @@ class OAuth2FlowHandler(
"prompt": "consent",
}
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow."""
return await self.async_step_user(user_input)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -81,13 +91,16 @@ class OAuth2FlowHandler(
await self.async_set_unique_id(email_address)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
if self.source == SOURCE_REAUTH:
entry = self._get_reauth_entry()
else:
entry = self._get_reconfigure_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"email": cast(str, reauth_entry.unique_id)},
description_placeholders={"email": cast(str, entry.unique_id)},
)
return self.async_update_reload_and_abort(reauth_entry, data=data)
return self.async_update_reload_and_abort(entry, data=data)
self._abort_if_unique_id_configured()

View File

@@ -17,9 +17,7 @@ rules:
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name:
status: exempt
comment: No entities.
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
@@ -66,12 +64,8 @@ rules:
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: No entities.
reconfiguration-flow:
status: exempt
comment: No configuration options.
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: No repairs.

View File

@@ -18,6 +18,7 @@
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"wrong_account": "Wrong account: Please authenticate with {email}."
@@ -62,5 +63,22 @@
"name": "Used storage in Drive Trash"
}
}
},
"exceptions": {
"authentication_failed": {
"message": "Authentication failed"
},
"authentication_not_valid": {
"message": "OAuth session is not valid, reauthentication required"
},
"failed_to_get_folder": {
"message": "Failed to get {folder} folder"
},
"invalid_response_google_drive_error": {
"message": "Invalid response from Google Drive: {error}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -24,6 +24,8 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
T = TypeVar(
@@ -97,7 +99,13 @@ class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
self.subentry.title,
err,
)
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"error": str(err),
},
) from err
class GoogleWeatherCurrentConditionsCoordinator(

View File

@@ -66,7 +66,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:

View File

@@ -98,5 +98,10 @@
"name": "Wind gust speed"
}
}
},
"exceptions": {
"update_error": {
"message": "Error fetching weather data: {error}"
}
}
}

View File

@@ -372,7 +372,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self.api_version != "v1":
raise ServiceValidationError(
"Updating time segments requires token authentication"
translation_domain=DOMAIN,
translation_key="token_auth_required",
)
try:
@@ -388,7 +389,11 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
enabled,
)
except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError(f"API error updating time segment: {err}") from err
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err
# Update coordinator's cached data without making an API call (avoids rate limit)
if self.data:
@@ -411,7 +416,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self.api_version != "v1":
raise ServiceValidationError(
"Reading time segments requires token authentication"
translation_domain=DOMAIN,
translation_key="token_auth_required",
)
# Ensure we have current data
@@ -496,7 +502,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""
if self.api_version != "v1":
raise ServiceValidationError(
"Updating AC charge times requires token authentication"
translation_domain=DOMAIN,
translation_key="token_auth_required",
)
try:
@@ -510,7 +517,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError(
f"API error updating AC charge times: {err}"
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err
if self.data:
@@ -544,7 +553,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""
if self.api_version != "v1":
raise ServiceValidationError(
"Updating AC discharge times requires token authentication"
translation_domain=DOMAIN,
translation_key="token_auth_required",
)
try:
@@ -557,7 +567,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError(
f"API error updating AC discharge times: {err}"
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err
if self.data:
@@ -579,7 +591,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Read AC charge time settings from SPH device cache."""
if self.api_version != "v1":
raise ServiceValidationError(
"Reading AC charge times requires token authentication"
translation_domain=DOMAIN,
translation_key="token_auth_required",
)
if not self.data:
@@ -591,7 +604,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Read AC discharge time settings from SPH device cache."""
if self.api_version != "v1":
raise ServiceValidationError(
"Reading AC discharge times requires token authentication"
translation_domain=DOMAIN,
translation_key="token_auth_required",
)
if not self.data:

View File

@@ -0,0 +1,65 @@
"""Diagnostics support for Growatt Server."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_UNIQUE_ID, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .const import CONF_PLANT_ID
from .coordinator import GrowattConfigEntry
TO_REDACT = {
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
CONF_UNIQUE_ID,
CONF_PLANT_ID,
"user_id",
"deviceSn",
"device_sn",
}
# Allowlist of safe telemetry fields from the total coordinator.
# Monetary fields (plantMoneyText, totalMoneyText, currency) are intentionally
# excluded to avoid leaking financial data under unpredictable key names.
_TOTAL_SAFE_KEYS = frozenset(
{
# Classic API keys
"todayEnergy",
"totalEnergy",
"invTodayPpv",
"nominalPower",
# V1 API keys (aliases used after normalisation in coordinator)
"today_energy",
"total_energy",
"current_power",
}
)
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: GrowattConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
runtime_data = config_entry.runtime_data
total_data = runtime_data.total_coordinator.data or {}
return async_redact_data(
{
"config_entry": config_entry.as_dict(),
"total_coordinator": {
k: v for k, v in total_data.items() if k in _TOTAL_SAFE_KEYS
},
"devices": [
{
"device_sn": device_sn,
"device_type": coordinator.device_type,
"data": coordinator.data,
}
for device_sn, coordinator in runtime_data.devices.items()
],
},
TO_REDACT,
)

View File

@@ -158,7 +158,11 @@ class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
int_value,
)
except GrowattV1ApiError as e:
raise HomeAssistantError(f"Error while setting parameter: {e}") from e
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(e)},
) from e
# If no exception was raised, the write was successful
_LOGGER.debug(

View File

@@ -33,7 +33,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info: todo
discovery: todo
docs-data-update: todo
@@ -48,7 +48,7 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:

View File

@@ -46,15 +46,20 @@ def _get_coordinator(
if not coordinators:
raise ServiceValidationError(
f"No {device_type.upper()} devices with token authentication are configured. "
f"Services require {device_type.upper()} devices with V1 API access."
translation_domain=DOMAIN,
translation_key="no_devices_configured",
translation_placeholders={"device_type": device_type.upper()},
)
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get(device_id)
if not device_entry:
raise ServiceValidationError(f"Device '{device_id}' not found")
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"device_id": device_id},
)
serial_number = None
for identifier in device_entry.identifiers:
@@ -63,11 +68,20 @@ def _get_coordinator(
break
if not serial_number:
raise ServiceValidationError(f"Device '{device_id}' is not a Growatt device")
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_growatt",
translation_placeholders={"device_id": device_id},
)
if serial_number not in coordinators:
raise ServiceValidationError(
f"{device_type.upper()} device '{serial_number}' not found or not configured for services"
translation_domain=DOMAIN,
translation_key="device_not_configured",
translation_placeholders={
"device_type": device_type.upper(),
"serial_number": serial_number,
},
)
return coordinators[serial_number]
@@ -78,13 +92,17 @@ def _parse_time_str(time_str: str, field_name: str) -> time:
parts = time_str.split(":")
if len(parts) not in (2, 3):
raise ServiceValidationError(
f"{field_name} must be in HH:MM or HH:MM:SS format"
translation_domain=DOMAIN,
translation_key="invalid_time_format",
translation_placeholders={"field_name": field_name},
)
try:
return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
f"{field_name} must be in HH:MM or HH:MM:SS format"
translation_domain=DOMAIN,
translation_key="invalid_time_format",
translation_placeholders={"field_name": field_name},
) from err
@@ -103,7 +121,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
if not 1 <= segment_id <= 9:
raise ServiceValidationError(
f"segment_id must be between 1 and 9, got {segment_id}"
translation_domain=DOMAIN,
translation_key="invalid_segment_id",
translation_placeholders={"segment_id": str(segment_id)},
)
valid_modes = {
@@ -113,7 +133,12 @@ def async_setup_services(hass: HomeAssistant) -> None:
}
if batt_mode_str not in valid_modes:
raise ServiceValidationError(
f"batt_mode must be one of {list(valid_modes.keys())}, got '{batt_mode_str}'"
translation_domain=DOMAIN,
translation_key="invalid_batt_mode",
translation_placeholders={
"batt_mode": batt_mode_str,
"allowed_modes": ", ".join(valid_modes),
},
)
batt_mode: int = valid_modes[batt_mode_str]
@@ -151,11 +176,15 @@ def async_setup_services(hass: HomeAssistant) -> None:
if not 0 <= charge_power <= 100:
raise ServiceValidationError(
f"charge_power must be between 0 and 100, got {charge_power}"
translation_domain=DOMAIN,
translation_key="invalid_charge_power",
translation_placeholders={"value": str(charge_power)},
)
if not 0 <= charge_stop_soc <= 100:
raise ServiceValidationError(
f"charge_stop_soc must be between 0 and 100, got {charge_stop_soc}"
translation_domain=DOMAIN,
translation_key="invalid_charge_stop_soc",
translation_placeholders={"value": str(charge_stop_soc)},
)
periods = []
@@ -193,11 +222,15 @@ def async_setup_services(hass: HomeAssistant) -> None:
if not 0 <= discharge_power <= 100:
raise ServiceValidationError(
f"discharge_power must be between 0 and 100, got {discharge_power}"
translation_domain=DOMAIN,
translation_key="invalid_discharge_power",
translation_placeholders={"value": str(discharge_power)},
)
if not 0 <= discharge_stop_soc <= 100:
raise ServiceValidationError(
f"discharge_stop_soc must be between 0 and 100, got {discharge_stop_soc}"
translation_domain=DOMAIN,
translation_key="invalid_discharge_stop_soc",
translation_placeholders={"value": str(discharge_stop_soc)},
)
periods = []

View File

@@ -574,6 +574,47 @@
}
}
},
"exceptions": {
"api_error": {
"message": "Growatt API error: {error}"
},
"device_not_configured": {
"message": "{device_type} device {serial_number} is not configured for services."
},
"device_not_found": {
"message": "Device {device_id} not found in the device registry."
},
"device_not_growatt": {
"message": "Device {device_id} is not a Growatt device."
},
"invalid_batt_mode": {
"message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}."
},
"invalid_charge_power": {
"message": "charge_power must be between 0 and 100, got {value}."
},
"invalid_charge_stop_soc": {
"message": "charge_stop_soc must be between 0 and 100, got {value}."
},
"invalid_discharge_power": {
"message": "discharge_power must be between 0 and 100, got {value}."
},
"invalid_discharge_stop_soc": {
"message": "discharge_stop_soc must be between 0 and 100, got {value}."
},
"invalid_segment_id": {
"message": "segment_id must be between 1 and 9, got {segment_id}."
},
"invalid_time_format": {
"message": "{field_name} must be in HH:MM or HH:MM:SS format."
},
"no_devices_configured": {
"message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access."
},
"token_auth_required": {
"message": "This action requires token authentication (V1 API)."
}
},
"selector": {
"batt_mode": {
"options": {

View File

@@ -125,7 +125,11 @@ class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity):
api_value,
)
except GrowattV1ApiError as e:
raise HomeAssistantError(f"Error while setting switch state: {e}") from e
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(e)},
) from e
# If no exception was raised, the write was successful
_LOGGER.debug(

View File

@@ -119,7 +119,6 @@ from .coordinator import (
get_core_stats,
get_host_info,
get_info,
get_issues_info,
get_network_info,
get_os_info,
get_store,
@@ -158,7 +157,6 @@ __all__ = [
"get_core_stats",
"get_host_info",
"get_info",
"get_issues_info",
"get_network_info",
"get_os_info",
"get_store",

View File

@@ -92,6 +92,7 @@ ISSUE_KEYS_FOR_REPAIRS = {
ISSUE_KEY_SYSTEM_FREE_SPACE,
ISSUE_KEY_ADDON_PWNED,
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
"issue_system_ntp_sync_failed",
}
_LOGGER = logging.getLogger(__name__)

View File

@@ -15,7 +15,7 @@ from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from . import get_addons_list, get_issues_info
from . import get_addons_list
from .const import (
ATTR_SLUG,
EXTRA_PLACEHOLDERS,
@@ -31,6 +31,7 @@ from .const import (
PLACEHOLDER_KEY_COMPONENTS,
PLACEHOLDER_KEY_REFERENCE,
)
from .coordinator import get_issues_info
from .handler import get_supervisor_client
from .issues import Issue, Suggestion

View File

@@ -177,6 +177,19 @@
},
"title": "Multiple data disks detected"
},
"issue_system_ntp_sync_failed": {
"fix_flow": {
"abort": {
"apply_suggestion_fail": "Could not re-enable NTP. Check the Supervisor logs for more details."
},
"step": {
"system_enable_ntp": {
"description": "The device could not contact its configured time servers (NTP). Using a secondary online time check, we detected that the system clock was more than 1 hour incorrect. The time has been corrected and the NTP service was temporarily disabled so the correction could be applied. To keep the system time accurate, we recommend fixing the issue preventing access to the NTP servers.\n\nCheck the **Host logs** to investigate why NTP servers could not be reached. Once resolved, select **Submit** to re-enable the NTP service."
}
}
},
"title": "Time synchronization issue detected"
},
"issue_system_reboot_required": {
"fix_flow": {
"abort": {

View File

@@ -117,13 +117,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
# Map raw event type names to friendly names using SENSOR_MAP
mapped_events: dict[str, list[int]] = {}
for event_type, channels in nvr_events.items():
friendly_name = SENSOR_MAP.get(event_type.lower(), event_type)
event_key = event_type.lower()
# Skip videoloss - used as watchdog by pyhik, not a real sensor
if event_key == "videoloss":
continue
friendly_name = SENSOR_MAP.get(event_key)
if friendly_name is None:
_LOGGER.debug("Skipping unmapped event type: %s", event_type)
continue
if friendly_name in mapped_events:
mapped_events[friendly_name].extend(channels)
else:
mapped_events[friendly_name] = list(channels)
_LOGGER.debug("Mapped NVR events: %s", mapped_events)
camera.inject_events(mapped_events)
if mapped_events:
camera.inject_events(mapped_events)
else:
_LOGGER.debug(
"No event triggers returned from %s. "

View File

@@ -23,6 +23,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.32.0"],
"requirements": ["aiohomeconnect==0.33.0"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -119,6 +119,10 @@ set_program_and_options:
- cooking_common_program_hood_automatic
- cooking_common_program_hood_venting
- cooking_common_program_hood_delayed_shut_off
- cooking_oven_program_heating_mode_3_d_heating
- cooking_oven_program_heating_mode_air_fry
- cooking_oven_program_heating_mode_grill_large_area
- cooking_oven_program_heating_mode_grill_small_area
- cooking_oven_program_heating_mode_pre_heating
- cooking_oven_program_heating_mode_hot_air
- cooking_oven_program_heating_mode_hot_air_eco

View File

@@ -260,12 +260,16 @@
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
"cooking_oven_program_heating_mode_grill_large_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_large_area%]",
"cooking_oven_program_heating_mode_grill_small_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_small_area%]",
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
"cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]",
@@ -616,12 +620,16 @@
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
"cooking_oven_program_heating_mode_grill_large_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_large_area%]",
"cooking_oven_program_heating_mode_grill_small_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_small_area%]",
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
"cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]",
@@ -1621,12 +1629,16 @@
"cooking_common_program_hood_automatic": "Automatic",
"cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
"cooking_common_program_hood_venting": "Venting",
"cooking_oven_program_heating_mode_3_d_heating": "3D heating",
"cooking_oven_program_heating_mode_air_fry": "Air fry",
"cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
"cooking_oven_program_heating_mode_bread_baking": "Bread baking",
"cooking_oven_program_heating_mode_defrost": "Defrost",
"cooking_oven_program_heating_mode_desiccation": "Desiccation",
"cooking_oven_program_heating_mode_dough_proving": "Dough proving",
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products",
"cooking_oven_program_heating_mode_grill_large_area": "Grill (large area)",
"cooking_oven_program_heating_mode_grill_small_area": "Grill (small area)",
"cooking_oven_program_heating_mode_hot_air": "Hot air",
"cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
"cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH",

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.6.0"]
"requirements": ["homematicip==2.7.0"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["hyponcloud==0.3.0"]
"requirements": ["hyponcloud==0.9.0"]
}

View File

@@ -21,11 +21,17 @@ from .coordinator import HypontechConfigEntry, HypontechDataCoordinator
from .entity import HypontechEntity, HypontechPlantEntity
def _power_unit(data: OverviewData | PlantData) -> str:
"""Return the unit of measurement for power based on the API unit."""
return UnitOfPower.KILO_WATT if data.company.upper() == "KW" else UnitOfPower.WATT
@dataclass(frozen=True, kw_only=True)
class HypontechSensorDescription(SensorEntityDescription):
"""Describes Hypontech overview sensor entity."""
value_fn: Callable[[OverviewData], float | None]
unit_fn: Callable[[OverviewData], str] | None = None
@dataclass(frozen=True, kw_only=True)
@@ -33,15 +39,16 @@ class HypontechPlantSensorDescription(SensorEntityDescription):
"""Describes Hypontech plant sensor entity."""
value_fn: Callable[[PlantData], float | None]
unit_fn: Callable[[PlantData], str] | None = None
OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = (
HypontechSensorDescription(
key="pv_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda c: c.power,
unit_fn=_power_unit,
),
HypontechSensorDescription(
key="lifetime_energy",
@@ -64,10 +71,10 @@ OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = (
PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = (
HypontechPlantSensorDescription(
key="pv_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda c: c.power,
unit_fn=_power_unit,
),
HypontechPlantSensorDescription(
key="lifetime_energy",
@@ -124,6 +131,13 @@ class HypontechOverviewSensor(HypontechEntity, SensorEntity):
self.entity_description = description
self._attr_unique_id = f"{coordinator.account_id}_{description.key}"
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
if self.entity_description.unit_fn is not None:
return self.entity_description.unit_fn(self.coordinator.data.overview)
return super().native_unit_of_measurement
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
@@ -146,6 +160,13 @@ class HypontechPlantSensor(HypontechPlantEntity, SensorEntity):
self.entity_description = description
self._attr_unique_id = f"{plant_id}_{description.key}"
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
if self.entity_description.unit_fn is not None:
return self.entity_description.unit_fn(self.plant)
return super().native_unit_of_measurement
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""

View File

@@ -214,12 +214,7 @@ class MoldIndicator(SensorEntity):
# Replay current state of source entities
for entity_id in self._entities.values():
state = self.hass.states.get(entity_id)
state_event: Event[EventStateChangedData] = Event(
"", {"entity_id": entity_id, "new_state": state, "old_state": None}
)
self._async_mold_indicator_sensor_state_listener(
state_event, update_state=False
)
self._update_cached_values(entity_id, state)
self._recalculate()
@@ -227,9 +222,19 @@ class MoldIndicator(SensorEntity):
calculated_state = self._async_calculate_state()
self._preview_callback(calculated_state.state, calculated_state.attributes)
@callback
def _update_cached_values(self, entity_id: str, new_state: State | None) -> None:
"""Update cached sensor values from a state."""
if entity_id == self._entities[CONF_INDOOR_TEMP]:
self._indoor_temp = self._get_temperature_from_state(new_state)
elif entity_id == self._entities[CONF_OUTDOOR_TEMP]:
self._outdoor_temp = self._get_temperature_from_state(new_state)
elif entity_id == self._entities[CONF_INDOOR_HUMIDITY]:
self._indoor_hum = self._get_humidity_from_state(new_state)
@callback
def _async_mold_indicator_sensor_state_listener(
self, event: Event[EventStateChangedData], update_state: bool = True
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state changes for dependent sensors."""
entity_id = event.data["entity_id"]
@@ -242,16 +247,7 @@ class MoldIndicator(SensorEntity):
new_state,
)
# update state depending on which sensor changed
if entity_id == self._entities[CONF_INDOOR_TEMP]:
self._indoor_temp = self._get_temperature_from_state(new_state)
elif entity_id == self._entities[CONF_OUTDOOR_TEMP]:
self._outdoor_temp = self._get_temperature_from_state(new_state)
elif entity_id == self._entities[CONF_INDOOR_HUMIDITY]:
self._indoor_hum = self._get_humidity_from_state(new_state)
if not update_state:
return
self._update_cached_values(entity_id, new_state)
self._recalculate()

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["ohme==1.7.0"]
"requirements": ["ohme==1.7.1"]
}

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "platinum",
"requirements": ["opower==0.17.0"]
"requirements": ["opower==0.17.1"]
}

View File

@@ -65,6 +65,7 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
)
self._connected: bool = False
self._current_devices: set[str] = set()
self._firmware_list: dict[str, str | None] = {}
self._stored_devices: set[str] = set()
self.new_devices: set[str] = set()
@@ -129,6 +130,7 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
) from err
self._add_remove_devices(data)
self._update_device_firmware(data)
return data
def _add_remove_devices(self, data: dict[str, GwEntityData]) -> None:
@@ -138,6 +140,9 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
# 'new_devices' contains all devices present in 'data' at init ('self._current_devices' is empty)
# this is required for the proper initialization of all the present platform entities.
self.new_devices = set_of_data - self._current_devices
for device_id in self.new_devices:
self._firmware_list.setdefault(device_id, data[device_id].get("firmware"))
current_devices = (
self._stored_devices if not self._current_devices else self._current_devices
)
@@ -149,21 +154,52 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
"""Clean registries when removed devices found."""
device_reg = dr.async_get(self.hass)
for device_id in removed_devices:
device_entry = device_reg.async_get_device({(DOMAIN, device_id)})
if device_entry is None:
LOGGER.warning(
"Failed to remove %s device/zone %s, not present in device_registry",
if (
device_entry := device_reg.async_get_device({(DOMAIN, device_id)})
) is not None:
device_reg.async_update_device(
device_entry.id, remove_config_entry_id=self.config_entry.entry_id
)
LOGGER.debug(
"%s %s %s removed from device_registry",
DOMAIN,
device_entry.model,
device_id,
)
continue # pragma: no cover
device_reg.async_update_device(
device_entry.id, remove_config_entry_id=self.config_entry.entry_id
)
self._firmware_list.pop(device_id, None)
def _update_device_firmware(self, data: dict[str, GwEntityData]) -> None:
"""Detect firmware changes and update the device registry."""
for device_id, device in data.items():
# Only update firmware when the key is present and not None, to avoid
# wiping stored firmware on partial or transient updates.
if "firmware" not in device:
continue
new_firmware = device.get("firmware")
if new_firmware is None:
continue
if (
device_id in self._firmware_list
and new_firmware != self._firmware_list[device_id]
):
updated = self._update_firmware_in_dr(device_id, new_firmware)
if updated:
self._firmware_list[device_id] = new_firmware
def _update_firmware_in_dr(self, device_id: str, firmware: str | None) -> bool:
"""Update device sw_version in device_registry."""
device_reg = dr.async_get(self.hass)
if (
device_entry := device_reg.async_get_device({(DOMAIN, device_id)})
) is not None:
device_reg.async_update_device(device_entry.id, sw_version=firmware)
LOGGER.debug(
"%s %s %s removed from device_registry",
"Firmware in device_registry updated for %s %s %s",
DOMAIN,
device_entry.model,
device_id,
)
return True
return False # pragma: no cover

View File

@@ -74,6 +74,26 @@ CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = (
)
),
),
PortainerButtonDescription(
key="pause",
translation_key="pause_container",
entity_category=EntityCategory.CONFIG,
press_action=(
lambda portainer, endpoint_id, container_id: portainer.pause_container(
endpoint_id, container_id
)
),
),
PortainerButtonDescription(
key="resume",
translation_key="resume_container",
entity_category=EntityCategory.CONFIG,
press_action=(
lambda portainer, endpoint_id, container_id: portainer.unpause_container(
endpoint_id, container_id
)
),
),
)

View File

@@ -1,5 +1,13 @@
{
"entity": {
"button": {
"pause_container": {
"default": "mdi:pause-circle"
},
"resume_container": {
"default": "mdi:play"
}
},
"sensor": {
"api_version": {
"default": "mdi:api"

View File

@@ -66,8 +66,14 @@
"images_prune": {
"name": "Prune unused images"
},
"pause_container": {
"name": "Pause container"
},
"restart_container": {
"name": "Restart container"
},
"resume_container": {
"name": "Resume container"
}
},
"sensor": {

View File

@@ -44,11 +44,9 @@ CONNECTION_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_CODE): cv.string,
}
)
CODE_SCHEMA = vol.Schema(
{
vol.Optional(CONF_CODE): cv.string,
@@ -86,6 +84,11 @@ SWITCHABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a Satel Integra config flow."""
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.connection_data: dict[str, Any] = {}
VERSION = 2
MINOR_VERSION = 1
@@ -119,24 +122,71 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
valid = await self.test_connection(
user_input[CONF_HOST], user_input[CONF_PORT]
)
if valid:
return self.async_create_entry(
title=user_input[CONF_HOST],
data={
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
},
options={CONF_CODE: user_input.get(CONF_CODE)},
)
if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]):
self.connection_data = {
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
}
return await self.async_step_code()
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user", data_schema=CONNECTION_SCHEMA, errors=errors
step_id="user",
data_schema=CONNECTION_SCHEMA,
errors=errors,
)
async def async_step_code(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle code configuration."""
if user_input is not None:
return self.async_create_entry(
title=self.connection_data[CONF_HOST],
data=self.connection_data,
options={CONF_CODE: user_input.get(CONF_CODE)},
)
return self.async_show_form(
step_id="code",
data_schema=CODE_SCHEMA,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]):
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
},
title=user_input[CONF_HOST],
reload_even_if_entry_is_unchanged=False,
)
errors["base"] = "cannot_connect"
suggested_values: dict[str, Any] = {
**reconfigure_entry.data,
**(user_input or {}),
}
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
CONNECTION_SCHEMA, suggested_values
),
errors=errors,
)
async def test_connection(self, host: str, port: int) -> bool:

View File

@@ -5,20 +5,37 @@
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"code": {
"data": {
"code": "[%key:component::satel_integra::common::code%]"
},
"data_description": {
"code": "[%key:component::satel_integra::common::code_input_description%]"
}
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "[%key:component::satel_integra::config::step::user::data_description::host%]",
"port": "[%key:component::satel_integra::config::step::user::data_description::port%]"
}
},
"user": {
"data": {
"code": "[%key:component::satel_integra::common::code%]",
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"code": "[%key:component::satel_integra::common::code_input_description%]",
"host": "The IP address of the alarm panel",
"port": "The port of the alarm panel"
}

View File

@@ -0,0 +1,17 @@
"""Provides conditions for schedules."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the schedule conditions."""
return CONDITIONS

View File

@@ -0,0 +1,17 @@
.condition_common: &condition_common
target:
entity:
domain: schedule
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_off: *condition_common
is_on: *condition_common

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_off": {
"condition": "mdi:calendar-blank"
},
"is_on": {
"condition": "mdi:calendar-clock"
}
},
"services": {
"get_schedule": {
"service": "mdi:calendar-export"

View File

@@ -1,8 +1,32 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted schedules.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted schedules to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_off": {
"description": "Tests if one or more schedule blocks are currently not active.",
"fields": {
"behavior": {
"description": "[%key:component::schedule::common::condition_behavior_description%]",
"name": "[%key:component::schedule::common::condition_behavior_name%]"
}
},
"name": "Schedule is off"
},
"is_on": {
"description": "Tests if one or more schedule blocks are currently active.",
"fields": {
"behavior": {
"description": "[%key:component::schedule::common::condition_behavior_description%]",
"name": "[%key:component::schedule::common::condition_behavior_name%]"
}
},
"name": "Schedule is on"
}
},
"entity_component": {
"_": {
"name": "[%key:component::schedule::title%]",
@@ -25,6 +49,12 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -17,7 +17,7 @@
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for accessing your SFR box's web interface, the default is the WiFi security key found on the device label",
"password": "The password for accessing your SFR box's web interface, the default is the Wi-Fi security key found on the device label",
"username": "The username for accessing your SFR box's web interface, the default is 'admin'"
}
},

View File

@@ -2,21 +2,21 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_on_wifi": "Device is already connected to WiFi and was discovered via the network.",
"already_on_wifi": "Device is already connected to Wi-Fi and was discovered via the network.",
"another_device": "Reconfiguration was unsuccessful, the IP address/hostname of another Shelly device was used.",
"ble_not_permitted": "Device is bound to a Shelly cloud account and cannot be provisioned via Bluetooth. Please use the Shelly app to provision WiFi credentials, then add the device when it appears on your network.",
"ble_not_permitted": "Device is bound to a Shelly cloud account and cannot be provisioned via Bluetooth. Please use the Shelly app to provision Wi-Fi credentials, then add the device when it appears on your network.",
"cannot_connect": "Failed to connect to the device. Ensure the device is powered on and within range.",
"custom_port_not_supported": "[%key:component::shelly::config::error::custom_port_not_supported%]",
"firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support",
"invalid_discovery_info": "Invalid Bluetooth discovery information.",
"ipv6_not_supported": "IPv6 is not supported.",
"mac_address_mismatch": "[%key:component::shelly::config::error::mac_address_mismatch%]",
"no_wifi_networks": "No WiFi networks found during scan.",
"no_wifi_networks": "No Wi-Fi networks found during scan.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reauth_unsuccessful": "Re-authentication was unsuccessful, please remove the integration and set it up again.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"wifi_provisioned": "WiFi credentials for {ssid} have been provisioned to {name}. The device is connecting to WiFi and will complete setup automatically."
"wifi_provisioned": "Wi-Fi credentials for {ssid} have been provisioned to {name}. The device is connecting to Wi-Fi and will complete setup automatically."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -28,20 +28,20 @@
},
"flow_title": "{name}",
"progress": {
"provisioning": "Provisioning WiFi credentials and waiting for device to connect"
"provisioning": "Provisioning Wi-Fi credentials and waiting for device to connect"
},
"step": {
"bluetooth_confirm": {
"data": {
"disable_ap": "Disable WiFi access point after provisioning",
"disable_ap": "Disable Wi-Fi access point after provisioning",
"disable_ble_rpc": "Disable Bluetooth RPC after provisioning"
},
"data_description": {
"disable_ap": "For improved security, disable the WiFi access point after successfully connecting to your network.",
"disable_ble_rpc": "For improved security, disable Bluetooth RPC access after WiFi is configured. Bluetooth will remain enabled for BLE sensors and buttons."
"disable_ap": "For improved security, disable the Wi-Fi access point after successfully connecting to your network.",
"disable_ble_rpc": "For improved security, disable Bluetooth RPC access after Wi-Fi is configured. Bluetooth will remain enabled for BLE sensors and buttons."
},
"description": "The Shelly device {name} has been discovered via Bluetooth but is not connected to WiFi.\n\nDo you want to provision WiFi credentials to this device?",
"title": "Provision WiFi via Bluetooth"
"description": "The Shelly device {name} has been discovered via Bluetooth but is not connected to Wi-Fi.\n\nDo you want to provision Wi-Fi credentials to this device?",
"title": "Provision Wi-Fi via Bluetooth"
},
"confirm_discovery": {
"description": "Do you want to set up the {model} at {host}?\n\nBattery-powered devices that are password-protected must be woken up before continuing with setting up.\nBattery-powered devices that are not password-protected will be added when the device wakes up, you can now manually wake the device up using a button on it or wait for the next data update from the device."
@@ -103,16 +103,16 @@
"wifi_scan": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"ssid": "WiFi network"
"ssid": "Wi-Fi network"
},
"data_description": {
"password": "Password for the WiFi network.",
"ssid": "Select a WiFi network from the list or enter a custom SSID for hidden networks."
"password": "Password for the Wi-Fi network.",
"ssid": "Select a Wi-Fi network from the list or enter a custom SSID for hidden networks."
},
"description": "Select a WiFi network and enter the password to provision the device."
"description": "Select a Wi-Fi network and enter the password to provision the device."
},
"wifi_scan_failed": {
"description": "Failed to scan for WiFi networks via Bluetooth. The device may be out of range or Bluetooth connection failed. Would you like to try again?"
"description": "Failed to scan for Wi-Fi networks via Bluetooth. The device may be out of range or Bluetooth connection failed. Would you like to try again?"
}
}
},
@@ -727,16 +727,16 @@
},
"step": {
"init": {
"description": "Your Shelly device {device_name} with IP address {ip_address} has an open WiFi access point enabled without a password. This is a security risk as anyone nearby can connect to the device.\n\nNote: If you disable the access point, the device may need to restart.",
"description": "Your Shelly device {device_name} with IP address {ip_address} has an open Wi-Fi access point enabled without a password. This is a security risk as anyone nearby can connect to the device.\n\nNote: If you disable the access point, the device may need to restart.",
"menu_options": {
"confirm": "Disable WiFi access point",
"confirm": "Disable Wi-Fi access point",
"ignore": "Ignore"
},
"title": "[%key:component::shelly::issues::open_wifi_ap::title%]"
}
}
},
"title": "Open WiFi access point on {device_name}"
"title": "Open Wi-Fi access point on {device_name}"
},
"outbound_websocket_incorrectly_enabled": {
"fix_flow": {

View File

@@ -1,7 +1,7 @@
{
"domain": "smarla",
"name": "Swing2Sleep Smarla",
"codeowners": ["@explicatis", "@rlint-explicatis"],
"codeowners": ["@explicatis", "@johannes-exp"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/smarla",
"integration_type": "device",

View File

@@ -3,16 +3,18 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from pysmartthings import Capability, Command, SmartThings
from pysmartthings import Attribute, Capability, Category, Command, SmartThings
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullDevice, SmartThingsConfigEntry
from .const import MAIN
from .const import DOMAIN, MAIN
from .entity import SmartThingsEntity
@@ -22,7 +24,11 @@ class SmartThingsButtonDescription(ButtonEntityDescription):
key: Capability
command: Command
command_identifier: str | None = None
components: list[str] | None = None
argument: int | str | list[Any] | dict[str, Any] | None = None
requires_remote_control_status: bool = False
requires_dishwasher_machine_state: set[str] | None = None
CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = {
@@ -53,6 +59,50 @@ CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] =
}
DISHWASHER_OPERATION_COMMANDS_TO_BUTTONS: dict[
Command | str, SmartThingsButtonDescription
] = {
Command.CANCEL: SmartThingsButtonDescription(
key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION,
translation_key="cancel",
command_identifier="drain",
command=Command.CANCEL,
argument=[True],
requires_remote_control_status=True,
),
Command.PAUSE: SmartThingsButtonDescription(
key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION,
translation_key="pause",
command=Command.PAUSE,
requires_remote_control_status=True,
requires_dishwasher_machine_state={"run"},
),
Command.RESUME: SmartThingsButtonDescription(
key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION,
translation_key="resume",
command=Command.RESUME,
requires_remote_control_status=True,
requires_dishwasher_machine_state={"pause"},
),
Command.START: SmartThingsButtonDescription(
key=Capability.SAMSUNG_CE_DISHWASHER_OPERATION,
translation_key="start",
command=Command.START,
requires_remote_control_status=True,
requires_dishwasher_machine_state={"stop"},
),
}
DISHWASHER_CANCEL_AND_DRAIN_BUTTON = SmartThingsButtonDescription(
key=Capability.CUSTOM_SUPPORTED_OPTIONS,
translation_key="cancel_and_drain",
command_identifier="89",
command=Command.SET_COURSE,
argument="89",
requires_remote_control_status=True,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SmartThingsConfigEntry,
@@ -60,13 +110,41 @@ async def async_setup_entry(
) -> None:
"""Add button entities for a config entry."""
entry_data = entry.runtime_data
async_add_entities(
SmartThingsButtonEntity(entry_data.client, device, description, component)
entities: list[SmartThingsEntity] = []
entities.extend(
SmartThingsButtonEntity(
entry_data.client, device, description, Capability(capability), component
)
for capability, description in CAPABILITIES_TO_BUTTONS.items()
for device in entry_data.devices.values()
for component in description.components or [MAIN]
if component in device.status and capability in device.status[component]
)
entities.extend(
SmartThingsButtonEntity(
entry_data.client,
device,
description,
Capability.SAMSUNG_CE_DISHWASHER_OPERATION,
)
for device in entry_data.devices.values()
if Capability.SAMSUNG_CE_DISHWASHER_OPERATION in device.status[MAIN]
for description in DISHWASHER_OPERATION_COMMANDS_TO_BUTTONS.values()
)
entities.extend(
SmartThingsButtonEntity(
entry_data.client,
device,
DISHWASHER_CANCEL_AND_DRAIN_BUTTON,
Capability.CUSTOM_SUPPORTED_OPTIONS,
)
for device in entry_data.devices.values()
if (
device.device.components[MAIN].manufacturer_category == Category.DISHWASHER
and Capability.CUSTOM_SUPPORTED_OPTIONS in device.status[MAIN]
)
)
async_add_entities(entities)
class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity):
@@ -79,16 +157,53 @@ class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity):
client: SmartThings,
device: FullDevice,
entity_description: SmartThingsButtonDescription,
component: str,
capability: Capability,
component: str = MAIN,
) -> None:
"""Initialize the instance."""
super().__init__(client, device, set(), component=component)
capabilities = set()
if entity_description.requires_remote_control_status:
capabilities.add(Capability.REMOTE_CONTROL_STATUS)
if entity_description.requires_dishwasher_machine_state:
capabilities.add(Capability.DISHWASHER_OPERATING_STATE)
super().__init__(client, device, capabilities)
self.entity_description = entity_description
self.button_capability = capability
self._attr_unique_id = f"{device.device.device_id}_{component}_{entity_description.key}_{entity_description.command}"
if entity_description.command_identifier is not None:
self._attr_unique_id += f"_{entity_description.command_identifier}"
async def async_press(self) -> None:
"""Press the button."""
self._validate_before_execute()
await self.execute_device_command(
self.entity_description.key,
self.button_capability,
self.entity_description.command,
self.entity_description.argument,
)
def _validate_before_execute(self) -> None:
"""Validate that the command can be executed."""
if (
self.entity_description.requires_remote_control_status
and self.get_attribute_value(
Capability.REMOTE_CONTROL_STATUS, Attribute.REMOTE_CONTROL_ENABLED
)
== "false"
):
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="remote_control_status"
)
if (
self.entity_description.requires_dishwasher_machine_state
and self.get_attribute_value(
Capability.DISHWASHER_OPERATING_STATE, Attribute.MACHINE_STATE
)
not in self.entity_description.requires_dishwasher_machine_state
):
state_list = " or ".join(
self.entity_description.requires_dishwasher_machine_state
)
raise ServiceValidationError(
f"Can only be updated when dishwasher machine state is {state_list}"
)

View File

@@ -27,12 +27,27 @@
}
},
"button": {
"cancel": {
"default": "mdi:stop"
},
"cancel_and_drain": {
"default": "mdi:stop"
},
"pause": {
"default": "mdi:pause"
},
"reset_hepa_filter": {
"default": "mdi:air-filter"
},
"reset_water_filter": {
"default": "mdi:reload"
},
"resume": {
"default": "mdi:play"
},
"start": {
"default": "mdi:play"
},
"stop": {
"default": "mdi:stop"
}

View File

@@ -1221,6 +1221,24 @@ CAPABILITY_TO_SENSORS: dict[
)
]
},
Capability.SAMSUNG_CE_MICROFIBER_FILTER_OPERATING_STATE: {
Attribute.MICROFIBER_FILTER_JOB_STATE: [
SmartThingsSensorEntityDescription(
key=Attribute.MICROFIBER_FILTER_JOB_STATE,
translation_key="microfiber_filter_job_state",
device_class=SensorDeviceClass.ENUM,
options_attribute=Attribute.SUPPORTED_JOB_STATES,
)
],
Attribute.OPERATING_STATE: [
SmartThingsSensorEntityDescription(
key=Attribute.OPERATING_STATE,
translation_key="microfiber_filter_operating_state",
device_class=SensorDeviceClass.ENUM,
options_attribute=Attribute.SUPPORTED_OPERATING_STATES,
)
],
},
}

View File

@@ -93,6 +93,15 @@
}
},
"button": {
"cancel": {
"name": "Cancel"
},
"cancel_and_drain": {
"name": "Cancel and drain"
},
"pause": {
"name": "[%key:common::action::pause%]"
},
"reset_hepa_filter": {
"name": "Reset HEPA filter"
},
@@ -102,6 +111,12 @@
"reset_water_filter": {
"name": "Reset water filter"
},
"resume": {
"name": "Resume"
},
"start": {
"name": "[%key:common::action::start%]"
},
"stop": {
"name": "[%key:common::action::stop%]"
}
@@ -570,6 +585,25 @@
"media_playback_status": {
"name": "Media playback status"
},
"microfiber_filter_job_state": {
"name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]",
"state": {
"bypassing": "Bypassing",
"filtering": "Filtering",
"none": "[%key:component::smartthings::entity::sensor::washer_job_state::state::none%]",
"sensing": "Weight sensing",
"stopping": "Stopping",
"waiting": "Waiting"
}
},
"microfiber_filter_operating_state": {
"name": "[%key:component::smartthings::entity::sensor::cooktop_operating_state::name%]",
"state": {
"paused": "[%key:common::state::paused%]",
"ready": "[%key:component::smartthings::entity::sensor::oven_machine_state::state::ready%]",
"running": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]"
}
},
"odor_sensor": {
"name": "Odor sensor"
},
@@ -916,6 +950,9 @@
"bubble_soak": {
"name": "Bubble Soak"
},
"bypass_mode": {
"name": "Bypass mode"
},
"display_lighting": {
"name": "Display lighting"
},
@@ -1009,6 +1046,9 @@
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"remote_control_status": {
"message": "Can only be changed when remote control is enabled"
}
},
"issues": {

View File

@@ -101,6 +101,15 @@ CAPABILITY_TO_COMMAND_SWITCHES: dict[
command=Command.SET_STEAM_CLOSET_AUTO_CYCLE_LINK,
entity_category=EntityCategory.CONFIG,
),
Capability.SAMSUNG_CE_MICROFIBER_FILTER_SETTINGS: SmartThingsCommandSwitchEntityDescription(
key=Capability.SAMSUNG_CE_MICROFIBER_FILTER_SETTINGS,
translation_key="bypass_mode",
status_attribute=Attribute.BYPASS_MODE,
entity_category=EntityCategory.CONFIG,
on_key="enabled",
off_key="disabled",
command=Command.SET_BYPASS_MODE,
),
}
CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = {
Capability.SAMSUNG_CE_AIR_CONDITIONER_BEEP: SmartThingsSwitchEntityDescription(

View File

@@ -290,16 +290,29 @@ class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
and entity.unique_id != self.unique_id
]
# Get unique ID prefix for this host
unique_id_prefix = self.get_unique_id(self.coordinator.host_id, "")
for client in clients:
# Valid entity is a snapcast client
# Validate entity is a snapcast client
if not client.unique_id.startswith(CLIENT_PREFIX):
raise ServiceValidationError(
f"Entity '{client.entity_id}' is not a Snapcast client device."
)
# Validate client belongs to the same server
if not client.unique_id.startswith(unique_id_prefix):
raise ServiceValidationError(
f"Entity '{client.entity_id}' does not belong to the same Snapcast server."
)
# Extract client ID and join it to the current group
identifier = client.unique_id.split("_")[-1]
await self._current_group.add_client(identifier)
identifier = client.unique_id.removeprefix(unique_id_prefix)
try:
await self._current_group.add_client(identifier)
except KeyError as e:
raise ServiceValidationError(
f"Client with identifier '{identifier}' does not exist on the server."
) from e
self.async_write_ha_state()

View File

@@ -178,31 +178,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
connectivity=False, token=True, busy=False
)
except ClientConnectionError as err:
_LOGGER.debug("Connection error during setup at %s:%s: %s", host, port, err)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
"host": host,
"port": str(port),
"error": str(err),
},
translation_key="cannot_connect",
translation_placeholders={"host": host, "port": str(port)},
) from err
except TimeoutError as err:
_LOGGER.debug("Timeout during setup at %s:%s: %s", host, port, err)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"host": host, "port": str(port)},
) from err
except Exception as err:
_LOGGER.exception("Unexpected error setting up Splunk")
_LOGGER.exception("Unexpected setup error at %s:%s", host, port)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="unexpected_error",
translation_placeholders={
"host": host,
"port": str(port),
"error": str(err),
},
translation_key="unexpected_connect_error",
) from err
if not connectivity_ok:

View File

@@ -33,7 +33,7 @@
"name": "[%key:common::config_flow::data::name%]",
"port": "[%key:common::config_flow::data::port%]",
"ssl": "[%key:common::config_flow::data::ssl%]",
"token": "HTTP Event Collector token",
"token": "[%key:component::splunk::config::step::user::data::token%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
@@ -72,17 +72,14 @@
"cannot_connect": {
"message": "Unable to connect to Splunk at {host}:{port}."
},
"connection_error": {
"message": "Unable to connect to Splunk at {host}:{port}: {error}."
},
"invalid_auth": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"timeout_connect": {
"message": "Connection to Splunk at {host}:{port} timed out."
},
"unexpected_error": {
"message": "Unexpected error while connecting to Splunk at {host}:{port}: {error}."
"unexpected_connect_error": {
"message": "Unexpected error while connecting to Splunk."
}
},
"issues": {

View File

@@ -397,11 +397,11 @@ def _metadata_from_header(request: web.Request) -> SpeechMetadata:
try:
return SpeechMetadata(
language=args["language"],
format=args["format"],
codec=args["codec"],
bit_rate=args["bit_rate"],
sample_rate=args["sample_rate"],
channel=args["channel"],
format=AudioFormats(args["format"]),
codec=AudioCodecs(args["codec"]),
bit_rate=AudioBitRates(int(args["bit_rate"])),
sample_rate=AudioSampleRates(int(args["sample_rate"])),
channel=AudioChannels(int(args["channel"])),
)
except ValueError as err:
raise ValueError(f"Wrong format of X-Speech-Content: {err}") from err

View File

@@ -23,12 +23,6 @@ class SpeechMetadata:
sample_rate: AudioSampleRates
channel: AudioChannels
def __post_init__(self) -> None:
"""Finish initializing the metadata."""
self.bit_rate = AudioBitRates(int(self.bit_rate))
self.sample_rate = AudioSampleRates(int(self.sample_rate))
self.channel = AudioChannels(int(self.channel))
@dataclass
class SpeechResult:

View File

@@ -227,6 +227,9 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
# Clear saved credentials if auth failed
self._cloud_username = None
self._cloud_password = None
except Exception:
_LOGGER.exception("Unexpected error retrieving encryption key")
errors = {"base": "unknown"}
else:
return await self.async_step_encrypted_key(key_details)
@@ -366,6 +369,9 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Authentication failed: %s", ex, exc_info=True)
errors = {"base": "auth_failed"}
description_placeholders = {"error_detail": str(ex)}
except Exception:
_LOGGER.exception("Unexpected error during cloud login")
errors = {"base": "unknown"}
else:
# Save credentials temporarily for the duration of this flow
# to avoid re-prompting if encrypted device auth is needed

View File

@@ -9,7 +9,8 @@
},
"error": {
"auth_failed": "Authentication failed: {error_detail}",
"encryption_key_invalid": "Key ID or encryption key is invalid"
"encryption_key_invalid": "Key ID or encryption key is invalid",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name} ({address})",
"step": {

View File

@@ -13,7 +13,7 @@ from tesla_fleet_api.exceptions import (
TeslaFleetError,
)
from tesla_fleet_api.tessie import Tessie
from tessie_api import get_battery, get_state_of_all_vehicles
from tessie_api import get_state_of_all_vehicles
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
@@ -28,7 +28,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN, MODELS
from .coordinator import (
TessieBatteryHealthCoordinator,
TessieEnergyHistoryCoordinator,
TessieEnergySiteInfoCoordinator,
TessieEnergySiteLiveCoordinator,
@@ -74,25 +73,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
except ClientError as e:
raise ConfigEntryNotReady from e
try:
batteries = await asyncio.gather(
*(
get_battery(
session=session,
api_key=api_key,
vin=vehicle["vin"],
)
for vehicle in state_of_all_vehicles["results"]
if vehicle["last_state"] is not None
)
)
except ClientResponseError as e:
if e.status == HTTPStatus.UNAUTHORIZED:
raise ConfigEntryAuthFailed from e
raise ConfigEntryError("Setup failed, unable to get battery data") from e
except ClientError as e:
raise ConfigEntryNotReady from e
vehicles = [
TessieVehicleData(
vin=vehicle["vin"],
@@ -103,13 +83,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
vin=vehicle["vin"],
data=vehicle["last_state"],
),
battery_coordinator=TessieBatteryHealthCoordinator(
hass,
entry,
api_key=api_key,
vin=vehicle["vin"],
data=battery,
),
device=DeviceInfo(
identifiers={(DOMAIN, vehicle["vin"])},
manufacturer="Tesla",
@@ -126,15 +99,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
serial_number=vehicle["vin"],
),
)
for vehicle, battery in zip(
(
v
for v in state_of_all_vehicles["results"]
if v["last_state"] is not None
),
batteries,
strict=True,
)
for vehicle in state_of_all_vehicles["results"]
if vehicle["last_state"] is not None
]
# Energy Sites

View File

@@ -11,7 +11,7 @@ from aiohttp import ClientResponseError
from tesla_fleet_api.const import TeslaEnergyPeriod
from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError
from tesla_fleet_api.tessie import EnergySite
from tessie_api import get_battery, get_state
from tessie_api import get_state
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -87,48 +87,6 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return flatten(vehicle)
class TessieBatteryHealthCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching battery health data from the Tessie API."""
config_entry: TessieConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: TessieConfigEntry,
api_key: str,
vin: str,
data: dict[str, Any],
) -> None:
"""Initialize Tessie Battery Health coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name="Tessie Battery Health",
update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL),
)
self.api_key = api_key
self.vin = vin
self.session = async_get_clientsession(hass)
self.data = data
async def _async_update_data(self) -> dict[str, Any]:
"""Update battery health data using Tessie API."""
try:
data = await get_battery(
session=self.session,
api_key=self.api_key,
vin=self.vin,
)
except ClientResponseError as e:
if e.status == HTTPStatus.UNAUTHORIZED:
raise ConfigEntryAuthFailed from e
raise UpdateFailed from e
return data
class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching energy site live status from the Tessie API."""

View File

@@ -35,7 +35,6 @@ async def async_get_config_entry_diagnostics(
vehicles = [
{
"data": async_redact_data(x.data_coordinator.data, VEHICLE_REDACT),
"battery": x.battery_coordinator.data,
}
for x in entry.runtime_data.vehicles
]

View File

@@ -12,7 +12,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, TRANSLATED_ERRORS
from .coordinator import (
TessieBatteryHealthCoordinator,
TessieEnergyHistoryCoordinator,
TessieEnergySiteInfoCoordinator,
TessieEnergySiteLiveCoordinator,
@@ -24,7 +23,6 @@ from .models import TessieEnergyData, TessieVehicleData
class TessieBaseEntity(
CoordinatorEntity[
TessieStateUpdateCoordinator
| TessieBatteryHealthCoordinator
| TessieEnergySiteInfoCoordinator
| TessieEnergySiteLiveCoordinator
| TessieEnergyHistoryCoordinator
@@ -37,15 +35,16 @@ class TessieBaseEntity(
def __init__(
self,
coordinator: TessieStateUpdateCoordinator
| TessieBatteryHealthCoordinator
| TessieEnergySiteInfoCoordinator
| TessieEnergySiteLiveCoordinator
| TessieEnergyHistoryCoordinator,
key: str,
data_key: str | None = None,
) -> None:
"""Initialize common aspects of a Tessie entity."""
self.key = key
self.data_key = data_key or key
self._attr_translation_key = key
super().__init__(coordinator)
self._async_update_attrs()
@@ -53,11 +52,11 @@ class TessieBaseEntity(
@property
def _value(self) -> Any:
"""Return value from coordinator data."""
return self.coordinator.data.get(self.key)
return self.coordinator.data.get(self.data_key)
def get(self, key: str | None = None, default: Any | None = None) -> Any:
"""Return a specific value from coordinator data."""
return self.coordinator.data.get(key or self.key, default)
return self.coordinator.data.get(key or self.data_key, default)
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
@@ -76,6 +75,7 @@ class TessieEntity(TessieBaseEntity):
self,
vehicle: TessieVehicleData,
key: str,
data_key: str | None = None,
) -> None:
"""Initialize common aspects of a Tessie vehicle entity."""
self.vin = vehicle.vin
@@ -84,12 +84,7 @@ class TessieEntity(TessieBaseEntity):
self._attr_unique_id = f"{vehicle.vin}-{key}"
self._attr_device_info = vehicle.device
super().__init__(vehicle.data_coordinator, key)
@property
def _value(self) -> Any:
"""Return value from coordinator data."""
return self.coordinator.data.get(self.key)
super().__init__(vehicle.data_coordinator, key, data_key)
def set(self, *args: Any) -> None:
"""Set a value in coordinator data."""
@@ -133,29 +128,14 @@ class TessieEnergyEntity(TessieBaseEntity):
data: TessieEnergyData,
coordinator: TessieEnergySiteInfoCoordinator | TessieEnergySiteLiveCoordinator,
key: str,
data_key: str | None = None,
) -> None:
"""Initialize common aspects of a Tessie energy site entity."""
self.api = data.api
self._attr_unique_id = f"{data.id}-{key}"
self._attr_device_info = data.device
super().__init__(coordinator, key)
class TessieBatteryEntity(TessieBaseEntity):
"""Parent class for Tessie battery health entities."""
def __init__(
self,
vehicle: TessieVehicleData,
key: str,
) -> None:
"""Initialize common aspects of a Tessie battery health entity."""
self.vin = vehicle.vin
self._attr_unique_id = f"{vehicle.vin}-{key}"
self._attr_device_info = vehicle.device
super().__init__(vehicle.battery_coordinator, key)
super().__init__(coordinator, key, data_key)
class TessieEnergyHistoryEntity(TessieBaseEntity):
@@ -165,13 +145,14 @@ class TessieEnergyHistoryEntity(TessieBaseEntity):
self,
data: TessieEnergyData,
key: str,
data_key: str | None = None,
) -> None:
"""Initialize common aspects of a Tessie energy history entity."""
self.api = data.api
self._attr_unique_id = f"{data.id}-{key}"
self._attr_device_info = data.device
assert data.history_coordinator
super().__init__(data.history_coordinator, key)
super().__init__(data.history_coordinator, key, data_key)
class TessieWallConnectorEntity(TessieBaseEntity):
@@ -182,6 +163,7 @@ class TessieWallConnectorEntity(TessieBaseEntity):
data: TessieEnergyData,
din: str,
key: str,
data_key: str | None = None,
) -> None:
"""Initialize common aspects of a Teslemetry entity."""
self.din = din
@@ -194,7 +176,7 @@ class TessieWallConnectorEntity(TessieBaseEntity):
serial_number=din.rsplit("-", maxsplit=1)[-1],
)
assert data.live_coordinator
super().__init__(data.live_coordinator, key)
super().__init__(data.live_coordinator, key, data_key)
@property
def _value(self) -> int:

View File

@@ -9,7 +9,6 @@ from tesla_fleet_api.tessie import EnergySite
from homeassistant.helpers.device_registry import DeviceInfo
from .coordinator import (
TessieBatteryHealthCoordinator,
TessieEnergyHistoryCoordinator,
TessieEnergySiteInfoCoordinator,
TessieEnergySiteLiveCoordinator,
@@ -42,6 +41,5 @@ class TessieVehicleData:
"""Data for a Tessie vehicle."""
data_coordinator: TessieStateUpdateCoordinator
battery_coordinator: TessieBatteryHealthCoordinator
device: DeviceInfo
vin: str

View File

@@ -41,7 +41,6 @@ from .const import (
TessieWallConnectorStates,
)
from .entity import (
TessieBatteryEntity,
TessieEnergyEntity,
TessieEnergyHistoryEntity,
TessieEntity,
@@ -62,6 +61,7 @@ def minutes_to_datetime(value: StateType) -> datetime | None:
class TessieSensorEntityDescription(SensorEntityDescription):
"""Describes Tessie Sensor entity."""
data_key: str | None = None
value_fn: Callable[[StateType], StateType | datetime] = lambda x: x
available_fn: Callable[[StateType], bool] = lambda _: True
@@ -142,6 +142,14 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
suggested_display_precision=1,
entity_registry_enabled_default=False,
),
TessieSensorEntityDescription(
key="phantom_drain_percent",
data_key="charge_state_phantom_drain",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
),
TessieSensorEntityDescription(
key="charge_state_energy_remaining",
state_class=SensorStateClass.MEASUREMENT,
@@ -150,6 +158,51 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
),
TessieSensorEntityDescription(
key="lifetime_energy_used",
data_key="charge_state_lifetime_energy_used",
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="pack_current",
data_key="charge_state_pack_current",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="pack_voltage",
data_key="charge_state_pack_voltage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="module_temp_min",
data_key="charge_state_module_temp_min",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="module_temp_max",
data_key="charge_state_module_temp_max",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="charge_state_conn_charge_cable",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -290,57 +343,6 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
),
)
BATTERY_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
TessieSensorEntityDescription(
key="phantom_drain_percent",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
),
TessieSensorEntityDescription(
key="lifetime_energy_used",
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="pack_current",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="pack_voltage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="module_temp_min",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
TessieSensorEntityDescription(
key="module_temp_max",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
),
)
ENERGY_LIVE_DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
TessieSensorEntityDescription(
key="solar_power",
@@ -494,12 +496,6 @@ async def async_setup_entry(
for vehicle in entry.runtime_data.vehicles
for description in DESCRIPTIONS
),
( # Add vehicle battery health
TessieBatteryHealthSensorEntity(vehicle, description)
for vehicle in entry.runtime_data.vehicles
for description in BATTERY_DESCRIPTIONS
if description.key in vehicle.battery_coordinator.data
),
( # Add energy site info
TessieEnergyInfoSensorEntity(energysite, description)
for energysite in entry.runtime_data.energysites
@@ -545,7 +541,7 @@ class TessieVehicleSensorEntity(TessieEntity, SensorEntity):
) -> None:
"""Initialize the sensor."""
self.entity_description = description
super().__init__(vehicle, description.key)
super().__init__(vehicle, description.key, description.data_key)
@property
def native_value(self) -> StateType | datetime:
@@ -558,25 +554,6 @@ class TessieVehicleSensorEntity(TessieEntity, SensorEntity):
return super().available and self.entity_description.available_fn(self.get())
class TessieBatteryHealthSensorEntity(TessieBatteryEntity, SensorEntity):
"""Sensor entity for Tessie battery health data."""
entity_description: TessieSensorEntityDescription
def __init__(
self,
vehicle: TessieVehicleData,
description: TessieSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
self.entity_description = description
super().__init__(vehicle, description.key)
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
self._attr_native_value = self.entity_description.value_fn(self._value)
class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity):
"""Base class for Tessie energy site sensor entity."""

View File

@@ -4,8 +4,7 @@ from __future__ import annotations
from datetime import timedelta
from pyvlx.exception import PyVLXException
from pyvlx.opening_device import OpeningDevice, Window
from pyvlx import OpeningDevice, Position, PyVLXException, Window
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -55,7 +54,7 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity):
async def async_update(self) -> None:
"""Fetch the latest state from the device."""
try:
limitation = await self.node.get_limitation()
limitation: Position = await self.node.get_limitation_min()
except (OSError, PyVLXException) as err:
if not self._unavailable_logged:
LOGGER.warning(
@@ -78,4 +77,4 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity):
# So far we've seen 89, 91, 93 (most cases) or 100 (Velux GPU). It probably makes sense to
# assume that any large enough limitation (we use >=89) means rain is detected.
# Documentation on this is non-existent AFAIK.
self._attr_is_on = limitation.min_value >= 89
self._attr_is_on = limitation.position_percent >= 89

View File

@@ -1,7 +1,7 @@
{
"domain": "velux",
"name": "Velux",
"codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio", "@wollew"],
"codeowners": ["@Julius2342", "@pawlizio", "@wollew"],
"config_flow": true,
"dhcp": [
{
@@ -14,5 +14,5 @@
"iot_class": "local_polling",
"loggers": ["pyvlx"],
"quality_scale": "silver",
"requirements": ["pyvlx==0.2.30"]
"requirements": ["pyvlx==0.2.32"]
}

View File

@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
"requirements": ["PyViCare==2.58.0"]
"requirements": ["PyViCare==2.58.1"]
}

View File

@@ -4,10 +4,12 @@ from __future__ import annotations
import logging
from sensor_state_data import SensorUpdate
from victron_ble_ha_parser import VictronBluetoothDeviceData
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
async_rediscover_address,
)
from homeassistant.components.bluetooth.passive_update_processor import (
@@ -17,6 +19,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from .const import REAUTH_AFTER_FAILURES
_LOGGER = logging.getLogger(__name__)
@@ -26,12 +30,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
assert address is not None
key = entry.data[CONF_ACCESS_TOKEN]
data = VictronBluetoothDeviceData(key)
consecutive_failures = 0
def _update(
service_info: BluetoothServiceInfoBleak,
) -> SensorUpdate:
nonlocal consecutive_failures
update = data.update(service_info)
# If the device type was recognized (devices dict populated) but
# only signal strength came back, decryption likely failed.
# Unsupported devices have an empty devices dict and won't trigger this.
if update.devices and len(update.entity_values) <= 1:
consecutive_failures += 1
if consecutive_failures >= REAUTH_AFTER_FAILURES:
_LOGGER.debug(
"Triggering reauth for %s after %d consecutive failures",
address,
consecutive_failures,
)
entry.async_start_reauth(hass)
consecutive_failures = 0
else:
consecutive_failures = 0
return update
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=data.update,
update_method=_update,
)
entry.runtime_data = coordinator

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -123,3 +124,42 @@ class VictronBLEConfigFlow(ConfigFlow, domain=DOMAIN):
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
),
)
async def async_step_reauth(
self, _entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a flow initialized by a reauth event."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation with a new encryption key."""
reauth_entry = self._get_reauth_entry()
errors: dict[str, str] = {}
if user_input is not None:
device = VictronBluetoothDeviceData(user_input[CONF_ACCESS_TOKEN])
# Find the current advertisement data for this device
for discovery_info in async_discovered_service_info(self.hass, False):
if discovery_info.address == reauth_entry.unique_id:
mfr_data = discovery_info.manufacturer_data.get(VICTRON_IDENTIFIER)
if mfr_data is None or not device.validate_advertisement_key(
mfr_data
):
errors["base"] = "invalid_access_token"
break
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_ACCESS_TOKEN: user_input[CONF_ACCESS_TOKEN]},
)
else:
errors["base"] = "no_devices_found"
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA,
description_placeholders={"title": reauth_entry.title},
errors=errors,
)

View File

@@ -1,4 +1,5 @@
"""Constants for the Victron Bluetooth Low Energy integration."""
DOMAIN = "victron_ble"
REAUTH_AFTER_FAILURES = 3
VICTRON_IDENTIFIER = 0x02E1

View File

@@ -8,10 +8,15 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"invalid_access_token": "Invalid encryption key for instant readout",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"not_supported": "Device not supported",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"invalid_access_token": "Invalid encryption key for instant readout"
"invalid_access_token": "Invalid encryption key for instant readout",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"flow_title": "{title}",
"step": {
@@ -24,6 +29,15 @@
},
"title": "{title}"
},
"reauth_confirm": {
"data": {
"access_token": "[%key:component::victron_ble::config::step::access_token::data::access_token%]"
},
"data_description": {
"access_token": "[%key:component::victron_ble::config::step::access_token::data_description::access_token%]"
},
"description": "The encryption key for {title} is invalid or has changed. Please enter the correct key."
},
"user": {
"data": {
"address": "The Bluetooth address of the Victron device."

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