Compare commits

...

90 Commits

Author SHA1 Message Date
mib1185
ee0230f3b1 use renamed helpers 2025-12-17 20:06:03 +00:00
mib1185
851fd467fe Merge branch 'dev' into input_boolean/add-domain-driven-triggers 2025-12-17 20:05:20 +00:00
hanwg
c418d9750b Remove ALLOW_EXTRA from Telegram bot action schema (#158886) 2025-12-17 19:49:34 +01:00
Joost Lekkerkerker
e96d614076 Add integration_type service to meteo_france (#159315) 2025-12-17 19:19:14 +01:00
Abílio Costa
f0a5e0a023 Enable duplicated log file on supervised when env var is set (#158679) 2025-12-17 17:44:54 +00:00
Klaas Schoute
6ac6b86060 Set quality scale in Autarco manifest (#159263) 2025-12-17 16:17:19 +01:00
PaulCavill
3909171b1a Login exception reason (#159259) 2025-12-17 16:13:54 +01:00
Luke Lashley
769029505f Bump python-roborock to 3.18.0 (#159271) 2025-12-17 06:39:06 -08:00
Paul Tarjan
080ec3524b Fix flaky camera stream teardown (#158507)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-12-17 13:47:22 +01:00
Matthew Vance
48d671ad5f Update py-improv-ble-client to 2.0.1 (#159233) 2025-12-17 08:27:06 +01:00
alorente
7115db5d22 Change device class from PRESSURE to ATMOSPHERIC_PRESSURE (#159149) 2025-12-17 07:16:46 +01:00
Jordan Harvey
d0c8792e4b Improve Nintendo Switch parental controls exception handling (#159199) 2025-12-17 07:15:26 +01:00
Richard
84d7c37502 Bump mill-local to 0.5.0 (#159220) 2025-12-16 20:41:28 +01:00
Jordan Harvey
8a10638470 Add select platform to Nintendo Switch parental controls (#159217) 2025-12-16 19:06:43 +01:00
Abílio Costa
10dd53ffc2 Rename base trigger class and methods (#159213) 2025-12-16 18:01:37 +00:00
ryanjones-gentex
36aefce9e1 Store unique user configurations for HomeLink integration (#159111) 2025-12-16 17:14:49 +01:00
Raphael Hehl
fe34da19e2 Use typed HassKey for hass.data in unifiprotect (#158798)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-16 17:12:57 +01:00
Jordan Harvey
fe94dea1db Add missing tests for Nintendo parental controls code coverage (#159210)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-16 17:12:36 +01:00
Anthony Garera
3f57b46756 Add issue sensors to Overseerr integration (#158888) 2025-12-16 17:11:28 +01:00
Raphael Hehl
7e141533bb Improve config flow tests to verify error recovery (#158484)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-16 17:04:38 +01:00
Joost Lekkerkerker
391ccbafae Add integration_type service to ipma (#159179) 2025-12-16 17:04:29 +01:00
epenet
6af674e64e Use is over == comparison for ConfigEntryState in tests (#159212) 2025-12-16 16:51:39 +01:00
Manu
7b1653c77b Migrate friends to subentries in Xbox integration (#156101) 2025-12-16 16:22:28 +01:00
peteS-UK
c87dafa2e6 Create Squeezebox initial Quality Scale entry (#153993) 2025-12-16 15:56:03 +01:00
Abílio Costa
8375acf315 Add device_tracker home enter/leave triggers (#158083) 2025-12-16 14:50:56 +00:00
Paul Tarjan
4df5a41b57 Migrate Hikvision integration to config flow (#158279)
Co-authored-by: Kamil Breguła <mik-laj@users.noreply.github.com>
2025-12-16 15:44:23 +01:00
Niracler
5796b4c0d9 Enhance Sunricher DALI with update gateway IP from DHCP discovery (#157809)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-16 15:19:37 +01:00
Andrew Jackson
5f4f07803b Add a delay to switch statuses on Transmission (#157493) 2025-12-16 15:11:10 +01:00
Richard Polzer
a0a444e3c8 Bump ekey-bionyxpy to version 1.0.1 (#159196) 2025-12-16 14:30:58 +01:00
epenet
30cfe987ed Bump pyinsteon to 1.6.4 (#159067) 2025-12-16 14:29:06 +01:00
Siemon Geeroms
412ee0da05 Adds continuous play support to Plex integration (#158281) 2025-12-16 14:20:03 +01:00
Ludovic BOUÉ
d6b675138d Bump python-matter-server dependency to version 8.1.2 (#159198) 2025-12-16 14:07:34 +01:00
Joost Lekkerkerker
bde3cef17d Add integration_type service to imgw_pib (#159172) 2025-12-16 14:04:51 +01:00
starkillerOG
412ee30584 Do not check Reolink firmware at start (#158275) 2025-12-16 13:27:09 +01:00
Abílio Costa
7eecdc87fd Add lookup caching to get_x_for_target (#157888) 2025-12-16 12:17:58 +00:00
Jordan Harvey
9ba252d8e3 Bump pynintendoparental 2.1.3 (#159120)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-12-16 12:09:35 +00:00
johanzander
1709a9d255 Add services for managing Time-of-Use (TOU) schedule for Growatt integration (#154703)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-16 11:56:21 +01:00
Joost Lekkerkerker
bcf46f09a2 Add integration_type device to kegtron (#159187) 2025-12-16 11:51:57 +01:00
Joost Lekkerkerker
d4097a8686 Add integration_type device to keenetic_ndms2 (#159186) 2025-12-16 11:50:53 +01:00
Joost Lekkerkerker
2a92292e76 Add integration_type device to kaleidescape (#159185) 2025-12-16 11:49:26 +01:00
Joost Lekkerkerker
fe987a63d6 Add integration_type device to justnimbus (#159184) 2025-12-16 11:48:15 +01:00
Joost Lekkerkerker
91f3b991ba Add integration_type hub to izone (#159183) 2025-12-16 11:47:22 +01:00
Joost Lekkerkerker
46c6313068 Add integration_type service to israel_rail (#159181) 2025-12-16 11:46:33 +01:00
Joost Lekkerkerker
86e4a81934 Add integration_type service to ista_ecotrend (#159182) 2025-12-16 11:46:15 +01:00
Joost Lekkerkerker
234d6ae161 Add integration_type hub to insteon (#159176) 2025-12-16 11:45:06 +01:00
Joost Lekkerkerker
2ab203618e Add integration_type device to intellifire (#159177) 2025-12-16 11:44:06 +01:00
Joost Lekkerkerker
faae23ee1b Add integration_type device to iotawatt (#159178) 2025-12-16 11:42:59 +01:00
Joost Lekkerkerker
f6acd4f230 Add integration_type service to islamic_prayer_times (#159180) 2025-12-16 11:37:33 +01:00
Joost Lekkerkerker
71d36a6496 Add integration_type device to inkbird (#159175) 2025-12-16 11:36:56 +01:00
Joost Lekkerkerker
9fc014c6f4 Add integration_type hub to inels (#159174) 2025-12-16 11:35:58 +01:00
Joost Lekkerkerker
537f93872c Add integration_type service to imap (#159171) 2025-12-16 11:32:48 +01:00
Joost Lekkerkerker
06a55175a8 Add integration_type device to ialarm (#159166) 2025-12-16 11:31:44 +01:00
Joost Lekkerkerker
5f37016baa Add integration_type hub to icloud (#159169) 2025-12-16 11:30:44 +01:00
Joost Lekkerkerker
1af884293f Add integration_type hub to igloohome (#159170) 2025-12-16 11:30:23 +01:00
Joost Lekkerkerker
ba73ab38e8 Add integration_type hub to iaqualink (#159168) 2025-12-16 11:29:41 +01:00
Artur Pragacz
2d33a720f7 Modernise condition checker in helper (#159159) 2025-12-16 10:46:10 +01:00
epenet
dbfdaf6a2e Use is over == comparison for FlowResultType in flow tests (#159158) 2025-12-16 09:48:45 +01:00
Artur Pragacz
278cb4d3ae Add integration type to sun (#159146) 2025-12-16 09:38:50 +01:00
Przemko92
1c6f8b7e54 Update compit-inext-api to 0.3.4 (#158821)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-12-16 09:15:00 +01:00
epenet
731f5078a6 Fix actron_air config_flow test (#159157) 2025-12-16 09:10:26 +01:00
Federico Imberti
9863d3484d Prevent empty aliases in registries (#156061)
Co-authored-by: J. Diego Rodríguez Royo <jdrr1998@hotmail.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-12-15 22:28:53 +01:00
Åke Strandberg
f85a684e31 Handle missing Miele status codes gracefully (#159124) 2025-12-15 19:58:02 +00:00
inventor7777
e292a67692 Increase maximum screensaver time for Fully Kiosk (#159122) 2025-12-15 20:34:14 +01:00
vexofp
c82d159c14 Add enum options for Octoprint status sensor (#157213) 2025-12-15 20:24:19 +01:00
Joost Lekkerkerker
d890387d3d Add integration_type hub to hive (#159126) 2025-12-15 20:21:10 +01:00
Joost Lekkerkerker
d996d7b113 Add integration_type service to hko (#159127) 2025-12-15 20:20:23 +01:00
Joost Lekkerkerker
d28a4598d5 Add integration_type device to hlk_sw16 (#159128) 2025-12-15 20:19:46 +01:00
Joost Lekkerkerker
229f7c4f37 Add integration_type hub to homematicip_cloud (#159129) 2025-12-15 20:18:27 +01:00
Joost Lekkerkerker
9f2138aa18 Add integration_type hub to homeworks (#159130) 2025-12-15 20:17:49 +01:00
Joost Lekkerkerker
7506ff826c Add integration_type hub to honeywell (#159131) 2025-12-15 20:17:09 +01:00
Joost Lekkerkerker
317a3ed044 Add integration_type device to huawei_lte (#159132) 2025-12-15 20:16:19 +01:00
Frederic Mariën
d7801881e9 Add Risco set_time service (#139015) 2025-12-15 20:14:49 +01:00
Joost Lekkerkerker
a4bbdafd55 Add integration_type hub to hunterdouglas_powerview (#159134) 2025-12-15 20:12:32 +01:00
Joost Lekkerkerker
97673f22cb Add integration_type device to husqvarna_automower_ble (#159135) 2025-12-15 20:11:38 +01:00
Joost Lekkerkerker
d63cdafad2 Add integration_type device to huum (#159136) 2025-12-15 20:10:58 +01:00
Joost Lekkerkerker
50f47a7397 Add integration_type service to hvv_departures (#159137) 2025-12-15 20:09:15 +01:00
Kurt Chrisford
123d573274 Actron Air Integration: Add reauthentication flow (#158246)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-15 20:08:57 +01:00
Joost Lekkerkerker
64ccde6709 Add integration_type hub to hydrawise (#159138) 2025-12-15 20:08:38 +01:00
Pete Sage
c69ef7e1f6 Sonos fix media player join to avoid race condition (#159106) 2025-12-15 20:04:55 +01:00
Davide
d51cca3325 Fix Philips TV channel logos not displaying in media browser (#158975) 2025-12-15 20:04:41 +01:00
Allen Porter
2679ac3f5e Add support for dynamic nest devices and remove stale devices (#159060)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-15 19:58:25 +01:00
Retha Runolfsson
47f476af32 Remove the restriction that Bluetooth login to the Switchbot account is only possible in active mode (#157154) 2025-12-15 19:55:43 +01:00
Pete Sage
ca3d03131e Bump soco to 0.30.13 for Sonos (#159123) 2025-12-15 19:50:39 +01:00
Josef Zweck
a3f3586b02 Add option to enable offline mode to lamarzocco (#159094)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-15 19:22:50 +01:00
Abílio Costa
0ced960d1d Add button pressed trigger (#158745) 2025-12-15 18:02:30 +00:00
Michael
78f1b434b3 Add update became available trigger (#158984) 2025-12-15 18:31:51 +01:00
Joost Lekkerkerker
563fa8f958 Add integration_type device to enphase_envoy (#159006)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-15 18:11:45 +01:00
RSDynamics
8de6f04829 Change Lektrico lifetime_energy sensor to float (#158880) 2025-12-15 17:42:42 +01:00
epenet
f74128de49 Drop supports_action in Tuya alarm_control_panel wrapper (#159118) 2025-12-15 17:41:47 +01:00
mib1185
d10148a175 add turned_off and turned_on triggers 2025-12-12 20:53:03 +00:00
322 changed files with 8206 additions and 1413 deletions

1
CODEOWNERS generated
View File

@@ -665,6 +665,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/here_travel_time/ @eifinger
/tests/components/here_travel_time/ @eifinger
/homeassistant/components/hikvision/ @mezz64
/tests/components/hikvision/ @mezz64
/homeassistant/components/hikvisioncam/ @fbradyirl
/homeassistant/components/hisense_aehw4a1/ @bannhead
/tests/components/hisense_aehw4a1/ @bannhead

View File

@@ -624,13 +624,16 @@ async def async_enable_logging(
if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ:
# Rename the default log file if it exists, since previous versions created
# it even on Supervisor
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
def rename_old_file() -> None:
"""Rename old log file in executor."""
if os.path.isfile(default_log_path):
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
await hass.async_add_executor_job(rename_old_file)
err_log_path = None
else:
err_log_path = default_log_path

View File

@@ -9,8 +9,9 @@ from actron_neo_api import (
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import _LOGGER
from .const import _LOGGER, DOMAIN
from .coordinator import (
ActronAirConfigEntry,
ActronAirRuntimeData,
@@ -29,12 +30,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
try:
systems = await api.get_ac_systems()
await api.update_status()
except ActronAirAuthError:
_LOGGER.error("Authentication error while setting up Actron Air integration")
raise
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except ActronAirAPIError as err:
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
raise
raise ConfigEntryNotReady from err
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:

View File

@@ -1,11 +1,12 @@
"""Setup config flow for Actron Air integration."""
import asyncio
from collections.abc import Mapping
from typing import Any
from actron_neo_api import ActronAirAPI, ActronAirAuthError
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError
@@ -95,8 +96,16 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
# Check if this is a reauth flow
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_updates={CONF_API_TOKEN: self._api.refresh_token_value},
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value},
@@ -114,6 +123,21 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
del self.login_task
return await self.async_step_user()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication request."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is not None:
return await self.async_step_user()
return self.async_show_form(step_id="reauth_confirm")
async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@@ -5,16 +5,23 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from actron_neo_api import ActronAirACSystem, ActronAirAPI, ActronAirStatus
from actron_neo_api import (
ActronAirACSystem,
ActronAirAPI,
ActronAirAuthError,
ActronAirStatus,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import _LOGGER
from .const import _LOGGER, DOMAIN
STALE_DEVICE_TIMEOUT = timedelta(hours=24)
SCAN_INTERVAL = timedelta(seconds=30)
STALE_DEVICE_TIMEOUT = timedelta(minutes=5)
ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
ERROR_UNKNOWN = "unknown_error"
@@ -29,9 +36,6 @@ class ActronAirRuntimeData:
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
AUTH_ERROR_THRESHOLD = 3
SCAN_INTERVAL = timedelta(seconds=30)
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
"""System coordinator for Actron Air integration."""
@@ -59,7 +63,14 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
async def _async_update_data(self) -> ActronAirStatus:
"""Fetch updates and merge incremental changes into the full state."""
await self.api.update_status()
try:
await self.api.update_status()
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow()
return self.status

View File

@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["actron-neo-api==0.1.87"]
"requirements": ["actron-neo-api==0.2.0"]
}

View File

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

View File

@@ -2,10 +2,12 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"oauth2_error": "Failed to start OAuth2 flow"
"oauth2_error": "Failed to start authentication flow",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured."
},
"error": {
"oauth2_error": "Failed to start OAuth2 flow. Please try again later."
"oauth2_error": "Failed to start authentication flow. Please try again later."
},
"progress": {
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
@@ -16,14 +18,23 @@
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
"title": "Connection error"
},
"reauth_confirm": {
"description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.",
"title": "Authentication expired"
},
"timeout": {
"data": {},
"description": "The authorization process timed out. Please try again.",
"title": "Authorization timeout"
"description": "The authentication process timed out. Please try again.",
"title": "Authentication timeout"
},
"user": {
"title": "Actron Air OAuth2 Authorization"
"title": "Actron Air Authentication"
}
}
},
"exceptions": {
"auth_error": {
"message": "Authentication failed, please reauthenticate"
}
}
}

View File

@@ -4,10 +4,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
EntityStateTriggerBase,
EntityTargetStateTriggerBase,
Trigger,
make_conditional_entity_state_trigger,
make_entity_state_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
@@ -21,7 +21,7 @@ def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool
return False
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_required_features: int
@@ -38,7 +38,7 @@ class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityStateTriggerBase]:
) -> type[EntityTargetStateTriggerBase]:
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
@@ -52,7 +52,7 @@ def make_entity_state_trigger_required_features(
TRIGGERS: dict[str, type[Trigger]] = {
"armed": make_conditional_entity_state_trigger(
"armed": make_entity_transition_trigger(
DOMAIN,
from_states={
AlarmControlPanelState.ARMING,
@@ -89,8 +89,12 @@ TRIGGERS: dict[str, type[Trigger]] = {
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
"disarmed": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.DISARMED
),
"triggered": make_entity_target_state_trigger(
DOMAIN, AlarmControlPanelState.TRIGGERED
),
}

View File

@@ -1,16 +1,22 @@
"""Provides triggers for assist satellites."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN
from .entity import AssistSatelliteState
TRIGGERS: dict[str, type[Trigger]] = {
"idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING),
"processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING),
"responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING),
"idle": make_entity_target_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.LISTENING
),
"processing": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.PROCESSING
),
"responding": make_entity_target_state_trigger(
DOMAIN, AssistSatelliteState.RESPONDING
),
}

View File

@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/autarco",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["autarco==3.2.0"]
}

View File

@@ -125,14 +125,18 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"binary_sensor",
"button",
"climate",
"cover",
"device_tracker",
"fan",
"input_boolean",
"lawn_mower",
"light",
"media_player",
"switch",
"text",
"update",
"vacuum",
}

View File

@@ -4,7 +4,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import DOMAIN, BinarySensorDeviceClass
@@ -20,7 +20,7 @@ def get_device_class_or_undefined(
return UNDEFINED
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None

View File

@@ -17,5 +17,10 @@
"press": {
"service": "mdi:gesture-tap-button"
}
},
"triggers": {
"pressed": {
"trigger": "mdi:gesture-tap-button"
}
}
}

View File

@@ -27,5 +27,11 @@
"name": "Press"
}
},
"title": "Button"
"title": "Button",
"triggers": {
"pressed": {
"description": "Triggers when a button was pressed",
"name": "Button pressed"
}
}
}

View File

@@ -0,0 +1,42 @@
"""Provides triggers for buttons."""
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import DOMAIN
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domain = DOMAIN
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
"pressed": ButtonPressedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for buttons."""
return TRIGGERS

View File

@@ -0,0 +1,4 @@
pressed:
target:
entity:
domain: button

View File

@@ -3,22 +3,22 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_conditional_entity_state_trigger,
make_entity_state_attribute_trigger,
make_entity_state_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
TRIGGERS: dict[str, type[Trigger]] = {
"started_cooling": make_entity_state_attribute_trigger(
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),
"started_drying": make_entity_state_attribute_trigger(
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_conditional_entity_state_trigger(
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(
DOMAIN,
from_states={
HVACMode.OFF,
@@ -32,7 +32,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
HVACMode.HEAT_COOL,
},
),
"started_heating": make_entity_state_attribute_trigger(
"started_heating": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
),
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["compit"],
"quality_scale": "bronze",
"requirements": ["compit-inext-api==0.3.1"]
"requirements": ["compit-inext-api==0.3.4"]
}

View File

@@ -65,8 +65,10 @@ def websocket_create_area(
data.pop("id")
if "aliases" in data:
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
if "labels" in data:
# Convert labels to a set
@@ -133,8 +135,10 @@ def websocket_update_area(
data.pop("id")
if "aliases" in data:
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
if "labels" in data:
# Convert labels to a set

View File

@@ -227,8 +227,10 @@ def websocket_update_entity(
changes[key] = msg[key]
if "aliases" in msg:
# Convert aliases to a set
changes["aliases"] = set(msg["aliases"])
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
changes["aliases"] = {s_strip for s in msg["aliases"] if (s_strip := s.strip())}
if "labels" in msg:
# Convert labels to a set

View File

@@ -61,8 +61,10 @@ def websocket_create_floor(
data.pop("id")
if "aliases" in data:
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
try:
entry = registry.async_create(**data)
@@ -117,8 +119,10 @@ def websocket_update_floor(
data.pop("id")
if "aliases" in data:
# Convert aliases to a set
data["aliases"] = set(data["aliases"])
# Create a set for the aliases without:
# - Empty strings
# - Trailing and leading whitespace characters in the individual aliases
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
try:
entry = registry.async_update(**data)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol
from typing import Any, Protocol
import voluptuous as vol
@@ -11,18 +11,15 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import (
Condition,
ConditionChecker,
ConditionCheckerType,
ConditionConfig,
trace_condition_function,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import DeviceAutomationType, async_get_device_automation_platform
from .helpers import async_validate_device_automation_config
if TYPE_CHECKING:
from homeassistant.helpers import condition
class DeviceAutomationConditionProtocol(Protocol):
"""Define the format of device_condition modules.
@@ -90,15 +87,21 @@ class DeviceCondition(Condition):
assert config.options is not None
self._config = config.options
async def async_get_checker(self) -> condition.ConditionCheckerType:
async def async_get_checker(self) -> ConditionChecker:
"""Test a device condition."""
platform = await async_get_device_automation_platform(
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
)
return trace_condition_function(
platform.async_condition_from_config(self._hass, self._config)
platform_checker = platform.async_condition_from_config(
self._hass, self._config
)
def checker(variables: TemplateVarsType = None, **kwargs: Any) -> bool:
result = platform_checker(self._hass, variables)
return result is not False
return checker
CONDITIONS: dict[str, type[Condition]] = {
"_device": DeviceCondition,

View File

@@ -11,5 +11,13 @@
"see": {
"service": "mdi:account-eye"
}
},
"triggers": {
"entered_home": {
"trigger": "mdi:account-arrow-left"
},
"left_home": {
"trigger": "mdi:account-arrow-right"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"condition_type": {
"is_home": "{entity_name} is home",
@@ -44,6 +48,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"see": {
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
@@ -80,5 +93,27 @@
"name": "See"
}
},
"title": "Device tracker"
"title": "Device tracker",
"triggers": {
"entered_home": {
"description": "Triggers when one or more device trackers enter home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
}
},
"name": "Entered home"
},
"left_home": {
"description": "Triggers when one or more device trackers leave home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
}
},
"name": "Left home"
}
}
}

View File

@@ -0,0 +1,21 @@
"""Provides triggers for device_trackers."""
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_origin_state_trigger,
make_entity_target_state_trigger,
)
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"entered_home": make_entity_target_state_trigger(DOMAIN, STATE_HOME),
"left_home": make_entity_origin_state_trigger(DOMAIN, from_state=STATE_HOME),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for device trackers."""
return TRIGGERS

View File

@@ -0,0 +1,18 @@
.trigger_common: &trigger_common
target:
entity:
domain: device_tracker
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
entered_home: *trigger_common
left_home: *trigger_common

View File

@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["ekey-bionyxpy==1.0.0"]
"requirements": ["ekey-bionyxpy==1.0.1"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@bdraco", "@cgarwood", "@catsmanac"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",

View File

@@ -2,13 +2,13 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -17,7 +17,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
NumberEntityDescription(
key="timeToScreensaverV2",
translation_key="screensaver_time",
native_max_value=9999,
native_max_value=86400,
native_step=1,
native_min_value=0,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -34,7 +34,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
NumberEntityDescription(
key="timeToScreenOffV2",
translation_key="screen_off_time",
native_max_value=9999,
native_max_value=86400,
native_step=1,
native_min_value=0,
native_unit_of_measurement=UnitOfTime.SECONDS,

View File

@@ -5,6 +5,7 @@ from typing import Any
import botocore.exceptions
from homelink.auth.srp_auth import SRPAuth
import jwt
import voluptuous as vol
from homeassistant.config_entries import ConfigFlowResult
@@ -38,8 +39,6 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Ask for username and password."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
srp_auth = SRPAuth()
try:
tokens = await self.hass.async_add_executor_job(
@@ -48,12 +47,17 @@ class SRPFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
user_input[CONF_PASSWORD],
)
except botocore.exceptions.ClientError:
_LOGGER.exception("Error authenticating homelink account")
errors["base"] = "srp_auth_failed"
except Exception:
_LOGGER.exception("An unexpected error occurred")
errors["base"] = "unknown"
else:
access_token = jwt.decode(
tokens["AuthenticationResult"]["AccessToken"],
options={"verify_signature": False},
)
await self.async_set_unique_id(access_token["sub"])
self._abort_if_unique_id_configured()
self.external_data = {"tokens": tokens}
return await self.async_step_creation()

View File

@@ -1,10 +1,9 @@
"""Makes requests to the state server and stores the resulting data so that the buttons can access it."""
"""Establish MQTT connection and listen for event data."""
from __future__ import annotations
from collections.abc import Callable
from functools import partial
import logging
from typing import TypedDict
from homelink.model.device import Device
@@ -14,8 +13,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.ssl import get_default_context
_LOGGER = logging.getLogger(__name__)
type HomeLinkConfigEntry = ConfigEntry[HomeLinkCoordinator]
type EventCallback = Callable[[HomeLinkEventData], None]

View File

@@ -10,6 +10,8 @@ from requests import RequestException
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
AUTH_API_TOKEN,
@@ -19,14 +21,25 @@ from .const import (
DEFAULT_PLANT_ID,
DEFAULT_URL,
DEPRECATED_URLS,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
PLATFORMS,
)
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
from .services import async_register_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Growatt Server component."""
# Register services
await async_register_services(hass)
return True
def get_device_list_classic(
api: growattServer.GrowattApi, config: Mapping[str, str]

View File

@@ -46,3 +46,8 @@ ERROR_INVALID_AUTH = "invalid_auth"
# Config flow abort reasons
ABORT_NO_PLANTS = "no_plants"
# Battery modes for TOU (Time of Use) settings
BATT_MODE_LOAD_FIRST = 0
BATT_MODE_BATTERY_FIRST = 1
BATT_MODE_GRID_FIRST = 2

View File

@@ -12,10 +12,17 @@ import growattServer
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DEFAULT_URL, DOMAIN
from .const import (
BATT_MODE_BATTERY_FIRST,
BATT_MODE_GRID_FIRST,
BATT_MODE_LOAD_FIRST,
DEFAULT_URL,
DOMAIN,
)
from .models import GrowattRuntimeData
if TYPE_CHECKING:
@@ -247,3 +254,134 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.previous_values[variable] = return_value
return return_value
async def update_time_segment(
self, segment_id: int, batt_mode: int, start_time, end_time, enabled: bool
) -> None:
"""Update an inverter time segment.
Args:
segment_id: Time segment ID (1-9)
batt_mode: Battery mode (0=load first, 1=battery first, 2=grid first)
start_time: Start time (datetime.time object)
end_time: End time (datetime.time object)
enabled: Whether the segment is enabled
"""
_LOGGER.debug(
"Updating time segment %d for device %s (mode=%d, %s-%s, enabled=%s)",
segment_id,
self.device_id,
batt_mode,
start_time,
end_time,
enabled,
)
if self.api_version != "v1":
raise ServiceValidationError(
"Updating time segments requires token authentication"
)
try:
# Use V1 API for token authentication
# The library's _process_response will raise GrowattV1ApiError if error_code != 0
await self.hass.async_add_executor_job(
self.api.min_write_time_segment,
self.device_id,
segment_id,
batt_mode,
start_time,
end_time,
enabled,
)
except growattServer.GrowattV1ApiError as err:
raise HomeAssistantError(f"API error updating time segment: {err}") from err
# Update coordinator's cached data without making an API call (avoids rate limit)
if self.data:
# Update the time segment data in the cache
self.data[f"forcedTimeStart{segment_id}"] = start_time.strftime("%H:%M")
self.data[f"forcedTimeStop{segment_id}"] = end_time.strftime("%H:%M")
self.data[f"time{segment_id}Mode"] = batt_mode
self.data[f"forcedStopSwitch{segment_id}"] = 1 if enabled else 0
# Notify entities of the updated data (no API call)
self.async_set_updated_data(self.data)
async def read_time_segments(self) -> list[dict]:
"""Read time segments from an inverter.
Returns:
List of dictionaries containing segment information
"""
_LOGGER.debug("Reading time segments for device %s", self.device_id)
if self.api_version != "v1":
raise ServiceValidationError(
"Reading time segments requires token authentication"
)
# Ensure we have current data
if not self.data:
_LOGGER.debug("Coordinator data not available, triggering refresh")
await self.async_refresh()
time_segments = []
# Extract time segments from coordinator data
for i in range(1, 10): # Segments 1-9
segment = self._parse_time_segment(i)
time_segments.append(segment)
return time_segments
def _parse_time_segment(self, segment_id: int) -> dict:
"""Parse a single time segment from coordinator data."""
# Get raw time values - these should always be present from the API
start_time_raw = self.data.get(f"forcedTimeStart{segment_id}")
end_time_raw = self.data.get(f"forcedTimeStop{segment_id}")
# Handle 'null' or empty values from API
if start_time_raw in ("null", None, ""):
start_time_raw = "0:0"
if end_time_raw in ("null", None, ""):
end_time_raw = "0:0"
# Format times with leading zeros (HH:MM)
start_time = self._format_time(str(start_time_raw))
end_time = self._format_time(str(end_time_raw))
# Get battery mode
batt_mode_int = int(
self.data.get(f"time{segment_id}Mode", BATT_MODE_LOAD_FIRST)
)
# Map numeric mode to string key (matches update_time_segment input format)
mode_map = {
BATT_MODE_LOAD_FIRST: "load_first",
BATT_MODE_BATTERY_FIRST: "battery_first",
BATT_MODE_GRID_FIRST: "grid_first",
}
batt_mode = mode_map.get(batt_mode_int, "load_first")
# Get enabled status
enabled = bool(int(self.data.get(f"forcedStopSwitch{segment_id}", 0)))
return {
"segment_id": segment_id,
"start_time": start_time,
"end_time": end_time,
"batt_mode": batt_mode,
"enabled": enabled,
}
def _format_time(self, time_raw: str) -> str:
"""Format time string to HH:MM format."""
try:
parts = str(time_raw).split(":")
hour = int(parts[0])
minute = int(parts[1])
except (ValueError, IndexError):
return "00:00"
else:
return f"{hour:02d}:{minute:02d}"

View File

@@ -0,0 +1,10 @@
{
"services": {
"read_time_segments": {
"service": "mdi:clock-outline"
},
"update_time_segment": {
"service": "mdi:clock-edit"
}
}
}

View File

@@ -0,0 +1,169 @@
"""Service handlers for Growatt Server integration."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from .const import (
BATT_MODE_BATTERY_FIRST,
BATT_MODE_GRID_FIRST,
BATT_MODE_LOAD_FIRST,
DOMAIN,
)
if TYPE_CHECKING:
from .coordinator import GrowattCoordinator
async def async_register_services(hass: HomeAssistant) -> None:
"""Register services for Growatt Server integration."""
def get_min_coordinators() -> dict[str, GrowattCoordinator]:
"""Get all MIN coordinators with V1 API from loaded config entries."""
min_coordinators: dict[str, GrowattCoordinator] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.state != ConfigEntryState.LOADED:
continue
# Add MIN coordinators from this entry
for coord in entry.runtime_data.devices.values():
if coord.device_type == "min" and coord.api_version == "v1":
min_coordinators[coord.device_id] = coord
return min_coordinators
def get_coordinator(device_id: str) -> GrowattCoordinator:
"""Get coordinator by device_id.
Args:
device_id: Device registry ID (not serial number)
"""
# Get current coordinators (they may have changed since service registration)
min_coordinators = get_min_coordinators()
if not min_coordinators:
raise ServiceValidationError(
"No MIN devices with token authentication are configured. "
"Services require MIN devices with V1 API access."
)
# Device registry ID provided - map to serial number
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")
# Extract serial number from device identifiers
serial_number = None
for identifier in device_entry.identifiers:
if identifier[0] == DOMAIN:
serial_number = identifier[1]
break
if not serial_number:
raise ServiceValidationError(
f"Device '{device_id}' is not a Growatt device"
)
# Find coordinator by serial number
if serial_number not in min_coordinators:
raise ServiceValidationError(
f"MIN device '{serial_number}' not found or not configured for services"
)
return min_coordinators[serial_number]
async def handle_update_time_segment(call: ServiceCall) -> None:
"""Handle update_time_segment service call."""
segment_id: int = int(call.data["segment_id"])
batt_mode_str: str = call.data["batt_mode"]
start_time_str: str = call.data["start_time"]
end_time_str: str = call.data["end_time"]
enabled: bool = call.data["enabled"]
device_id: str = call.data["device_id"]
# Validate segment_id range
if not 1 <= segment_id <= 9:
raise ServiceValidationError(
f"segment_id must be between 1 and 9, got {segment_id}"
)
# Validate and convert batt_mode string to integer
valid_modes = {
"load_first": BATT_MODE_LOAD_FIRST,
"battery_first": BATT_MODE_BATTERY_FIRST,
"grid_first": BATT_MODE_GRID_FIRST,
}
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}'"
)
batt_mode: int = valid_modes[batt_mode_str]
# Convert time strings to datetime.time objects
# UI time selector sends HH:MM:SS, but we only need HH:MM (strip seconds)
try:
# Take only HH:MM part (ignore seconds if present)
start_parts = start_time_str.split(":")
start_time_hhmm = f"{start_parts[0]}:{start_parts[1]}"
start_time = datetime.strptime(start_time_hhmm, "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
"start_time must be in HH:MM or HH:MM:SS format"
) from err
try:
# Take only HH:MM part (ignore seconds if present)
end_parts = end_time_str.split(":")
end_time_hhmm = f"{end_parts[0]}:{end_parts[1]}"
end_time = datetime.strptime(end_time_hhmm, "%H:%M").time()
except (ValueError, IndexError) as err:
raise ServiceValidationError(
"end_time must be in HH:MM or HH:MM:SS format"
) from err
# Get the appropriate MIN coordinator
coordinator: GrowattCoordinator = get_coordinator(device_id)
await coordinator.update_time_segment(
segment_id,
batt_mode,
start_time,
end_time,
enabled,
)
async def handle_read_time_segments(call: ServiceCall) -> dict[str, Any]:
"""Handle read_time_segments service call."""
device_id: str = call.data["device_id"]
# Get the appropriate MIN coordinator
coordinator: GrowattCoordinator = get_coordinator(device_id)
time_segments: list[dict[str, Any]] = await coordinator.read_time_segments()
return {"time_segments": time_segments}
# Register services without schema - services.yaml will provide UI definition
# Schema validation happens in the handler functions
hass.services.async_register(
DOMAIN,
"update_time_segment",
handle_update_time_segment,
supports_response=SupportsResponse.NONE,
)
hass.services.async_register(
DOMAIN,
"read_time_segments",
handle_read_time_segments,
supports_response=SupportsResponse.ONLY,
)

View File

@@ -0,0 +1,50 @@
# Service definitions for Growatt Server integration
update_time_segment:
fields:
segment_id:
required: true
example: 1
selector:
number:
min: 1
max: 9
mode: box
batt_mode:
required: true
example: "load_first"
selector:
select:
options:
- "load_first"
- "battery_first"
- "grid_first"
translation_key: batt_mode
start_time:
required: true
example: "08:00"
selector:
time:
end_time:
required: true
example: "12:00"
selector:
time:
enabled:
required: true
example: true
selector:
boolean:
device_id:
required: true
selector:
device:
integration: growatt_server
read_time_segments:
fields:
device_id:
required: true
selector:
device:
integration: growatt_server

View File

@@ -523,5 +523,56 @@
}
}
},
"selector": {
"batt_mode": {
"options": {
"battery_first": "Battery first",
"grid_first": "Grid first",
"load_first": "Load first"
}
}
},
"services": {
"read_time_segments": {
"description": "Read all time segments from a supported inverter.",
"fields": {
"device_id": {
"description": "The Growatt device to perform the action on.",
"name": "Device"
}
},
"name": "Read time segments"
},
"update_time_segment": {
"description": "Update a time segment for supported inverters.",
"fields": {
"batt_mode": {
"description": "Battery operation mode for this time segment.",
"name": "Battery mode"
},
"device_id": {
"description": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::description%]",
"name": "[%key:component::growatt_server::services::read_time_segments::fields::device_id::name%]"
},
"enabled": {
"description": "Whether this time segment is active.",
"name": "Enabled"
},
"end_time": {
"description": "End time for the segment (HH:MM format).",
"name": "End time"
},
"segment_id": {
"description": "Time segment ID (1-9).",
"name": "Segment ID"
},
"start_time": {
"description": "Start time for the segment (HH:MM format).",
"name": "Start time"
}
},
"name": "Update time segment"
}
},
"title": "Growatt Server"
}

View File

@@ -1 +1,87 @@
"""The hikvision component."""
"""The Hikvision integration."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from pyhik.hikvision import HikCamera
import requests
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR]
@dataclass
class HikvisionData:
"""Data class for Hikvision runtime data."""
camera: HikCamera
device_id: str
device_name: str
device_type: str
type HikvisionConfigEntry = ConfigEntry[HikvisionData]
async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) -> bool:
"""Set up Hikvision from a config entry."""
host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT]
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
ssl = entry.data[CONF_SSL]
protocol = "https" if ssl else "http"
url = f"{protocol}://{host}"
try:
camera = await hass.async_add_executor_job(
HikCamera, url, port, username, password
)
except requests.exceptions.RequestException as err:
raise ConfigEntryNotReady(f"Unable to connect to {host}") from err
device_id = camera.get_id()
if device_id is None:
raise ConfigEntryNotReady(f"Unable to get device ID from {host}")
device_name = camera.get_name or host
device_type = camera.get_type or "Camera"
entry.runtime_data = HikvisionData(
camera=camera,
device_id=device_id,
device_name=device_name,
device_type=device_type,
)
# Start the event stream
await hass.async_add_executor_job(camera.start_stream)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
# Stop the event stream
await hass.async_add_executor_job(entry.runtime_data.camera.disconnect)
return unload_ok

View File

@@ -2,10 +2,9 @@
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pyhik.hikvision import HikCamera
import voluptuous as vol
from homeassistant.components.binary_sensor import (
@@ -13,6 +12,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_LAST_TRIP_TIME,
CONF_CUSTOMIZE,
@@ -23,27 +23,27 @@ from homeassistant.const import (
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import track_point_in_utc_time
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.dt import utcnow
_LOGGER = logging.getLogger(__name__)
from . import HikvisionConfigEntry
from .const import DEFAULT_PORT, DOMAIN
CONF_IGNORED = "ignored"
DEFAULT_PORT = 80
DEFAULT_IGNORED = False
DEFAULT_DELAY = 0
DEFAULT_IGNORED = False
ATTR_DELAY = "delay"
DEVICE_CLASS_MAP = {
# Device class mapping for Hikvision event types
DEVICE_CLASS_MAP: dict[str, BinarySensorDeviceClass | None] = {
"Motion": BinarySensorDeviceClass.MOTION,
"Line Crossing": BinarySensorDeviceClass.MOTION,
"Field Detection": BinarySensorDeviceClass.MOTION,
@@ -67,6 +67,8 @@ DEVICE_CLASS_MAP = {
"Entering Region": BinarySensorDeviceClass.MOTION,
}
_LOGGER = logging.getLogger(__name__)
CUSTOMIZE_SCHEMA = vol.Schema(
{
vol.Optional(CONF_IGNORED, default=DEFAULT_IGNORED): cv.boolean,
@@ -88,214 +90,144 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
}
)
PARALLEL_UPDATES = 0
def setup_platform(
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Hikvision binary sensor devices."""
name = config.get(CONF_NAME)
host = config[CONF_HOST]
port = config[CONF_PORT]
username = config[CONF_USERNAME]
password = config[CONF_PASSWORD]
"""Set up the Hikvision binary sensor platform from YAML."""
# Trigger the import flow to migrate YAML config to config entry
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
customize = config[CONF_CUSTOMIZE]
protocol = "https" if config[CONF_SSL] else "http"
url = f"{protocol}://{host}"
data = HikvisionData(hass, url, port, name, username, password)
if data.sensors is None:
_LOGGER.error("Hikvision event stream has no data, unable to set up")
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result.get('reason')}",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Hikvision",
},
)
return
entities = []
for sensor, channel_list in data.sensors.items():
for channel in channel_list:
# Build sensor name, then parse customize config.
if data.type == "NVR":
sensor_name = f"{sensor.replace(' ', '_')}_{channel[1]}"
else:
sensor_name = sensor.replace(" ", "_")
custom = customize.get(sensor_name.lower(), {})
ignore = custom.get(CONF_IGNORED)
delay = custom.get(CONF_DELAY)
_LOGGER.debug(
"Entity: %s - %s, Options - Ignore: %s, Delay: %s",
data.name,
sensor_name,
ignore,
delay,
)
if not ignore:
entities.append(
HikvisionBinarySensor(hass, sensor, channel[1], data, delay)
)
add_entities(entities)
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Hikvision",
},
)
class HikvisionData:
"""Hikvision device event stream object."""
async def async_setup_entry(
hass: HomeAssistant,
entry: HikvisionConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hikvision binary sensors from a config entry."""
data = entry.runtime_data
camera = data.camera
def __init__(self, hass, url, port, name, username, password):
"""Initialize the data object."""
self._url = url
self._port = port
self._name = name
self._username = username
self._password = password
sensors = camera.current_event_states
if sensors is None or not sensors:
_LOGGER.warning("Hikvision device has no sensors available")
return
# Establish camera
self.camdata = HikCamera(self._url, self._port, self._username, self._password)
if self._name is None:
self._name = self.camdata.get_name
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop_hik)
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.start_hik)
def stop_hik(self, event):
"""Shutdown Hikvision subscriptions and subscription thread on exit."""
self.camdata.disconnect()
def start_hik(self, event):
"""Start Hikvision event stream thread."""
self.camdata.start_stream()
@property
def sensors(self):
"""Return list of available sensors and their states."""
return self.camdata.current_event_states
@property
def cam_id(self):
"""Return device id."""
return self.camdata.get_id
@property
def name(self):
"""Return device name."""
return self._name
@property
def type(self):
"""Return device type."""
return self.camdata.get_type
def get_attributes(self, sensor, channel):
"""Return attribute list for sensor/channel."""
return self.camdata.fetch_attributes(sensor, channel)
async_add_entities(
HikvisionBinarySensor(
entry=entry,
sensor_type=sensor_type,
channel=channel_info[1],
)
for sensor_type, channel_list in sensors.items()
for channel_info in channel_list
)
class HikvisionBinarySensor(BinarySensorEntity):
"""Representation of a Hikvision binary sensor."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, hass, sensor, channel, cam, delay):
"""Initialize the binary_sensor."""
self._hass = hass
self._cam = cam
self._sensor = sensor
def __init__(
self,
entry: HikvisionConfigEntry,
sensor_type: str,
channel: int,
) -> None:
"""Initialize the binary sensor."""
self._data = entry.runtime_data
self._camera = self._data.camera
self._sensor_type = sensor_type
self._channel = channel
if self._cam.type == "NVR":
self._name = f"{self._cam.name} {sensor} {channel}"
# Build unique ID
self._attr_unique_id = f"{self._data.device_id}_{sensor_type}_{channel}"
# Build entity name based on device type
if self._data.device_type == "NVR":
self._attr_name = f"{sensor_type} {channel}"
else:
self._name = f"{self._cam.name} {sensor}"
self._attr_name = sensor_type
self._id = f"{self._cam.cam_id}.{sensor}.{channel}"
# Device info for device registry
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._data.device_id)},
name=self._data.device_name,
manufacturer="Hikvision",
model=self._data.device_type,
)
if delay is None:
self._delay = 0
else:
self._delay = delay
# Set device class
self._attr_device_class = DEVICE_CLASS_MAP.get(sensor_type)
self._timer = None
# Callback ID for pyhik
self._callback_id = f"{self._data.device_id}.{sensor_type}.{channel}"
# Register callback function with pyHik
self._cam.camdata.add_update_callback(self._update_callback, self._id)
def _sensor_state(self):
"""Extract sensor state."""
return self._cam.get_attributes(self._sensor, self._channel)[0]
def _sensor_last_update(self):
"""Extract sensor last update time."""
return self._cam.get_attributes(self._sensor, self._channel)[3]
def _get_sensor_attributes(self) -> tuple[bool, Any, Any, Any]:
"""Get sensor attributes from camera."""
return self._camera.fetch_attributes(self._sensor_type, self._channel)
@property
def name(self):
"""Return the name of the Hikvision sensor."""
return self._name
@property
def unique_id(self):
"""Return a unique ID."""
return self._id
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if sensor is on."""
return self._sensor_state()
return self._get_sensor_attributes()[0]
@property
def device_class(self):
"""Return the class of this sensor, from DEVICE_CLASSES."""
try:
return DEVICE_CLASS_MAP[self._sensor]
except KeyError:
# Sensor must be unknown to us, add as generic
return None
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
attr = {ATTR_LAST_TRIP_TIME: self._sensor_last_update()}
attrs = self._get_sensor_attributes()
return {ATTR_LAST_TRIP_TIME: attrs[3]}
if self._delay != 0:
attr[ATTR_DELAY] = self._delay
async def async_added_to_hass(self) -> None:
"""Register callback when entity is added."""
await super().async_added_to_hass()
return attr
# Register callback with pyhik
self._camera.add_update_callback(self._update_callback, self._callback_id)
def _update_callback(self, msg):
"""Update the sensor's state, if needed."""
_LOGGER.debug("Callback signal from: %s", msg)
if self._delay > 0 and not self.is_on:
# Set timer to wait until updating the state
def _delay_update(now):
"""Timer callback for sensor update."""
_LOGGER.debug(
"%s Called delayed (%ssec) update", self._name, self._delay
)
self.schedule_update_ha_state()
self._timer = None
if self._timer is not None:
self._timer()
self._timer = None
self._timer = track_point_in_utc_time(
self._hass, _delay_update, utcnow() + timedelta(seconds=self._delay)
)
elif self._delay > 0 and self.is_on:
# For delayed sensors kill any callbacks on true events and update
if self._timer is not None:
self._timer()
self._timer = None
self.schedule_update_ha_state()
else:
self.schedule_update_ha_state()
@callback
def _update_callback(self, msg: str) -> None:
"""Update the sensor's state when callback is triggered."""
self.async_write_ha_state()

View File

@@ -0,0 +1,134 @@
"""Config flow for Hikvision integration."""
from __future__ import annotations
import logging
from typing import Any
from pyhik.hikvision import HikCamera
import requests
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
CONF_SSL,
CONF_USERNAME,
)
from homeassistant.helpers.typing import ConfigType
from .const import DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Hikvision."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
ssl = user_input[CONF_SSL]
protocol = "https" if ssl else "http"
url = f"{protocol}://{host}"
try:
camera = await self.hass.async_add_executor_job(
HikCamera, url, port, username, password
)
device_id = camera.get_id()
device_name = camera.get_name
except requests.exceptions.RequestException:
_LOGGER.exception("Error connecting to Hikvision device")
errors["base"] = "cannot_connect"
else:
if device_id is None:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=device_name or host,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_SSL: ssl,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_SSL, default=False): bool,
}
),
errors=errors,
)
async def async_step_import(self, import_data: ConfigType) -> ConfigFlowResult:
"""Handle import from configuration.yaml."""
host = import_data[CONF_HOST]
port = import_data.get(CONF_PORT, DEFAULT_PORT)
username = import_data[CONF_USERNAME]
password = import_data[CONF_PASSWORD]
ssl = import_data.get(CONF_SSL, False)
name = import_data.get(CONF_NAME)
protocol = "https" if ssl else "http"
url = f"{protocol}://{host}"
try:
camera = await self.hass.async_add_executor_job(
HikCamera, url, port, username, password
)
device_id = camera.get_id()
device_name = camera.get_name
except requests.exceptions.RequestException:
_LOGGER.exception(
"Error connecting to Hikvision device during import, aborting"
)
return self.async_abort(reason="cannot_connect")
if device_id is None:
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured()
_LOGGER.warning(
"Importing Hikvision config from configuration.yaml for %s", host
)
return self.async_create_entry(
title=name or device_name or host,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_SSL: ssl,
},
)

View File

@@ -0,0 +1,6 @@
"""Constants for the Hikvision integration."""
DOMAIN = "hikvision"
# Default values
DEFAULT_PORT = 80

View File

@@ -2,7 +2,9 @@
"domain": "hikvision",
"name": "Hikvision",
"codeowners": ["@mezz64"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hikvision",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyhik"],
"quality_scale": "legacy",

View File

@@ -0,0 +1,36 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]",
"ssl": "Use SSL",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of your Hikvision device",
"password": "The password for your Hikvision device",
"port": "The port number for the device (default is 80)",
"ssl": "Enable if your device uses HTTPS",
"username": "The username for your Hikvision device"
},
"description": "Enter your Hikvision device connection details.",
"title": "Set up Hikvision device"
}
}
},
"issues": {
"deprecated_yaml_import_issue": {
"description": "Configuring {integration_title} using YAML is deprecated and the import failed. Please remove the `{domain}` entry from your `configuration.yaml` file and set up the integration manually.",
"title": "YAML import failed"
}
}
}

View File

@@ -7,6 +7,7 @@
"homekit": {
"models": ["HHKBridge*"]
},
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"],
"requirements": ["pyhive-integration==1.0.7"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@MisterCommand"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hko",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["hko==0.3.2"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@jameshilliard"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hlk_sw16",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["hlk_sw16"],
"requirements": ["hlk-sw16==0.0.9"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@hahn-th"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.4.0"]

View File

@@ -4,6 +4,7 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homeworks",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyhomeworks"],
"requirements": ["pyhomeworks==1.1.2"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@rdfurman", "@mkmer"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/honeywell",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["somecomfort"],
"requirements": ["AIOSomecomfort==0.0.35"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@scop", "@fphammerle"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/huawei_lte",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["huawei_lte_api.Session"],
"requirements": ["huawei-lte-api==1.11.0", "url-normalize==2.2.1"],

View File

@@ -16,6 +16,7 @@
"homekit": {
"models": ["PowerView"]
},
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiopvapi"],
"requirements": ["aiopvapi==3.3.0"],

View File

@@ -11,6 +11,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@frwickst", "@vincentwolsink"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/huum",
"integration_type": "device",
"iot_class": "cloud_polling",
"requirements": ["huum==0.8.1"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@vigonotion"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hvv_departures",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pygti"],
"requirements": ["pygti==0.9.4"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@dknowles2", "@thomaskistler", "@ptcryan"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
"requirements": ["pydrawise==2025.9.0"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@RyuzakiKK"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ialarm",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyialarm"],
"requirements": ["pyialarm==2.2.0"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@flz"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/iaqualink",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["iaqualink"],
"requirements": ["iaqualink==0.6.0", "h2==4.3.0"],

View File

@@ -108,7 +108,7 @@ class IcloudAccount:
if self.api.requires_2fa:
# Trigger a new log in to ensure the user enters the 2FA code again.
raise PyiCloudFailedLoginException # noqa: TRY301
raise PyiCloudFailedLoginException("2FA Required") # noqa: TRY301
except PyiCloudFailedLoginException:
self.api = None

View File

@@ -4,6 +4,7 @@
"codeowners": ["@Quentame", "@nzapponi"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/icloud",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"],
"requirements": ["pyicloud==2.2.0"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@keithle888"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/igloohome",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["igloohome-api==0.1.1"]

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["repairs"],
"documentation": "https://www.home-assistant.io/integrations/imap",
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["aioimaplib"],
"requirements": ["aioimaplib==2.0.1"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@bieniu"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==1.6.0"]

View File

@@ -261,7 +261,8 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
if self._can_identify is None:
try:
self._can_identify = await self._try_call(device.can_identify())
await self._try_call(device.ensure_connected())
self._can_identify = device.can_identify
except AbortFlow as err:
return self.async_abort(reason=err.reason)
if self._can_identify:

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/improv_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["py-improv-ble-client==1.0.3"]
"requirements": ["py-improv-ble-client==2.0.1"]
}

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/inels",
"integration_type": "hub",
"iot_class": "local_push",
"mqtt": ["inels/status/#"],
"quality_scale": "bronze",

View File

@@ -61,6 +61,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/inkbird",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["inkbird-ble==1.1.1"]
}

View File

@@ -20,5 +20,13 @@
"turn_on": {
"service": "mdi:toggle-switch"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:toggle-switch-off"
},
"turned_on": {
"trigger": "mdi:toggle-switch"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted input booleans to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::input_boolean::title%]",
@@ -17,6 +21,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"reload": {
"description": "Reloads helpers from the YAML-configuration.",
@@ -35,5 +48,27 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Input boolean"
"title": "Input boolean",
"triggers": {
"turned_off": {
"description": "Triggers after one or more input booleans turn off.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Input boolean turned off"
},
"turned_on": {
"description": "Triggers after one or more input booleans turn on.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Input boolean turned on"
}
}
}

View File

@@ -0,0 +1,17 @@
"""Provides triggers for input booleans."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for input booleans."""
return TRIGGERS

View File

@@ -0,0 +1,18 @@
.trigger_common: &trigger_common
target:
entity:
domain: input_boolean
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -14,10 +14,11 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/insteon",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyinsteon", "pypubsub"],
"requirements": [
"pyinsteon==1.6.3",
"pyinsteon==1.6.4",
"insteon-frontend-home-assistant==0.5.0"
],
"single_config_entry": true,

View File

@@ -9,6 +9,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/intellifire",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["intellifire4py"],
"requirements": ["intellifire4py==4.2.1"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@gtdiehl", "@jyavenard"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/iotawatt",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["iotawattpy"],
"requirements": ["ha-iotawattpy==0.1.2"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@dgomes"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ipma",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["geopy", "pyipma"],
"requirements": ["pyipma==3.0.9"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@engrbm87", "@cpfair"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/islamic_prayer_times",
"integration_type": "service",
"iot_class": "calculated",
"loggers": ["prayer_times_calculator"],
"requirements": ["prayer-times-calculator-offline==1.0.3"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@shaiu"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/israel_rail",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["israelrailapi"],
"requirements": ["israel-rail-api==0.1.4"]

View File

@@ -5,6 +5,7 @@
"codeowners": ["@tr4nt0r"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ista_ecotrend",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyecotrend_ista"],
"quality_scale": "gold",

View File

@@ -7,6 +7,7 @@
"homekit": {
"models": ["iZone"]
},
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pizone"],
"requirements": ["python-izone==1.2.9"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@kvanzuijlen"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/justnimbus",
"integration_type": "device",
"iot_class": "cloud_polling",
"requirements": ["justnimbus==0.7.4"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@SteveEasley"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["pykaleidescape==1.0.2"],
"ssdp": [

View File

@@ -4,6 +4,7 @@
"codeowners": ["@foxel"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/keenetic_ndms2",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["ndms2_client"],
"requirements": ["ndms2-client==0.1.2"],

View File

@@ -11,6 +11,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/kegtron",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["kegtron-ble==1.0.2"]
}

View File

@@ -33,7 +33,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
from .const import CONF_INSTALLATION_KEY, CONF_OFFLINE_MODE, CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import (
LaMarzoccoBluetoothUpdateCoordinator,
LaMarzoccoConfigEntry,
@@ -118,45 +118,51 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
_LOGGER.info(
"Bluetooth device not found during lamarzocco setup, continuing with cloud only"
)
try:
settings = await cloud_client.get_thing_settings(serial)
except AuthFail as ex:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from ex
except (RequestNotSuccessful, TimeoutError) as ex:
_LOGGER.debug(ex, exc_info=True)
if not bluetooth_client:
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="api_error"
) from ex
_LOGGER.debug("Cloud failed, continuing with Bluetooth only", exc_info=True)
else:
gateway_version = version.parse(
settings.firmwares[FirmwareType.GATEWAY].build_version
)
if gateway_version < version.parse("v5.0.9"):
# incompatible gateway firmware, create an issue
ir.async_create_issue(
hass,
DOMAIN,
"unsupported_gateway_firmware",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="unsupported_gateway_firmware",
translation_placeholders={"gateway_version": str(gateway_version)},
)
# Update BLE Token if exists
if settings.ble_auth_token:
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_TOKEN: settings.ble_auth_token,
},
async def _get_thing_settings() -> None:
"""Get thing settings from cloud to verify details and get BLE token."""
try:
settings = await cloud_client.get_thing_settings(serial)
except AuthFail as ex:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from ex
except (RequestNotSuccessful, TimeoutError) as ex:
_LOGGER.debug(ex, exc_info=True)
if not bluetooth_client:
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="api_error"
) from ex
_LOGGER.debug("Cloud failed, continuing with Bluetooth only", exc_info=True)
else:
gateway_version = version.parse(
settings.firmwares[FirmwareType.GATEWAY].build_version
)
if gateway_version < version.parse("v5.0.9"):
# incompatible gateway firmware, create an issue
ir.async_create_issue(
hass,
DOMAIN,
"unsupported_gateway_firmware",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="unsupported_gateway_firmware",
translation_placeholders={"gateway_version": str(gateway_version)},
)
# Update BLE Token if exists
if settings.ble_auth_token:
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_TOKEN: settings.ble_auth_token,
},
)
if not (local_mode := entry.options.get(CONF_OFFLINE_MODE, False)):
await _get_thing_settings()
device = LaMarzoccoMachine(
serial_number=entry.unique_id,
cloud_client=cloud_client,
@@ -170,12 +176,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
)
await asyncio.gather(
coordinators.config_coordinator.async_config_entry_first_refresh(),
coordinators.settings_coordinator.async_config_entry_first_refresh(),
coordinators.schedule_coordinator.async_config_entry_first_refresh(),
coordinators.statistics_coordinator.async_config_entry_first_refresh(),
)
if not local_mode:
await asyncio.gather(
coordinators.config_coordinator.async_config_entry_first_refresh(),
coordinators.settings_coordinator.async_config_entry_first_refresh(),
coordinators.schedule_coordinator.async_config_entry_first_refresh(),
coordinators.statistics_coordinator.async_config_entry_first_refresh(),
)
if local_mode and not bluetooth_client:
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="bluetooth_required_offline"
)
# bt coordinator only if bluetooth client is available
# and after the initial refresh of the config coordinator

View File

@@ -47,7 +47,7 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import create_client_session
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
from .const import CONF_INSTALLATION_KEY, CONF_OFFLINE_MODE, CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import LaMarzoccoConfigEntry
CONF_MACHINE = "machine"
@@ -379,19 +379,30 @@ class LmOptionsFlowHandler(OptionsFlowWithReload):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options for the custom component."""
if user_input:
return self.async_create_entry(title="", data=user_input)
errors: dict[str, str] = {}
if user_input:
if user_input.get(CONF_OFFLINE_MODE) and not user_input.get(
CONF_USE_BLUETOOTH
):
errors[CONF_USE_BLUETOOTH] = "bluetooth_required_offline"
else:
return self.async_create_entry(title="", data=user_input)
options_schema = vol.Schema(
{
vol.Optional(
CONF_USE_BLUETOOTH,
default=self.config_entry.options.get(CONF_USE_BLUETOOTH, True),
): cv.boolean,
vol.Optional(
CONF_OFFLINE_MODE,
default=self.config_entry.options.get(CONF_OFFLINE_MODE, False),
): cv.boolean,
}
)
return self.async_show_form(
step_id="init",
data_schema=options_schema,
errors=errors,
)

View File

@@ -6,3 +6,4 @@ DOMAIN: Final = "lamarzocco"
CONF_USE_BLUETOOTH: Final = "use_bluetooth"
CONF_INSTALLATION_KEY: Final = "installation_key"
CONF_OFFLINE_MODE: Final = "offline_mode"

View File

@@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .const import CONF_OFFLINE_MODE, DOMAIN
SCAN_INTERVAL = timedelta(seconds=60)
SETTINGS_UPDATE_INTERVAL = timedelta(hours=8)
@@ -49,7 +49,8 @@ type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData]
class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
"""Base class for La Marzocco coordinators."""
_default_update_interval = SCAN_INTERVAL
_default_update_interval: timedelta | None = SCAN_INTERVAL
_ignore_offline_mode = False
config_entry: LaMarzoccoConfigEntry
update_success = False
@@ -60,12 +61,17 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
device: LaMarzoccoMachine,
) -> None:
"""Initialize coordinator."""
update_interval = self._default_update_interval
if not self._ignore_offline_mode and entry.options.get(
CONF_OFFLINE_MODE, False
):
update_interval = None
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=self._default_update_interval,
update_interval=update_interval,
)
self.device = device
self._websocket_task: Task | None = None
@@ -214,6 +220,8 @@ class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
class LaMarzoccoBluetoothUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Class to handle fetching data from the La Marzocco Bluetooth API centrally."""
_ignore_offline_mode = True
async def _internal_async_setup(self) -> None:
"""Initial setup for Bluetooth coordinator."""
await self.device.get_model_info_from_bluetooth()

View File

@@ -197,6 +197,9 @@
"bluetooth_connection_failed": {
"message": "Error while connecting to machine via Bluetooth"
},
"bluetooth_required_offline": {
"message": "Bluetooth is required when offline mode is enabled, but no Bluetooth device was found"
},
"button_error": {
"message": "Error while executing button {key}"
},
@@ -223,12 +226,17 @@
}
},
"options": {
"error": {
"bluetooth_required_offline": "Bluetooth is required when offline mode is enabled."
},
"step": {
"init": {
"data": {
"offline_mode": "Offline Mode",
"use_bluetooth": "Use Bluetooth"
},
"data_description": {
"offline_mode": "Enable offline mode to operate without internet connectivity through Bluetooth. Only local features will be available. Requires Bluetooth to be enabled.",
"use_bluetooth": "Should the integration try to use Bluetooth to control the machine?"
}
}

View File

@@ -1,15 +1,17 @@
"""Provides triggers for lawn mowers."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN, LawnMowerActivity
TRIGGERS: dict[str, type[Trigger]] = {
"docked": make_entity_state_trigger(DOMAIN, LawnMowerActivity.DOCKED),
"errored": make_entity_state_trigger(DOMAIN, LawnMowerActivity.ERROR),
"paused_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.PAUSED),
"started_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.MOWING),
"docked": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.DOCKED),
"errored": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.ERROR),
"paused_mowing": make_entity_target_state_trigger(DOMAIN, LawnMowerActivity.PAUSED),
"started_mowing": make_entity_target_state_trigger(
DOMAIN, LawnMowerActivity.MOWING
),
}

View File

@@ -30,70 +30,70 @@ BINARY_SENSORS: tuple[LektricoBinarySensorEntityDescription, ...] = (
translation_key="state_e_activated",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda data: bool(data["state_e_activated"]),
value_fn=lambda data: data["state_e_activated"],
),
LektricoBinarySensorEntityDescription(
key="overtemp",
translation_key="overtemp",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda data: bool(data["overtemp"]),
value_fn=lambda data: data["overtemp"],
),
LektricoBinarySensorEntityDescription(
key="critical_temp",
translation_key="critical_temp",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda data: bool(data["critical_temp"]),
value_fn=lambda data: data["critical_temp"],
),
LektricoBinarySensorEntityDescription(
key="overcurrent",
translation_key="overcurrent",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda data: bool(data["overcurrent"]),
value_fn=lambda data: data["overcurrent"],
),
LektricoBinarySensorEntityDescription(
key="meter_fault",
translation_key="meter_fault",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda data: bool(data["meter_fault"]),
value_fn=lambda data: data["meter_fault"],
),
LektricoBinarySensorEntityDescription(
key="undervoltage",
translation_key="undervoltage",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda data: bool(data["undervoltage_error"]),
value_fn=lambda data: data["undervoltage_error"],
),
LektricoBinarySensorEntityDescription(
key="overvoltage",
translation_key="overvoltage",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda data: bool(data["overvoltage_error"]),
value_fn=lambda data: data["overvoltage_error"],
),
LektricoBinarySensorEntityDescription(
key="rcd_error",
translation_key="rcd_error",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda data: bool(data["rcd_error"]),
value_fn=lambda data: data["rcd_error"],
),
LektricoBinarySensorEntityDescription(
key="cp_diode_failure",
translation_key="cp_diode_failure",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda data: bool(data["cp_diode_failure"]),
value_fn=lambda data: data["cp_diode_failure"],
),
LektricoBinarySensorEntityDescription(
key="contactor_failure",
translation_key="contactor_failure",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
value_fn=lambda data: bool(data["contactor_failure"]),
value_fn=lambda data: data["contactor_failure"],
),
)

View File

@@ -38,7 +38,7 @@ NUMBERS: tuple[LektricoNumberEntityDescription, ...] = (
native_max_value=100,
native_step=5,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: int(data["led_max_brightness"]),
value_fn=lambda data: data["led_max_brightness"],
set_value_fn=lambda data, value: data.set_led_max_brightness(value),
),
LektricoNumberEntityDescription(
@@ -49,7 +49,7 @@ NUMBERS: tuple[LektricoNumberEntityDescription, ...] = (
native_max_value=32,
native_step=1,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda data: int(data["dynamic_current"]),
value_fn=lambda data: data["dynamic_current"],
set_value_fn=lambda data, value: data.set_dynamic_current(value),
),
)

View File

@@ -79,7 +79,7 @@ SENSORS_FOR_CHARGERS: tuple[LektricoSensorEntityDescription, ...] = (
translation_key="charging_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
value_fn=lambda data: int(data["charging_time"]),
value_fn=lambda data: data["charging_time"],
),
LektricoSensorEntityDescription(
key="power",
@@ -87,20 +87,20 @@ SENSORS_FOR_CHARGERS: tuple[LektricoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
value_fn=lambda data: float(data["instant_power"]),
value_fn=lambda data: data["instant_power"],
),
LektricoSensorEntityDescription(
key="energy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda data: float(data["session_energy"]) / 1000,
value_fn=lambda data: data["session_energy"] / 1000,
),
LektricoSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda data: float(data["temperature"]),
value_fn=lambda data: data["temperature"],
),
LektricoSensorEntityDescription(
key="lifetime_energy",
@@ -108,14 +108,14 @@ SENSORS_FOR_CHARGERS: tuple[LektricoSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda data: int(data["total_charged_energy"]),
value_fn=lambda data: data["total_charged_energy"],
),
LektricoSensorEntityDescription(
key="installation_current",
translation_key="installation_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda data: int(data["install_current"]),
value_fn=lambda data: data["install_current"],
),
LektricoSensorEntityDescription(
key="limit_reason",
@@ -137,7 +137,7 @@ SENSORS_FOR_LB_DEVICES: tuple[LektricoSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda data: int(data["breaker_curent"]),
value_fn=lambda data: data["breaker_curent"],
),
)
@@ -146,14 +146,14 @@ SENSORS_FOR_1_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
value_fn=lambda data: float(data["voltage_l1"]),
value_fn=lambda data: data["voltage_l1"],
),
LektricoSensorEntityDescription(
key="current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda data: float(data["current_l1"]),
value_fn=lambda data: data["current_l1"],
),
)
@@ -163,21 +163,21 @@ SENSORS_FOR_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
translation_key="voltage_l1",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
value_fn=lambda data: float(data["voltage_l1"]),
value_fn=lambda data: data["voltage_l1"],
),
LektricoSensorEntityDescription(
key="voltage_l2",
translation_key="voltage_l2",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
value_fn=lambda data: float(data["voltage_l2"]),
value_fn=lambda data: data["voltage_l2"],
),
LektricoSensorEntityDescription(
key="voltage_l3",
translation_key="voltage_l3",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
value_fn=lambda data: float(data["voltage_l3"]),
value_fn=lambda data: data["voltage_l3"],
),
LektricoSensorEntityDescription(
key="current_l1",
@@ -185,7 +185,7 @@ SENSORS_FOR_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda data: float(data["current_l1"]),
value_fn=lambda data: data["current_l1"],
),
LektricoSensorEntityDescription(
key="current_l2",
@@ -193,7 +193,7 @@ SENSORS_FOR_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda data: float(data["current_l2"]),
value_fn=lambda data: data["current_l2"],
),
LektricoSensorEntityDescription(
key="current_l3",
@@ -201,7 +201,7 @@ SENSORS_FOR_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda data: float(data["current_l3"]),
value_fn=lambda data: data["current_l3"],
),
)
@@ -213,14 +213,14 @@ SENSORS_FOR_LB_1_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
value_fn=lambda data: float(data["power_l1"]),
value_fn=lambda data: data["power_l1"],
),
LektricoSensorEntityDescription(
key="pf",
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: float(data["power_factor_l1"]) * 100,
value_fn=lambda data: data["power_factor_l1"] * 100,
),
)
@@ -233,7 +233,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
value_fn=lambda data: float(data["power_l1"]),
value_fn=lambda data: data["power_l1"],
),
LektricoSensorEntityDescription(
key="power_l2",
@@ -242,7 +242,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
value_fn=lambda data: float(data["power_l2"]),
value_fn=lambda data: data["power_l2"],
),
LektricoSensorEntityDescription(
key="power_l3",
@@ -251,7 +251,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
value_fn=lambda data: float(data["power_l3"]),
value_fn=lambda data: data["power_l3"],
),
LektricoSensorEntityDescription(
key="pf_l1",
@@ -259,7 +259,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: float(data["power_factor_l1"]) * 100,
value_fn=lambda data: data["power_factor_l1"] * 100,
),
LektricoSensorEntityDescription(
key="pf_l2",
@@ -267,7 +267,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: float(data["power_factor_l2"]) * 100,
value_fn=lambda data: data["power_factor_l2"] * 100,
),
LektricoSensorEntityDescription(
key="pf_l3",
@@ -275,7 +275,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: float(data["power_factor_l3"]) * 100,
value_fn=lambda data: data["power_factor_l3"] * 100,
),
)

View File

@@ -1,7 +1,7 @@
"""Provides conditions for lights."""
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Final, override
from typing import TYPE_CHECKING, Any, Final, Unpack, override
import voluptuous as vol
@@ -10,11 +10,11 @@ from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import config_validation as cv, target
from homeassistant.helpers.condition import (
Condition,
ConditionCheckerType,
ConditionChecker,
ConditionCheckParams,
ConditionConfig,
trace_condition_function,
)
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -61,7 +61,7 @@ class StateConditionBase(Condition):
self._state = state
@override
async def async_get_checker(self) -> ConditionCheckerType:
async def async_get_checker(self) -> ConditionChecker:
"""Get the condition checker."""
def check_any_match_state(states: list[str]) -> bool:
@@ -78,12 +78,11 @@ class StateConditionBase(Condition):
elif self._behavior == BEHAVIOR_ALL:
matcher = check_all_match_state
@trace_condition_function
def test_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
def test_state(**kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Test state condition."""
target_selection = target.TargetSelection(self._target)
targeted_entities = target.async_extract_referenced_entity_ids(
hass, target_selection, expand_group=False
self._hass, target_selection, expand_group=False
)
referenced_entity_ids = targeted_entities.referenced.union(
targeted_entities.indirectly_referenced
@@ -96,7 +95,7 @@ class StateConditionBase(Condition):
light_entity_states = [
state.state
for entity_id in light_entity_ids
if (state := hass.states.get(entity_id))
if (state := self._hass.states.get(entity_id))
and state.state in STATE_CONDITION_VALID_STATES
]
return matcher(light_entity_states)

View File

@@ -2,13 +2,13 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -8,6 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/matter",
"integration_type": "hub",
"iot_class": "local_push",
"requirements": ["python-matter-server==8.1.0"],
"requirements": ["python-matter-server==8.1.2"],
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
}

View File

@@ -1,13 +1,13 @@
"""Provides triggers for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_conditional_entity_state_trigger
from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger
from . import MediaPlayerState
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"stopped_playing": make_conditional_entity_state_trigger(
"stopped_playing": make_entity_transition_trigger(
DOMAIN,
from_states={
MediaPlayerState.BUFFERING,

View File

@@ -1,9 +1,10 @@
{
"domain": "meteo_france",
"name": "M\u00e9t\u00e9o-France",
"name": "Météo-France",
"codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/meteo_france",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["meteofrance_api"],
"requirements": ["meteofrance-api==1.4.0"]

View File

@@ -62,7 +62,7 @@ SENSOR_TYPES: tuple[MeteoFranceSensorEntityDescription, ...] = (
key="pressure",
name="Pressure",
native_unit_of_measurement=UnitOfPressure.HPA,
device_class=SensorDeviceClass.PRESSURE,
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
data_path="current_forecast:sea_level",

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