Compare commits

..

5 Commits

Author SHA1 Message Date
Erik 813fa922e2 Include issue tracker link in custom integration analytics data 2026-04-30 10:24:44 +02:00
Erik Montnemery 4b28928702 Remove scripts from DATA_SCRIPTS on unload (#169415) 2026-04-29 18:09:49 +02:00
A. Gideonse 859ce55c96 Bump indevolt-api to 1.6.5 (#169406) 2026-04-29 16:57:53 +01:00
MohamedBarrak3 9a9f19cb9e Fix Schlage add_code service failing when code is passed as integer (#168399)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-29 17:42:16 +02:00
Heikki Henriksen d8b1bfb268 prusalink: populate serial number and firmware version in device info (#169309)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:38:32 +02:00
401 changed files with 2097 additions and 11484 deletions
Generated
+2 -2
View File
@@ -851,8 +851,8 @@ CLAUDE.md @home-assistant/core
/tests/components/input_select/ @home-assistant/core
/homeassistant/components/input_text/ @home-assistant/core
/tests/components/input_text/ @home-assistant/core
/homeassistant/components/insteon/ @teharris1 @ssyrell
/tests/components/insteon/ @teharris1 @ssyrell
/homeassistant/components/insteon/ @teharris1
/tests/components/insteon/ @teharris1
/homeassistant/components/integration/ @dgomes
/tests/components/integration/ @dgomes
/homeassistant/components/intelliclima/ @dvdinth
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "sensereo",
"name": "Sensereo",
"iot_standards": ["matter"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "zunzunbee",
"name": "Zunzunbee",
"iot_standards": ["zigbee"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.3"]
"requirements": ["serialx==1.4.1"]
}
@@ -147,7 +147,7 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
@property
def target_temperature(self) -> float:
"""Return the target temperature."""
return self._status.user_aircon_settings.current_setpoint
return self._status.user_aircon_settings.temperature_setpoint_cool_c
@actron_air_command
async def async_set_fan_mode(self, fan_mode: str) -> None:
@@ -239,7 +239,7 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
return self._zone.current_setpoint
return self._zone.temperature_setpoint_cool_c
@actron_air_command
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
@@ -36,7 +36,9 @@ def _make_detected_condition(
) -> type[Condition]:
"""Create a detected condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)},
STATE_ON,
support_duration=True,
)
@@ -45,7 +47,9 @@ def _make_cleared_condition(
) -> type[Condition]:
"""Create a cleared condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)},
STATE_OFF,
support_duration=True,
)
@@ -7,12 +7,6 @@
automation_behavior:
mode: condition
.condition_for: &condition_for
required: true
default: 00:00:00
selector:
duration:
# --- Unit lists for multi-unit pollutants ---
.co_units: &co_units
@@ -252,7 +246,11 @@
.condition_binary_common: &condition_binary_common
fields:
behavior: *condition_behavior
for: *condition_for
for:
required: true
default: 00:00:00
selector:
duration:
is_gas_detected:
<<: *condition_binary_common
@@ -284,7 +282,6 @@ is_co_value:
target: *target_co_sensor
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -299,7 +296,6 @@ is_ozone_value:
target: *target_ozone
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -314,7 +310,6 @@ is_voc_value:
target: *target_voc
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -329,7 +324,6 @@ is_voc_ratio_value:
target: *target_voc_ratio
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -344,7 +338,6 @@ is_no_value:
target: *target_no
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -359,7 +352,6 @@ is_no2_value:
target: *target_no2
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -374,7 +366,6 @@ is_so2_value:
target: *target_so2
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -391,7 +382,6 @@ is_co2_value:
target: *target_co2
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -404,7 +394,6 @@ is_pm1_value:
target: *target_pm1
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -417,7 +406,6 @@ is_pm25_value:
target: *target_pm25
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -430,7 +418,6 @@ is_pm4_value:
target: *target_pm4
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -443,7 +430,6 @@ is_pm10_value:
target: *target_pm10
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -456,7 +442,6 @@ is_n2o_value:
target: *target_n2o
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -14,9 +14,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -53,9 +50,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -92,9 +86,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -107,9 +98,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -122,9 +110,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -137,9 +122,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -152,9 +134,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -167,9 +146,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -182,9 +158,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -197,9 +170,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -236,9 +206,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -251,9 +218,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -266,9 +230,6 @@
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
@@ -4,6 +4,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR,
Condition,
EntityStateConditionBase,
make_entity_state_condition,
@@ -25,6 +26,7 @@ class EntityStateRequiredFeaturesCondition(EntityStateConditionBase):
"""State condition."""
_required_features: int
_schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain with the required features."""
@@ -82,9 +84,11 @@ CONDITIONS: dict[str, type[Condition]] = {
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
"is_disarmed": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.DISARMED, support_duration=True
),
"is_triggered": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.TRIGGERED
DOMAIN, AlarmControlPanelState.TRIGGERED, support_duration=True
),
}
@@ -1,14 +1,19 @@
.condition_common: &condition_common
target:
target: &condition_common_target
entity:
domain: alarm_control_panel
fields: &condition_common_fields
behavior:
behavior: &condition_common_behavior
required: true
default: any
selector:
automation_behavior:
mode: condition
.condition_common_for: &condition_common_for
target: *condition_common_target
fields: &condition_common_for_fields
behavior: *condition_common_behavior
for:
required: true
default: 00:00:00
@@ -18,7 +23,7 @@
is_armed: *condition_common
is_armed_away:
fields: *condition_common_fields
fields: *condition_common_for_fields
target:
entity:
domain: alarm_control_panel
@@ -26,7 +31,7 @@ is_armed_away:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
is_armed_home:
fields: *condition_common_fields
fields: *condition_common_for_fields
target:
entity:
domain: alarm_control_panel
@@ -34,7 +39,7 @@ is_armed_home:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
is_armed_night:
fields: *condition_common_fields
fields: *condition_common_for_fields
target:
entity:
domain: alarm_control_panel
@@ -42,13 +47,13 @@ is_armed_night:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
is_armed_vacation:
fields: *condition_common_fields
fields: *condition_common_for_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
is_disarmed: *condition_common
is_disarmed: *condition_common_for
is_triggered: *condition_common
is_triggered: *condition_common_for
@@ -11,9 +11,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed"
+3 -36
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
from asyncio import timeout
from collections.abc import Mapping
from datetime import datetime, timedelta
from http import HTTPStatus
import json
import logging
@@ -14,12 +13,7 @@ from uuid import uuid4
import aiohttp
from homeassistant.components import event
from homeassistant.const import (
EVENT_STATE_CHANGED,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON
from homeassistant.core import (
CALLBACK_TYPE,
Event,
@@ -59,25 +53,6 @@ DEFAULT_TIMEOUT = 10
TO_REDACT = {"correlationToken", "token"}
def valid_doorbell_timestamp(entity_id: str, event_state: str) -> bool:
"""Check if doorbell event timestamp is valid."""
if event_state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
try:
timestamp = datetime.fromisoformat(event_state)
except ValueError:
_LOGGER.debug(
"Unable to parse ISO timestamp from state for %s. Got %s",
entity_id,
event_state,
)
return False
else:
if (dt_util.utcnow() - timestamp) < timedelta(seconds=30):
return True
return False
class AlexaDirective:
"""An incoming Alexa directive."""
@@ -342,17 +317,9 @@ async def async_enable_proactive_mode(
if should_doorbell:
old_state = data["old_state"]
if (
new_state.domain == event.DOMAIN
and valid_doorbell_timestamp(new_state.entity_id, new_state.state)
and (old_state is None or old_state.state != STATE_UNAVAILABLE)
and (old_state is None or old_state.state != new_state.state)
) or (
if new_state.domain == event.DOMAIN or (
new_state.state == STATE_ON
and (
old_state is None
or old_state.state not in (STATE_ON, STATE_UNAVAILABLE)
)
and (old_state is None or old_state.state != STATE_ON)
):
await async_send_doorbell_event_message(
hass, smart_home_config, alexa_changed_entity
@@ -76,6 +76,7 @@ from .const import (
ATTR_HEALTHY,
ATTR_INTEGRATION_COUNT,
ATTR_INTEGRATIONS,
ATTR_ISSUE_TRACKER,
ATTR_OPERATING_SYSTEM,
ATTR_PROTECTED,
ATTR_RECORDER,
@@ -414,6 +415,7 @@ class Analytics:
custom_integrations.append(
{
ATTR_DOMAIN: integration.domain,
ATTR_ISSUE_TRACKER: integration.issue_tracker,
ATTR_VERSION: integration.version,
}
)
@@ -36,6 +36,7 @@ ATTR_HEALTHY = "healthy"
ATTR_INSTALLATION_TYPE = "installation_type"
ATTR_INTEGRATION_COUNT = "integration_count"
ATTR_INTEGRATIONS = "integrations"
ATTR_ISSUE_TRACKER = "issue_tracker"
ATTR_ONBOARDED = "onboarded"
ATTR_OPERATING_SYSTEM = "operating_system"
ATTR_PREFERENCES = "preferences"
@@ -7,13 +7,17 @@ from .const import DOMAIN
from .entity import AssistSatelliteState
CONDITIONS: dict[str, type[Condition]] = {
"is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE),
"is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING),
"is_idle": make_entity_state_condition(
DOMAIN, AssistSatelliteState.IDLE, support_duration=True
),
"is_listening": make_entity_state_condition(
DOMAIN, AssistSatelliteState.LISTENING, support_duration=True
),
"is_processing": make_entity_state_condition(
DOMAIN, AssistSatelliteState.PROCESSING
DOMAIN, AssistSatelliteState.PROCESSING, support_duration=True
),
"is_responding": make_entity_state_condition(
DOMAIN, AssistSatelliteState.RESPONDING
DOMAIN, AssistSatelliteState.RESPONDING, support_duration=True
),
}
@@ -901,13 +901,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script or conditions as they will
# be reused.
await self._async_disable()
return
await self._async_disable(stop_actions=False)
await self.action_script.async_unload()
self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==71"],
"requirements": ["axis==69"],
"ssdp": [
{
"manufacturer": "AXIS"
@@ -32,21 +32,25 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS,
STATE_ON,
support_duration=True,
primary_entities_only=False,
),
"is_not_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS,
STATE_OFF,
support_duration=True,
primary_entities_only=False,
),
"is_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS,
STATE_ON,
support_duration=True,
primary_entities_only=False,
),
"is_not_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS,
STATE_OFF,
support_duration=True,
primary_entities_only=False,
),
"is_level": make_entity_numerical_condition(
@@ -63,7 +63,6 @@ is_level:
primary_entities_only: false
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -26,9 +26,6 @@
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::battery::common::condition_threshold_name%]"
}
+1 -3
View File
@@ -85,9 +85,7 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
if position == -1: # possible for shutterBox
return None
if position is None:
return None
return 100 - position if self._feature.is_position_inverted else position
return None if position is None else 100 - position
@property
def current_cover_tilt_position(self) -> int | None:
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.3"],
"requirements": ["blebox-uniapi==2.5.1"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
"quality_scale": "platinum",
"requirements": ["bring-api==1.1.2"]
"requirements": ["bring-api==1.1.1"]
}
@@ -6,7 +6,6 @@ DOMAIN = "broadlink"
DOMAINS_AND_TYPES = {
Platform.CLIMATE: {"HYS"},
Platform.INFRARED: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
Platform.LIGHT: {"LB1", "LB2"},
Platform.RADIO_FREQUENCY: {"RM4PRO", "RMPRO"},
Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
@@ -1,69 +0,0 @@
"""Infrared platform for Broadlink remotes."""
from __future__ import annotations
from typing import TYPE_CHECKING
from broadlink.exceptions import BroadlinkException
from broadlink.remote import pulses_to_data as _bl_pulses_to_data
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import BroadlinkEntity
if TYPE_CHECKING:
from .device import BroadlinkDevice
PARALLEL_UPDATES = 1
def _timings_to_broadlink_packet(timings: list[int]) -> bytes:
"""Convert signed microsecond timings to a Broadlink IR packet.
Positive values are pulse (high) durations; negative values are space
(low) durations. The Broadlink library's encoder expects absolute
durations.
"""
pulses = [abs(t) for t in timings]
return _bl_pulses_to_data(pulses)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Broadlink infrared entity."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
device = hass.data[DOMAIN].devices[config_entry.entry_id]
async_add_entities([BroadlinkInfraredEntity(device)])
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEntity):
"""Broadlink infrared transmitter entity."""
_attr_has_entity_name = True
_attr_translation_key = "infrared_emitter"
def __init__(self, device: BroadlinkDevice) -> None:
"""Initialize the entity."""
super().__init__(device)
self._attr_unique_id = f"{device.unique_id}-emitter"
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command via the Broadlink device."""
packet = _timings_to_broadlink_packet(command.get_raw_timings())
try:
await self._device.async_request(self._device.api.send_data, packet)
except (BroadlinkException, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_command_failed",
translation_placeholders={"error": str(err)},
) from err
@@ -49,11 +49,6 @@
}
},
"entity": {
"infrared": {
"infrared_emitter": {
"name": "IR emitter"
}
},
"select": {
"day_of_week": {
"name": "Day of week",
@@ -87,9 +82,6 @@
"frequency_not_supported": {
"message": "Broadlink devices cannot transmit on {frequency} MHz"
},
"send_command_failed": {
"message": "Failed to send IR command: {error}"
},
"transmit_failed": {
"message": "Failed to transmit RF command: {error}"
}
+4 -38
View File
@@ -38,14 +38,7 @@ from homeassistant.helpers.device_registry import (
)
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
@@ -125,9 +118,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# Read available heating circuits from config entry data
# (populated by config flow or migration)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] or list(
DEFAULT_HEATING_CIRCUITS
)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
# Fetch required device metadata in parallel for faster startup
device, info = await asyncio.gather(
@@ -238,7 +229,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
if entry.version == 1 and entry.minor_version < 2:
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
circuits: list[int] = [1]
config = BSBLANConfig(
host=entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
@@ -254,18 +245,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
except (BSBLANError, TimeoutError) as err:
LOGGER.warning(
"Circuit discovery during migration failed for %s (%s); "
"defaulting to a single circuit. Use Reconfigure to "
"defaulting to single circuit [1]. Use Reconfigure to "
"rediscover additional circuits later",
entry.data[CONF_HOST],
err,
)
if not circuits:
LOGGER.warning(
"Circuit discovery during migration returned no heating circuits "
"for %s; defaulting to a single circuit",
entry.data[CONF_HOST],
)
circuits = list(DEFAULT_HEATING_CIRCUITS)
hass.config_entries.async_update_entry(
entry,
@@ -279,22 +263,4 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
circuits,
)
# 1.2 -> 1.3: Repair entries that stored an empty circuit list during
# discovery. Every BSB-LAN setup has at least one heating circuit.
if entry.version == 1 and entry.minor_version < 3:
if not entry.data[CONF_HEATING_CIRCUITS]:
LOGGER.warning(
"Stored heating circuits for %s are empty; defaulting to a "
"single circuit",
entry.data[CONF_HOST],
)
data = {
**entry.data,
CONF_HEATING_CIRCUITS: list(DEFAULT_HEATING_CIRCUITS),
}
else:
data = {**entry.data}
hass.config_entries.async_update_entry(entry, data=data, minor_version=3)
return True
+4 -18
View File
@@ -15,28 +15,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a BSBLAN config flow."""
VERSION = 1
MINOR_VERSION = 3
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize BSBLan flow."""
self.host: str = ""
self.port: int = DEFAULT_PORT
self.mac: str | None = None
self.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
self.circuits: list[int] = [1]
self.passkey: str | None = None
self.username: str | None = None
self.password: str | None = None
@@ -393,13 +386,6 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
try:
await bsblan.initialize()
self.circuits = await bsblan.get_available_circuits()
if not self.circuits:
LOGGER.debug(
"Circuit discovery returned no heating circuits for %s, "
"defaulting to single circuit",
self.host,
)
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
except (
BSBLANError,
TimeoutError,
@@ -408,4 +394,4 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"Circuit discovery not available for %s, defaulting to single circuit",
self.host,
)
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
self.circuits = [1]
-1
View File
@@ -24,5 +24,4 @@ ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
CONF_PASSKEY: Final = "passkey"
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
DEFAULT_HEATING_CIRCUITS: Final = (1,)
DEFAULT_PORT: Final = 80
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.2.1"],
"requirements": ["python-bsblan==5.2.0"],
"zeroconf": [
{
"name": "bsb-lan*",
@@ -7,7 +7,9 @@ from homeassistant.helpers.condition import Condition, make_entity_state_conditi
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_event_active": make_entity_state_condition(DOMAIN, STATE_ON),
"is_event_active": make_entity_state_condition(
DOMAIN, STATE_ON, support_duration=True
),
}
+6 -24
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -59,36 +59,15 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF, support_duration=True),
"is_on": make_entity_state_condition(
DOMAIN,
{
@@ -109,7 +88,10 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": ClimateTargetHumidityCondition,
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature": ClimateTargetTemperatureCondition,
}
@@ -9,11 +9,6 @@
selector:
automation_behavior:
mode: condition
for: &condition_for
required: true
default: 00:00:00
selector:
duration:
.humidity_threshold_entity: &humidity_threshold_entity
- domain: input_number
@@ -41,7 +36,16 @@
- domain: number
device_class: temperature
is_off: *condition_common
is_off:
target: *condition_climate_target
fields:
behavior: *condition_behavior
for:
required: true
default: 00:00:00
selector:
duration:
is_on: *condition_common
is_cooling: *condition_common
is_drying: *condition_common
@@ -51,7 +55,6 @@ is_hvac_mode:
target: *condition_climate_target
fields:
behavior: *condition_behavior
for: *condition_for
hvac_mode:
context:
filter_target: target
@@ -67,7 +70,6 @@ target_humidity:
target: *condition_climate_target
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -80,7 +82,6 @@ target_temperature:
target: *condition_climate_target
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -13,9 +13,6 @@
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::condition_for_name%]"
}
},
"name": "Thermostat is cooling"
@@ -25,9 +22,6 @@
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::condition_for_name%]"
}
},
"name": "Thermostat is drying"
@@ -37,9 +31,6 @@
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::condition_for_name%]"
}
},
"name": "Thermostat is heating"
@@ -50,9 +41,6 @@
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::condition_for_name%]"
},
"hvac_mode": {
"description": "The HVAC modes to test for.",
"name": "Modes"
@@ -77,9 +65,6 @@
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::condition_for_name%]"
}
},
"name": "Thermostat is on"
@@ -90,9 +75,6 @@
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::climate::common::condition_threshold_name%]"
}
@@ -105,9 +87,6 @@
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::climate::common::condition_threshold_name%]"
}
+10 -38
View File
@@ -8,15 +8,14 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
@@ -56,13 +55,6 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
@@ -83,32 +75,6 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
"""Trigger for climate target temperature value crossing a threshold."""
class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for climate target humidity triggers."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class ClimateTargetHumidityChangedTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for climate target humidity value changes."""
class ClimateTargetHumidityCrossedThresholdTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for climate target humidity value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
@@ -117,8 +83,14 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
+2 -53
View File
@@ -3,10 +3,9 @@
from aiocomelit.const import BRIDGE
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.core import HomeAssistant
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DOMAIN
from .const import CONF_VEDO_PIN, DEFAULT_PORT
from .coordinator import (
ComelitBaseCoordinator,
ComelitConfigEntry,
@@ -82,56 +81,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b
return True
async def async_migrate_entry(
hass: HomeAssistant, config_entry: ComelitConfigEntry
) -> bool:
"""Migrate old entry."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1 and config_entry.minor_version == 1:
device_registry = dr.async_get(hass)
@callback
def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None:
if (
entry.domain != Platform.SENSOR
or entry.device_id is None
or not (device_entry := device_registry.async_get(entry.device_id))
or not any(
platform == DOMAIN
and identifier.startswith(f"{config_entry.entry_id}-zone-")
for platform, identifier in device_entry.identifiers
)
):
return None
_LOGGER.debug(
"Migrating from version %s.%s",
config_entry.version,
config_entry.minor_version,
)
zone_index = entry.unique_id.removeprefix(f"{config_entry.entry_id}-")
return {
"new_unique_id": f"{config_entry.entry_id}-human_status-{zone_index}"
}
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
hass.config_entries.async_update_entry(config_entry, version=1, minor_version=2)
_LOGGER.info(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> bool:
"""Unload a config entry."""
@@ -94,7 +94,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Comelit."""
VERSION = 1
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -18,12 +18,7 @@ from aiocomelit.const import (
SCENARIO,
VEDO,
)
from aiocomelit.exceptions import (
CannotAuthenticate,
CannotConnect,
CannotRetrieveData,
DeviceStorageFailureError,
)
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
@@ -117,11 +112,6 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
translation_domain=DOMAIN,
translation_key="cannot_authenticate",
) from err
except DeviceStorageFailureError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="device_storage_failure",
) from err
@abstractmethod
async def _async_update_system_data(self) -> T:
+1 -1
View File
@@ -153,7 +153,7 @@ class ComelitVedoSensorEntity(
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{description.key}-{zone.index}"
self._attr_unique_id = f"{config_entry_entry_id}-{zone.index}"
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
self.entity_description = description
@@ -121,9 +121,6 @@
"cannot_retrieve_data": {
"message": "Error retrieving data: {error}"
},
"device_storage_failure": {
"message": "Device SD card read failure. The card may be corrupted or failing; replacement is recommended."
},
"humidity_while_off": {
"message": "Cannot change humidity while off"
},
+1 -12
View File
@@ -5,12 +5,7 @@ from functools import wraps
from typing import TYPE_CHECKING, Any, Concatenate, Literal
from aiocomelit.api import ComelitSerialBridgeObject
from aiocomelit.exceptions import (
CannotAuthenticate,
CannotConnect,
CannotRetrieveData,
DeviceStorageFailureError,
)
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from aiohttp import ClientSession, CookieJar
from homeassistant.config_entries import ConfigEntry
@@ -115,12 +110,6 @@ def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P](
translation_key="cannot_retrieve_data",
translation_placeholders={"error": repr(err)},
) from err
except DeviceStorageFailureError as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_storage_failure",
) from err
except CannotAuthenticate:
self.coordinator.last_update_success = False
self.coordinator.config_entry.async_start_reauth(self.hass)
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"]
}
@@ -9,11 +9,6 @@ is_value:
selector:
automation_behavior:
mode: condition
for:
required: true
default: 00:00:00
selector:
duration:
threshold:
required: true
selector:
@@ -1,6 +1,5 @@
{
"common": {
"condition_for_name": "For at least",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
@@ -11,9 +10,6 @@
"behavior": {
"name": "Condition passes if"
},
"for": {
"name": "[%key:component::counter::common::condition_for_name%]"
},
"threshold": {
"name": "Threshold type"
}
+6 -1
View File
@@ -4,7 +4,11 @@ from collections.abc import Mapping
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.condition import Condition, EntityConditionBase
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR,
Condition,
EntityConditionBase,
)
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .models import CoverDomainSpec
@@ -14,6 +18,7 @@ class CoverConditionBase(EntityConditionBase):
"""Base condition for cover state checks."""
_domain_specs: Mapping[str, CoverDomainSpec]
_schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected cover state."""
+4 -14
View File
@@ -1,34 +1,24 @@
"""The Duco integration."""
import re
from __future__ import annotations
from duco_connectivity import DucoClient
from duco import DucoClient, build_ssl_context
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PLATFORMS
from .coordinator import DucoConfigEntry, DucoCoordinator
_REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$")
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Set up Duco from a config entry."""
# Remove entity registry entries for the temperature and box_temperature
# sensors that were removed when migrating to python-duco-connectivity.
entity_registry = er.async_get(hass)
for entity_entry in er.async_entries_for_config_entry(
entity_registry, entry.entry_id
):
if _REMOVED_SENSOR_RE.search(entity_entry.unique_id):
entity_registry.async_remove(entity_entry.entity_id)
ssl_context = await hass.async_add_executor_job(build_ssl_context)
client = DucoClient(
session=async_get_clientsession(hass),
host=entry.data[CONF_HOST],
ssl_context=ssl_context,
)
coordinator = DucoCoordinator(hass, entry, client)
+4 -2
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
import logging
from typing import Any
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco import DucoClient, build_ssl_context
from duco.exceptions import DucoConnectionError, DucoError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -160,9 +160,11 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
Returns a tuple of (box_name, mac_address).
"""
ssl_context = await self.hass.async_add_executor_job(build_ssl_context)
client = DucoClient(
session=async_get_clientsession(self.hass),
host=host,
ssl_context=ssl_context,
)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
+3 -3
View File
@@ -5,9 +5,9 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco_connectivity.models import BoardInfo, Node
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
from duco.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+2 -16
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import asdict
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError
from duco.exceptions import DucoConnectionError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -15,9 +15,6 @@ from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .coordinator import DucoConfigEntry
# MAC addresses and serial numbers are redacted because a Duco installer or
# manufacturer could cross-reference them against an installation registry to
# identify the physical location of the device.
TO_REDACT = {
CONF_HOST,
"mac",
@@ -36,33 +33,22 @@ async def async_get_config_entry_diagnostics(
coordinator = entry.runtime_data
board = asdict(coordinator.board_info)
# `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage.
board.pop("time")
if board["public_api_version"] is None:
board.pop("public_api_version")
if board["software_version"] is None:
board.pop("software_version")
try:
api_info_obj = await coordinator.client.async_get_api_info()
lan_info = await coordinator.client.async_get_lan_info()
duco_diags = await coordinator.client.async_get_diagnostics()
write_remaining = await coordinator.client.async_get_write_requests_remaining()
write_remaining = await coordinator.client.async_get_write_req_remaining()
except DucoConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
api_info["reported_api_version"] = api_info_obj.reported_api_version
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"api_info": api_info,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node)
+3 -1
View File
@@ -1,6 +1,8 @@
"""Base entity for the Duco integration."""
from duco_connectivity.models import Node
from __future__ import annotations
from duco.models import Node
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+2 -2
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
from duco_connectivity.exceptions import DucoError, DucoRateLimitError
from duco_connectivity.models import Node, NodeType, VentilationState
from duco.exceptions import DucoError, DucoRateLimitError
from duco.models import Node, NodeType, VentilationState
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
-6
View File
@@ -7,12 +7,6 @@
"iaq_rh": {
"default": "mdi:water-percent"
},
"target_flow_level": {
"default": "mdi:gauge"
},
"time_state_end": {
"default": "mdi:timer-outline"
},
"ventilation_state": {
"default": "mdi:tune-variant"
}
+2 -2
View File
@@ -11,9 +11,9 @@
"documentation": "https://www.home-assistant.io/integrations/duco",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["duco_connectivity"],
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-connectivity==0.4.0"],
"requirements": ["python-duco-client==0.3.9"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
+22 -40
View File
@@ -4,10 +4,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
import logging
from duco_connectivity.models import Node, NodeType, VentilationState
from duco.models import Node, NodeType, VentilationState
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -20,11 +19,11 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
@@ -39,7 +38,7 @@ PARALLEL_UPDATES = 0
class DucoSensorEntityDescription(SensorEntityDescription):
"""Duco sensor entity description."""
value_fn: Callable[[Node], datetime | int | float | str | None]
value_fn: Callable[[Node], int | float | str | None]
node_types: tuple[NodeType, ...]
@@ -55,40 +54,29 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
key="ventilation_state",
translation_key="ventilation_state",
device_class=SensorDeviceClass.ENUM,
options=[
state.lower()
for state in VentilationState
if state != VentilationState.UNKNOWN
],
options=[s.lower() for s in VentilationState],
value_fn=lambda node: (
node.ventilation.state.lower()
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
else None
node.ventilation.state.lower() if node.ventilation else None
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="target_flow_level",
translation_key="target_flow_level",
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=0,
value_fn=lambda node: (
node.ventilation.flow_lvl_tgt if node.ventilation else None
),
node_types=(NodeType.BOX,),
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH),
),
DucoSensorEntityDescription(
key="time_state_end",
translation_key="time_state_end",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda node: (
dt_util.utc_from_timestamp(node.ventilation.time_state_end).replace(
second=0, microsecond=0
)
if node.ventilation and node.ventilation.time_state_end != 0
else None
),
key="box_temperature",
translation_key="box_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
@@ -155,7 +143,6 @@ async def async_setup_entry(
@callback
def _async_add_new_entities() -> None:
"""Add new sensor entities and remove stale ones on coordinator updates."""
# Remove devices whose nodes have disappeared from the API.
# The firmware removes deregistered RF/wired nodes automatically.
# BSRH box sensors that are physically unplugged from the PCB are
@@ -179,19 +166,14 @@ async def async_setup_entry(
for node in coordinator.data.nodes.values():
if node.node_id in known_nodes:
continue
known_nodes.add(node.node_id)
if node.general.node_type == NodeType.UNKNOWN:
# Do not add the node to known_nodes so that it is re-evaluated
# on every coordinator update. This allows entities to be
# created automatically once a firmware update or library
# update adds support for the device type.
_LOGGER.debug(
"Duco node %s (%s) has an unsupported device type and will be "
"retried on subsequent coordinator updates",
_LOGGER.warning(
"Duco node %s (%s) has an unsupported device type and will be ignored",
node.node_id,
node.general.name,
)
continue
known_nodes.add(node.node_id)
new_entities.extend(
DucoSensorEntity(coordinator, node, description)
for description in SENSOR_DESCRIPTIONS
@@ -228,7 +210,7 @@ class DucoSensorEntity(DucoEntity, SensorEntity):
)
@property
def native_value(self) -> datetime | int | float | str | None:
def native_value(self) -> int | float | str | None:
"""Return the sensor value."""
return self.entity_description.value_fn(self._node)
+3 -11
View File
@@ -47,18 +47,15 @@
}
},
"sensor": {
"box_temperature": {
"name": "Box temperature"
},
"iaq_co2": {
"name": "CO2 air quality index"
},
"iaq_rh": {
"name": "Humidity air quality index"
},
"target_flow_level": {
"name": "Target flow level"
},
"time_state_end": {
"name": "Mode end time"
},
"ventilation_state": {
"name": "Ventilation state",
"state": {
@@ -99,10 +96,5 @@
"rate_limit_exceeded": {
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
}
},
"system_health": {
"info": {
"write_requests_remaining": "Remaining write requests today"
}
}
}
@@ -1,47 +0,0 @@
"""Provide info to system health."""
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
from .coordinator import DucoConfigEntry
@callback
def async_register(
hass: HomeAssistant, register: system_health.SystemHealthRegistration
) -> None:
"""Register system health callbacks."""
register.async_register_info(system_health_info)
async def _async_get_write_requests_remaining(
config_entry: DucoConfigEntry,
) -> int | dict[str, str]:
"""Get the remaining write-request quota for system health."""
try:
return (
await config_entry.runtime_data.client.async_get_write_requests_remaining()
)
except DucoConnectionError:
return {"type": "failed", "error": "unreachable"}
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
config_entries: list[DucoConfigEntry] = hass.config_entries.async_loaded_entries(
DOMAIN
)
if not config_entries:
return {}
return {
"write_requests_remaining": _async_get_write_requests_remaining(
config_entries[0]
)
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==18.3.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==18.2.0"]
}
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
"requirements": ["sense-energy==0.14.1"]
"requirements": ["sense-energy==0.14.0"]
}
+5 -6
View File
@@ -666,12 +666,6 @@ class EnergyPowerSensor(SensorEntity):
self._is_inverted = "stat_rate_inverted" in config
self._is_combined = "stat_rate_from" in config and "stat_rate_to" in config
# Combined mode always emits Watts because _update_state converts
# heterogeneous source units to W internally. Inverted mode copies
# the source unit in _update_state to track source changes.
if self._is_combined:
self._attr_native_unit_of_measurement = UnitOfPower.WATT
# Determine source sensors
if self._is_inverted:
self._source_sensors = [config["stat_rate_inverted"]]
@@ -772,6 +766,11 @@ class EnergyPowerSensor(SensorEntity):
# Check first sensor
if source_entry := entity_reg.async_get(self._source_sensors[0]):
device_id = source_entry.device_id
# Combined mode always emits Watts because we convert
# heterogeneous source units internally. For inverted mode the
# unit is copied from the source state in _update_state.
if self._is_combined:
self._attr_native_unit_of_measurement = UnitOfPower.WATT
# Get source name from registry
source_name = source_entry.name or source_entry.original_name
# Assign power sensor to same device as source sensor(s)
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==44.24.1",
"aioesphomeapi==44.21.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.7.3"
],
+2 -2
View File
@@ -7,8 +7,8 @@ from homeassistant.helpers.condition import Condition, make_entity_state_conditi
from . import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF, support_duration=True),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON, support_duration=True),
}
@@ -10,7 +10,6 @@ from functools import partial
import logging
import re
from typing import Any, TypedDict, cast
from xml.etree.ElementTree import ParseError
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import FritzActionError
@@ -27,7 +26,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -229,13 +228,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self.fritz_guest_wifi = FritzGuestWLAN(fc=self.connection)
self.fritz_status = FritzStatus(fc=self.connection)
self.fritz_call = FritzCall(fc=self.connection)
try:
info = self.fritz_status.get_device_info()
except ParseError as ex:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="error_parse_device_info",
) from ex
info = self.fritz_status.get_device_info()
_LOGGER.debug(
"gathered device info of %s %s",
+1 -2
View File
@@ -7,7 +7,6 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from fritzconnection.core.exceptions import FritzConnectionException
from fritzconnection.lib.fritzstatus import FritzStatus
from requests.exceptions import RequestException
@@ -146,7 +145,7 @@ def _is_suitable_cpu_temperature(status: FritzStatus) -> bool:
"""Return whether the CPU temperature sensor is suitable."""
try:
cpu_temp = status.get_cpu_temperatures()[0]
except RequestException, IndexError, FritzConnectionException:
except RequestException, IndexError:
_LOGGER.debug("CPU temperature not supported by the device")
return False
if cpu_temp == 0:
@@ -185,9 +185,6 @@
"config_entry_not_found": {
"message": "Failed to perform action \"{service}\". Config entry for target not found"
},
"error_parse_device_info": {
"message": "Error parsing device info. Please check the system event log of your FRITZ!Box for malformed data and clear the event list."
},
"error_refresh_hosts_info": {
"message": "Error refreshing hosts info"
},
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.4"]
"requirements": ["home-assistant-frontend==20260429.0"]
}
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["afsapi"],
"requirements": ["afsapi==1.0.1"],
"requirements": ["afsapi==1.0.0"],
"ssdp": [
{
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"
@@ -198,9 +198,7 @@ class AFSAPIDevice(MediaPlayerEntity):
if not self._attr_source_list:
self.__modes_by_label = {
(mode.label or mode.id): mode.key
for mode in await afsapi.get_modes()
if mode.selectable
(mode.label or mode.id): mode.key for mode in await afsapi.get_modes()
}
self._attr_source_list = list(self.__modes_by_label)
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.8.1"]
"requirements": ["gardena-bluetooth==2.4.0"]
}
+3 -10
View File
@@ -9,13 +9,12 @@ from aiogithubapi import GitHubAPI
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import (
SERVER_SOFTWARE,
async_get_clientsession,
)
from .const import CONF_REPOSITORIES, CONF_REPOSITORY, DOMAIN, SUBENTRY_TYPE_REPOSITORY
from .const import CONF_REPOSITORIES, CONF_REPOSITORY, SUBENTRY_TYPE_REPOSITORY
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -69,7 +68,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> b
async def async_migrate_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool:
"""Migrate old entry."""
if entry.minor_version == 1:
dev_reg = dr.async_get(hass)
# In minor version 2 we migrated repositories from entry options to
# subentries, so we need to convert the list from
# entry.options[CONF_REPOSITORIES] into individual subentries.
@@ -80,13 +78,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: GithubConfigEntry) ->
title=repository,
unique_id=repository,
)
hass.config_entries.async_add_subentry(entry, subentry)
if device := dev_reg.async_get_device({(DOMAIN, repository)}):
dev_reg.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
add_config_subentry_id=subentry.subentry_id,
add_config_entry_id=entry.entry_id,
)
hass.config_entries.async_update_entry(entry, minor_version=2)
return True
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioharmony", "slixmpp"],
"requirements": ["aioharmony==1.0.3"],
"requirements": ["aioharmony==0.5.3"],
"ssdp": [
{
"deviceType": "urn:myharmony-com:device:harmony:1",
+8 -13
View File
@@ -12,7 +12,6 @@ import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.providers import homeassistant as auth_ha
from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView
from homeassistant.components.http.const import is_supervisor_unix_socket_request
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
@@ -42,18 +41,14 @@ class HassIOBaseAuth(HomeAssistantView):
def _check_access(self, request: web.Request) -> None:
"""Check if this call is from Supervisor."""
# Requests over the Supervisor Unix socket are authenticated by the
# http auth middleware as the Supervisor user, so the caller-IP check
# below does not apply (and would crash, since `peername` is empty for
# Unix sockets). The user-ID check still runs to ensure only the
# Supervisor user can reach this endpoint.
if not is_supervisor_unix_socket_request(request):
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
peername = request.transport.get_extra_info("peername")
if not peername or ip_address(peername[0]) != ip_address(hassio_ip):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller IP
hassio_ip = os.environ["SUPERVISOR"].split(":")[0]
assert request.transport
if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address(
hassio_ip
):
_LOGGER.error("Invalid auth request from %s", request.remote)
raise HTTPUnauthorized
# Check caller token
if request[KEY_HASS_USER].id != self.user.id:
+7 -36
View File
@@ -7,7 +7,6 @@ from typing import Any
from aiohasupervisor import SupervisorClient, SupervisorError
from aiohasupervisor.models import (
Folder,
FullBackupOptions,
FullRestoreOptions,
PartialBackupOptions,
@@ -71,31 +70,6 @@ SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
# Legacy alias used by the Supervisor API for the homeassistant flag, kept
# for backwards compatibility with existing automations.
LEGACY_FOLDER_HOMEASSISTANT = "homeassistant"
def _normalize_partial_options_data(data: dict[str, Any]) -> dict[str, Any]:
"""Map legacy aliases used by both partial backup and partial restore handlers."""
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
if ATTR_FOLDERS in data:
folders: set[Any] = set(data[ATTR_FOLDERS])
if LEGACY_FOLDER_HOMEASSISTANT in folders:
folders.discard(LEGACY_FOLDER_HOMEASSISTANT)
if data.get(ATTR_HOMEASSISTANT) is False:
raise ServiceValidationError(
f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy "
f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}"
)
data[ATTR_HOMEASSISTANT] = True
if folders:
data[ATTR_FOLDERS] = folders
else:
data.pop(ATTR_FOLDERS)
return data
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
@@ -139,10 +113,7 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list,
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
vol.Unique(),
vol.Coerce(set),
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
@@ -165,10 +136,7 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list,
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
vol.Unique(),
vol.Coerce(set),
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
@@ -375,7 +343,9 @@ def async_register_backup_restore_services(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create partial backup service. Returns the new backup's ID."""
data = _normalize_partial_options_data(service.data.copy())
data = service.data.copy()
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialBackupOptions(**data)
try:
@@ -422,7 +392,8 @@ def async_register_backup_restore_services(
"""Handler for partial restore service."""
data = service.data.copy()
backup_slug = data.pop(ATTR_SLUG)
data = _normalize_partial_options_data(data)
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialRestoreOptions(**data)
try:
+3 -17
View File
@@ -119,22 +119,9 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
if not errors:
_LOGGER.debug("2FA successful")
if self.source == SOURCE_REAUTH:
try:
device_registered = await self.hive_auth.is_device_registered()
except HiveApiError as err:
_LOGGER.debug(
"Failed to check whether the Hive device is registered during reauthentication: %s",
err,
)
errors["base"] = "no_internet_available"
else:
if device_registered:
return await self.async_setup_hive_entry()
self.device_registration = True
return await self.async_step_configuration()
else:
self.device_registration = True
return await self.async_step_configuration()
return await self.async_setup_hive_entry()
self.device_registration = True
return await self.async_step_configuration()
schema = vol.Schema({vol.Required(CONF_CODE): str})
return self.async_show_form(step_id="2fa", data_schema=schema, errors=errors)
@@ -186,7 +173,6 @@ class HiveFlowHandler(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Re Authenticate a user."""
self.data = dict(entry_data)
data = {
CONF_USERNAME: entry_data[CONF_USERNAME],
CONF_PASSWORD: entry_data[CONF_PASSWORD],
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.96", "babel==2.15.0"]
"requirements": ["holidays==0.95", "babel==2.15.0"]
}
@@ -10,14 +10,14 @@ from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator,
)
from homeassistant.components.usb import USBDevice, async_register_port_event_callback
from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DEVICE, DOMAIN, NABU_CASA_FIRMWARE_RELEASES_URL, SERIAL_NUMBER
from .const import DEVICE, DOMAIN, NABU_CASA_FIRMWARE_RELEASES_URL
_LOGGER = logging.getLogger(__name__)
@@ -97,75 +97,3 @@ async def async_unload_entry(
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, ["switch", "update"])
async def async_migrate_entry(
hass: HomeAssistant, config_entry: HomeAssistantConnectZBT2ConfigEntry
) -> bool:
"""Migrate old entry."""
_LOGGER.debug(
"Migrating from version %s.%s",
config_entry.version,
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version == 1:
serial_number = config_entry.data[SERIAL_NUMBER]
# Installations ended up with multiple config entries per physical adapter
# in 2026.5.0 and 2026.5.1. We need to delete the older entry.
duplicates = [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data.get(SERIAL_NUMBER) == serial_number
]
canonical = max(
duplicates,
key=lambda e: (
e.source != SOURCE_IGNORE,
e.disabled_by is None,
e.minor_version,
e.modified_at,
e.entry_id,
),
)
if canonical.entry_id != config_entry.entry_id:
# The canonical entry's migration will remove this duplicate.
return False
for duplicate in duplicates:
if duplicate.entry_id == config_entry.entry_id:
continue
_LOGGER.debug(
"Removing duplicate config entry %s for serial %s in favor of %s",
duplicate.entry_id,
serial_number,
config_entry.entry_id,
)
await hass.config_entries.async_remove(duplicate.entry_id)
# Replace the synthetic unique ID with the USB serial number
hass.config_entries.async_update_entry(
config_entry,
unique_id=serial_number,
version=1,
minor_version=2,
)
_LOGGER.debug(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True
# This means the user has downgraded from a future version
return False
@@ -16,7 +16,10 @@ from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.components.usb import usb_service_info_from_device
from homeassistant.components.usb import (
usb_service_info_from_device,
usb_unique_id_from_service_info,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -111,7 +114,7 @@ class HomeAssistantConnectZBT2ConfigFlow(
"""Handle a config flow for Home Assistant Connect ZBT-2."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 1
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the config flow."""
@@ -129,12 +132,14 @@ class HomeAssistantConnectZBT2ConfigFlow(
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle usb discovery."""
unique_id = usb_unique_id_from_service_info(discovery_info)
discovery_info.device = await self.hass.async_add_executor_job(
usb.get_serial_by_id, discovery_info.device
)
try:
await self.async_set_unique_id(discovery_info.serial_number)
await self.async_set_unique_id(unique_id)
finally:
self._abort_if_unique_id_configured(updates={DEVICE: discovery_info.device})
@@ -152,10 +157,9 @@ class HomeAssistantConnectZBT2ConfigFlow(
"""Handle import from ZHA/OTBR firmware notification."""
assert fw_discovery_info["usb_device"] is not None
usb_info = usb_service_info_from_device(fw_discovery_info["usb_device"])
unique_id = usb_unique_id_from_service_info(usb_info)
if await self.async_set_unique_id(
usb_info.serial_number, raise_on_progress=False
):
if await self.async_set_unique_id(unique_id, raise_on_progress=False):
self._abort_if_unique_id_configured(updates={DEVICE: usb_info.device})
self._usb_info = usb_info
@@ -15,7 +15,7 @@ from homeassistant.components.usb import (
async_register_port_event_callback,
async_scan_serial_ports,
)
from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -125,10 +125,6 @@ async def async_migrate_entry(
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version == 1:
# Add-on startup with type service get started before Core, always (e.g. the
@@ -200,50 +196,6 @@ async def async_migrate_entry(
minor_version=4,
)
if config_entry.minor_version == 4:
serial_number = config_entry.data[SERIAL_NUMBER]
# Installations ended up with multiple config entries per physical adapter
# in 2026.5.0 and 2026.5.1. We need to delete the older entry.
duplicates = [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.data.get(SERIAL_NUMBER) == serial_number
]
canonical = max(
duplicates,
key=lambda e: (
e.source != SOURCE_IGNORE,
e.disabled_by is None,
e.minor_version,
e.modified_at,
e.entry_id,
),
)
if canonical.entry_id != config_entry.entry_id:
# The canonical entry's migration will remove this duplicate.
return False
for duplicate in duplicates:
if duplicate.entry_id == config_entry.entry_id:
continue
_LOGGER.warning(
"Removing duplicate config entry %s for serial %s in favor of %s",
duplicate.entry_id,
serial_number,
config_entry.entry_id,
)
await hass.config_entries.async_remove(duplicate.entry_id)
# Replace the synthetic unique ID with the USB serial number
hass.config_entries.async_update_entry(
config_entry,
unique_id=serial_number,
version=1,
minor_version=5,
)
_LOGGER.debug(
"Migration to version %s.%s successful",
config_entry.version,
@@ -19,7 +19,10 @@ from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.components.usb import usb_service_info_from_device
from homeassistant.components.usb import (
usb_service_info_from_device,
usb_unique_id_from_service_info,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -127,7 +130,7 @@ class HomeAssistantSkyConnectConfigFlow(
"""Handle a config flow for Home Assistant SkyConnect."""
VERSION = 1
MINOR_VERSION = 5
MINOR_VERSION = 4
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the config flow."""
@@ -151,7 +154,9 @@ class HomeAssistantSkyConnectConfigFlow(
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle usb discovery."""
if await self.async_set_unique_id(discovery_info.serial_number):
unique_id = usb_unique_id_from_service_info(discovery_info)
if await self.async_set_unique_id(unique_id):
self._abort_if_unique_id_configured(updates={DEVICE: discovery_info.device})
discovery_info.device = await self.hass.async_add_executor_job(
@@ -177,10 +182,9 @@ class HomeAssistantSkyConnectConfigFlow(
"""Handle import from ZHA/OTBR firmware notification."""
assert fw_discovery_info["usb_device"] is not None
usb_info = usb_service_info_from_device(fw_discovery_info["usb_device"])
unique_id = usb_unique_id_from_service_info(usb_info)
if await self.async_set_unique_id(
usb_info.serial_number, raise_on_progress=False
):
if await self.async_set_unique_id(unique_id, raise_on_progress=False):
self._abort_if_unique_id_configured(updates={DEVICE: usb_info.device})
self._usb_info = usb_info
@@ -25,7 +25,7 @@ from .const import (
HMIPC_NAME,
)
from .hap import HomematicIPConfigEntry, HomematicipHAP
from .migration import _match_legacy_class_name, _migrate_unique_id
from .migration import _migrate_unique_id
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -157,73 +157,6 @@ async def async_migrate_entry(
)
entity_registry.async_remove(entry.entity_id)
# Pre-pass: deduplicate legacy entries that would migrate to the same
# new unique_id, and drop legacy entries whose target is already
# occupied by a stable-format entry from a previously-aborted
# migration. Two collision shapes are handled here:
#
# a) Two or more legacy entries share the same new target id (e.g.
# HomematicipNotificationLight + HomematicipNotificationLightV2
# for the same HmIP-BSL after firmware 2.0.0, or Switch +
# SwitchMeasuring on a device whose capability class changed).
#
# b) One legacy entry shares its target with a stable-format entry
# that was successfully migrated on a previous run before the
# run aborted on a sibling collision. async_migrate_entries
# commits each update individually with no rollback, so partial
# migration is the steady state for any user who already hit
# this bug at least once.
#
# When deduplicating pure-legacy groups, prefer the entry whose
# legacy class name is longer — that is the more specific variant
# (V2 over V1, Measuring over plain) and the one HA has been
# actively binding to since the class transition.
legacy_by_target: dict[tuple[str, str], list[er.RegistryEntry]] = {}
stable_targets: set[tuple[str, str]] = set()
for entry in er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
):
new_id = _migrate_unique_id(entry.unique_id)
if new_id is None:
# Stable-format entry — record so we can detect (b).
stable_targets.add((entry.domain, entry.unique_id))
continue
legacy_by_target.setdefault((entry.domain, new_id), []).append(entry)
for key, group in legacy_by_target.items():
if key in stable_targets:
# (b): stable entry already occupies the target. Drop every
# legacy duplicate; the surviving stable entry stays put.
for dup in group:
_LOGGER.warning(
"Removing legacy registry entry %s (%s) — its"
" migration target %s is already in use by a stable"
" entry from a previously-aborted migration",
dup.entity_id,
dup.unique_id,
key[1],
)
entity_registry.async_remove(dup.entity_id)
continue
if len(group) <= 1:
continue
# (a): multiple legacy entries collide on the same target.
group.sort(
key=lambda e: len(_match_legacy_class_name(e.unique_id) or ""),
reverse=True,
)
keeper, *duplicates = group
for dup in duplicates:
_LOGGER.warning(
"Removing duplicate registry entry %s (%s) — collides"
" with %s on migration to %s",
dup.entity_id,
dup.unique_id,
keeper.entity_id,
key[1],
)
entity_registry.async_remove(dup.entity_id)
@callback
def _update_unique_id(
entity_entry: er.RegistryEntry,
@@ -4,12 +4,7 @@ from __future__ import annotations
from typing import Any
from homematicip.base.enums import (
BinaryBehaviorType,
LockState,
SmokeDetectorAlarmType,
WindowState,
)
from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState
from homematicip.base.functionalChannels import MultiModeInputChannel
from homematicip.device import (
AccelerationSensor,
@@ -359,22 +354,7 @@ class HomematicipFullFlushLockControllerLocked(
@property
def is_on(self) -> bool:
"""Return true if the controlled lock is unlocked.
Per HA's BinarySensorDeviceClass.LOCK contract, ON means
unlocked / open and OFF means locked / closed.
The mapping from the firmware-reported ``lockState`` depends on
the channel's ``binaryBehaviorType``. With the default
``NORMALLY_OPEN`` wiring, the input goes ACTIVE (and lockState
flips to ``LOCKED``) when the contact closes i.e. when a
magnetic door contact registers the door as closed. With
``NORMALLY_CLOSE`` the same physical event puts the input into
the IDLE state (lockState ``UNLOCKED``). To present the same
HA semantics regardless of which way the user wired the
contact, ``lockState`` is interpreted relative to the
configured behavior.
"""
"""Return true if the controlled lock is locked."""
channel = _get_channel_by_role(
self._device,
"MULTI_MODE_LOCK_INPUT_CHANNEL",
@@ -383,15 +363,7 @@ class HomematicipFullFlushLockControllerLocked(
if channel is None:
return False
lock_state = getattr(channel, "lockState", None)
is_locked_state = (
getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
)
binary_behavior = getattr(channel, "binaryBehaviorType", None)
normally_close = (
getattr(binary_behavior, "name", binary_behavior)
== BinaryBehaviorType.NORMALLY_CLOSE.name
)
return is_locked_state if normally_close else not is_locked_state
return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
class HomematicipFullFlushLockControllerGlassBreak(
@@ -168,14 +168,6 @@ _NOTIFICATION_LIGHT_RE = re.compile(r"^(Top|Bottom)_(.+)$")
_NOTIFICATION_LIGHT_CHANNEL_MAP = {"Top": 2, "Bottom": 3}
def _match_legacy_class_name(old_unique_id: str) -> str | None:
"""Return the legacy class name that prefixes ``old_unique_id``, if any."""
for class_name in _SORTED_CLASS_NAMES:
if old_unique_id.startswith(class_name + "_"):
return class_name
return None
def _migrate_unique_id(old_unique_id: str) -> str | None:
"""Convert an old-format unique_id to the new format.
@@ -188,7 +180,14 @@ def _migrate_unique_id(old_unique_id: str) -> str | None:
{device_id}_{channel}_{feature_id} (device entities)
{device_id}_{feature_id} (group/home entities)
"""
matched_class = _match_legacy_class_name(old_unique_id)
# Find the matching class name (longest first)
matched_class: str | None = None
for class_name in _SORTED_CLASS_NAMES:
prefix = class_name + "_"
if old_unique_id.startswith(prefix):
matched_class = class_name
break
if matched_class is None:
return None
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL,
Condition,
ConditionConfig,
EntityNumericalConditionBase,
EntityStateConditionBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
@@ -46,20 +46,6 @@ def _supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> boo
return False
class IsTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for humidifier target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip humidifier entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class IsModeCondition(EntityStateConditionBase):
"""Condition for humidifier mode."""
@@ -84,8 +70,8 @@ class IsModeCondition(EntityStateConditionBase):
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF, support_duration=True),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON, support_duration=True),
"is_drying": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
),
@@ -93,7 +79,10 @@ CONDITIONS: dict[str, type[Condition]] = {
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
"is_mode": IsModeCondition,
"is_target_humidity": IsTargetHumidityCondition,
"is_target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit=PERCENTAGE,
),
}
@@ -9,7 +9,12 @@
selector:
automation_behavior:
mode: condition
for: &condition_for
.condition_common_for: &condition_common_for
target: *condition_humidifier_target
fields:
behavior: *condition_behavior
for:
required: true
default: 00:00:00
selector:
@@ -29,8 +34,8 @@
mode: box
unit_of_measurement: "%"
is_off: *condition_common
is_on: *condition_common
is_off: *condition_common_for
is_on: *condition_common_for
is_drying: *condition_common
is_humidifying: *condition_common
@@ -38,7 +43,6 @@ is_mode:
target: *condition_humidifier_target
fields:
behavior: *condition_behavior
for: *condition_for
mode:
context:
filter_target: target
@@ -52,7 +56,6 @@ is_target_humidity:
target: *condition_humidifier_target
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -12,9 +12,6 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::condition_for_name%]"
}
},
"name": "Humidifier is drying"
@@ -24,9 +21,6 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::condition_for_name%]"
}
},
"name": "Humidifier is humidifying"
@@ -37,9 +31,6 @@
"behavior": {
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::condition_for_name%]"
},
"mode": {
"description": "The operation modes to check for.",
"name": "Mode"
@@ -77,9 +68,6 @@
"behavior": {
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::humidifier::common::condition_threshold_name%]"
}
+3 -26
View File
@@ -16,9 +16,9 @@ from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, EntityNumericalConditionBase
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
HUMIDITY_DOMAIN_SPECS = {
CLIMATE_DOMAIN: DomainSpec(
@@ -33,31 +33,8 @@ HUMIDITY_DOMAIN_SPECS = {
),
}
class HumidityCondition(EntityNumericalConditionBase):
"""Condition for humidity value across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = PERCENTAGE
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
Mirrors the humidity trigger: for climate / humidifier / weather
(attribute-based), the entity is filtered when the source attribute
is absent; sensor entities (state-value-based) fall through to the
base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
CONDITIONS: dict[str, type[Condition]] = {
"is_value": HumidityCondition,
"is_value": make_entity_numerical_condition(HUMIDITY_DOMAIN_SPECS, PERCENTAGE),
}
@@ -27,11 +27,6 @@ is_value:
selector:
automation_behavior:
mode: condition
for:
required: true
default: 00:00:00
selector:
duration:
threshold:
required: true
selector:
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
@@ -14,9 +13,6 @@
"behavior": {
"name": "[%key:component::humidity::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::humidity::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::humidity::common::condition_threshold_name%]"
}
+9 -43
View File
@@ -15,13 +15,12 @@ from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
)
HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
@@ -39,46 +38,13 @@ HUMIDITY_DOMAIN_SPECS: dict[str, DomainSpec] = {
),
}
class _HumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for humidity triggers providing entity filtering."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip attribute-source entities that lack the humidity attribute.
For domains whose tracked value comes from an attribute
(climate / humidifier / weather), require the attribute to be
present; otherwise the all/count check would treat an entity that
cannot report a humidity as a non-match and block behavior=last.
Sensor entities source their value from `state.state`, so they
fall through to the base impl.
"""
if not super()._should_include(state):
return False
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is None:
return True
return state.attributes.get(domain_spec.value_source) is not None
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for humidity value changes across multiple domains."""
class HumidityCrossedThresholdTrigger(
_HumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
"changed": make_entity_numerical_state_changed_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
}
@@ -13,5 +13,5 @@
"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==2.8.1"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.4.0"]
}
+1 -6
View File
@@ -3,9 +3,4 @@
from datetime import timedelta
DOMAIN = "iaqualink"
UPDATE_INTERVAL_BY_SYSTEM_TYPE: dict[str, timedelta] = {
"iaqua": timedelta(seconds=15),
"exo": timedelta(seconds=60),
}
UPDATE_INTERVAL_DEFAULT = timedelta(seconds=30)
UPDATE_INTERVAL = timedelta(seconds=15)
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPDATE_INTERVAL_BY_SYSTEM_TYPE, UPDATE_INTERVAL_DEFAULT
from .const import DOMAIN, UPDATE_INTERVAL
_LOGGER = logging.getLogger(__name__)
@@ -28,15 +28,12 @@ class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
self, hass: HomeAssistant, config_entry: ConfigEntry, system: Any
) -> None:
"""Initialize the coordinator."""
update_interval = UPDATE_INTERVAL_BY_SYSTEM_TYPE.get(
system.NAME, UPDATE_INTERVAL_DEFAULT
)
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_{system.serial}",
update_interval=update_interval,
update_interval=UPDATE_INTERVAL,
)
self.system = system
@@ -25,10 +25,10 @@ ILLUMINANCE_VALUE_DOMAIN_SPECS = {
CONDITIONS: dict[str, type[Condition]] = {
"is_detected": make_entity_state_condition(
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_ON
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_ON, support_duration=True
),
"is_not_detected": make_entity_state_condition(
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_OFF
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_OFF, support_duration=True
),
"is_value": make_entity_numerical_condition(
ILLUMINANCE_VALUE_DOMAIN_SPECS, LIGHT_LUX
@@ -10,7 +10,7 @@
selector:
automation_behavior:
mode: condition
for: &condition_for
for:
required: true
default: 00:00:00
selector:
@@ -27,7 +27,6 @@ is_value:
device_class: illuminance
fields:
behavior: *condition_behavior
for: *condition_for
threshold:
required: true
selector:
@@ -38,9 +38,6 @@
"behavior": {
"name": "[%key:component::illuminance::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::illuminance::common::condition_for_name%]"
},
"threshold": {
"name": "[%key:component::illuminance::common::condition_threshold_name%]"
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==2.1.2"]
"requirements": ["imgw_pib==2.1.1"]
}
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["indevolt-api==1.6.4"]
"requirements": ["indevolt-api==1.6.5"]
}
@@ -290,9 +290,7 @@ class InfluxDBConfigFlow(ConfigFlow, domain=DOMAIN):
scheme="https" if entry.data.get(CONF_SSL) else "http",
host=entry.data.get(CONF_HOST, ""),
port=entry.data.get(CONF_PORT),
path=""
if entry.data.get(CONF_PATH) is None
else entry.data[CONF_PATH],
path=entry.data.get(CONF_PATH, ""),
)
)
@@ -2,7 +2,7 @@
"domain": "insteon",
"name": "Insteon",
"after_dependencies": ["panel_custom"],
"codeowners": ["@teharris1", "@ssyrell"],
"codeowners": ["@teharris1"],
"config_flow": true,
"dependencies": ["http", "usb", "websocket_api"],
"dhcp": [
@@ -19,7 +19,7 @@
"loggers": ["pyinsteon", "pypubsub"],
"requirements": [
"pyinsteon==1.6.4",
"insteon-frontend-home-assistant==0.6.2"
"insteon-frontend-home-assistant==0.6.1"
],
"single_config_entry": true,
"usb": [
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
import aiohttp
from intellifire4py import UnifiedFireplace
from intellifire4py.cloud_interface import IntelliFireCloudInterface
from intellifire4py.const import IntelliFireApiMode
@@ -156,10 +155,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
raise ConfigEntryNotReady(
"Initialization of fireplace timed out after 10 minutes"
) from err
except (aiohttp.ClientConnectionError, ConnectionError) as err:
raise ConfigEntryNotReady(
"Error communicating with fireplace during initialization"
) from err
# Construct coordinator
data_update_coordinator = IntellifireDataUpdateCoordinator(hass, entry, fireplace)
@@ -15,7 +15,6 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@@ -290,8 +289,10 @@ class IntelliFireOptionsFlowHandler(OptionsFlow):
errors: dict[str, str] = {}
if user_input is not None:
if self.config_entry.state is ConfigEntryState.LOADED:
fireplace = self.config_entry.runtime_data.fireplace
# Validate connectivity for requested modes if runtime data is available
coordinator = self.config_entry.runtime_data
if coordinator is not None:
fireplace = coordinator.fireplace
# Refresh connectivity status before validating
await fireplace.async_validate_connectivity()

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