Compare commits

..

2 Commits

Author SHA1 Message Date
Abílio Costa
937ccc7867 Merge branch 'dev' into scene_triggers 2025-12-16 18:50:53 +00:00
abmantis
385ab8f5d3 Add scene activated trigger 2025-12-16 18:45:56 +00:00
277 changed files with 1965 additions and 8398 deletions

View File

@@ -51,9 +51,6 @@ rules:
- **Missing imports** - We use static analysis tooling to catch that - **Missing imports** - We use static analysis tooling to catch that
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions) - **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Python Requirements ## Python Requirements
- **Compatibility**: Python 3.13+ - **Compatibility**: Python 3.13+

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -567,7 +567,6 @@ homeassistant.components.wake_word.*
homeassistant.components.wallbox.* homeassistant.components.wallbox.*
homeassistant.components.waqi.* homeassistant.components.waqi.*
homeassistant.components.water_heater.* homeassistant.components.water_heater.*
homeassistant.components.watts.*
homeassistant.components.watttime.* homeassistant.components.watttime.*
homeassistant.components.weather.* homeassistant.components.weather.*
homeassistant.components.webhook.* homeassistant.components.webhook.*

10
CODEOWNERS generated
View File

@@ -664,8 +664,8 @@ build.json @home-assistant/supervisor
/tests/components/heos/ @andrewsayre /tests/components/heos/ @andrewsayre
/homeassistant/components/here_travel_time/ @eifinger /homeassistant/components/here_travel_time/ @eifinger
/tests/components/here_travel_time/ @eifinger /tests/components/here_travel_time/ @eifinger
/homeassistant/components/hikvision/ @mezz64 @ptarjan /homeassistant/components/hikvision/ @mezz64
/tests/components/hikvision/ @mezz64 @ptarjan /tests/components/hikvision/ @mezz64
/homeassistant/components/hikvisioncam/ @fbradyirl /homeassistant/components/hikvisioncam/ @fbradyirl
/homeassistant/components/hisense_aehw4a1/ @bannhead /homeassistant/components/hisense_aehw4a1/ @bannhead
/tests/components/hisense_aehw4a1/ @bannhead /tests/components/hisense_aehw4a1/ @bannhead
@@ -1195,8 +1195,8 @@ build.json @home-assistant/supervisor
/tests/components/ourgroceries/ @OnFreund /tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl /homeassistant/components/overkiz/ @imicknl
/tests/components/overkiz/ @imicknl /tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek @AmGarera /homeassistant/components/overseerr/ @joostlek
/tests/components/overseerr/ @joostlek @AmGarera /tests/components/overseerr/ @joostlek
/homeassistant/components/ovo_energy/ @timmo001 /homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas /homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -1798,8 +1798,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/watergate/ @adam-the-hero /homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero /tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai /homeassistant/components/watson_tts/ @rutkai
/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/homeassistant/components/watttime/ @bachya /homeassistant/components/watttime/ @bachya
/tests/components/watttime/ @bachya /tests/components/watttime/ @bachya
/homeassistant/components/waze_travel_time/ @eifinger /homeassistant/components/waze_travel_time/ @eifinger

2
Dockerfile generated
View File

@@ -24,7 +24,7 @@ ENV \
COPY rootfs / COPY rootfs /
# Add go2rtc binary # Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc COPY --from=ghcr.io/alexxit/go2rtc@sha256:baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640 /usr/local/bin/go2rtc /bin/go2rtc
RUN \ RUN \
# Verify go2rtc can be executed # Verify go2rtc can be executed

View File

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

View File

@@ -18,7 +18,7 @@ from .coordinator import (
ActronAirSystemCoordinator, ActronAirSystemCoordinator,
) )
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH] PLATFORM = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
@@ -50,10 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
system_coordinators=system_coordinators, system_coordinators=system_coordinators,
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORM)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORM)

View File

@@ -148,7 +148,7 @@ class ActronSystemClimate(BaseClimateEntity):
@property @property
def fan_mode(self) -> str | None: def fan_mode(self) -> str | None:
"""Return the current fan mode.""" """Return the current fan mode."""
fan_mode = self._status.user_aircon_settings.base_fan_mode fan_mode = self._status.user_aircon_settings.fan_mode
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode) return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
@property @property

View File

@@ -1,30 +0,0 @@
{
"entity": {
"switch": {
"away_mode": {
"default": "mdi:home-export-outline",
"state": {
"off": "mdi:home-import-outline"
}
},
"continuous_fan": {
"default": "mdi:fan",
"state": {
"off": "mdi:fan-off"
}
},
"quiet_mode": {
"default": "mdi:volume-low",
"state": {
"off": "mdi:volume-high"
}
},
"turbo_mode": {
"default": "mdi:fan-plus",
"state": {
"off": "mdi:fan"
}
}
}
}
}

View File

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

View File

@@ -32,22 +32,6 @@
} }
} }
}, },
"entity": {
"switch": {
"away_mode": {
"name": "Away mode"
},
"continuous_fan": {
"name": "Continuous fan"
},
"quiet_mode": {
"name": "Quiet mode"
},
"turbo_mode": {
"name": "Turbo mode"
}
}
},
"exceptions": { "exceptions": {
"auth_error": { "auth_error": {
"message": "Authentication failed, please reauthenticate" "message": "Authentication failed, please reauthenticate"

View File

@@ -1,110 +0,0 @@
"""Switch platform for Actron Air integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ActronAirSwitchEntityDescription(SwitchEntityDescription):
"""Class describing Actron Air switch entities."""
is_on_fn: Callable[[ActronAirSystemCoordinator], bool]
set_fn: Callable[[ActronAirSystemCoordinator, bool], Awaitable[None]]
is_supported_fn: Callable[[ActronAirSystemCoordinator], bool] = lambda _: True
SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = (
ActronAirSwitchEntityDescription(
key="away_mode",
translation_key="away_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_away_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="continuous_fan",
translation_key="continuous_fan",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.continuous_fan_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_continuous_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="quiet_mode",
translation_key="quiet_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.quiet_mode_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_quiet_mode(enabled),
),
ActronAirSwitchEntityDescription(
key="turbo_mode",
translation_key="turbo_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_enabled,
set_fn=lambda coordinator,
enabled: coordinator.data.user_aircon_settings.set_turbo_mode(enabled),
is_supported_fn=lambda coordinator: coordinator.data.user_aircon_settings.turbo_supported,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air switch entities."""
system_coordinators = entry.runtime_data.system_coordinators
async_add_entities(
ActronAirSwitch(coordinator, description)
for coordinator in system_coordinators.values()
for description in SWITCHES
if description.is_supported_fn(coordinator)
)
class ActronAirSwitch(CoordinatorEntity[ActronAirSystemCoordinator], SwitchEntity):
"""Actron Air switch."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.CONFIG
entity_description: ActronAirSwitchEntityDescription
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
description: ActronAirSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.serial_number)},
manufacturer="Actron Air",
name=coordinator.data.ac_system.system_name,
)
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)

View File

@@ -88,11 +88,21 @@ class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
super().__init__(coordinator, unit_id) super().__init__(coordinator, unit_id)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}" self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{unit_id}"
@property
def climate_data(self) -> dict[str, Any]:
"""Return the climate data."""
return self.device_data.get("climate") or {}
@property @property
def params(self) -> dict[str, Any]: def params(self) -> dict[str, Any]:
"""Return the current parameters for the climate entity.""" """Return the current parameters for the climate entity."""
return self.climate_data.get("ParametersData") or {} return self.climate_data.get("ParametersData") or {}
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and bool(self.climate_data)
@property @property
def current_humidity(self) -> float | None: def current_humidity(self) -> float | None:
"""Return the current humidity.""" """Return the current humidity."""

View File

@@ -10,7 +10,7 @@ from homeassistant.const import Platform
DOMAIN = "airpatrol" DOMAIN = "airpatrol"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] PLATFORMS = [Platform.CLIMATE]
SCAN_INTERVAL = timedelta(minutes=1) SCAN_INTERVAL = timedelta(minutes=1)
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError) AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)

View File

@@ -38,17 +38,7 @@ class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
"""Return the device data.""" """Return the device data."""
return self.coordinator.data[self._unit_id] return self.coordinator.data[self._unit_id]
@property
def climate_data(self) -> dict[str, Any]:
"""Return the climate data for this unit."""
return self.device_data["climate"]
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
return ( return super().available and self._unit_id in self.coordinator.data
super().available
and self._unit_id in self.coordinator.data
and "climate" in self.device_data
and self.climate_data is not None
)

View File

@@ -1,89 +0,0 @@
"""Sensors for AirPatrol integration."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirPatrolConfigEntry
from .coordinator import AirPatrolDataUpdateCoordinator
from .entity import AirPatrolEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirPatrolSensorEntityDescription(SensorEntityDescription):
"""Describes AirPatrol sensor entity."""
data_field: str
SENSOR_DESCRIPTIONS = (
AirPatrolSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
data_field="RoomTemp",
),
AirPatrolSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
data_field="RoomHumidity",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirPatrolConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirPatrol sensors."""
coordinator = config_entry.runtime_data
units = coordinator.data
async_add_entities(
AirPatrolSensor(coordinator, unit_id, description)
for unit_id, unit in units.items()
for description in SENSOR_DESCRIPTIONS
if "climate" in unit and unit["climate"] is not None
)
class AirPatrolSensor(AirPatrolEntity, SensorEntity):
"""AirPatrol sensor entity."""
entity_description: AirPatrolSensorEntityDescription
def __init__(
self,
coordinator: AirPatrolDataUpdateCoordinator,
unit_id: str,
description: AirPatrolSensorEntityDescription,
) -> None:
"""Initialize AirPatrol sensor."""
super().__init__(coordinator, unit_id)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}-{unit_id}-{description.key}"
)
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
if value := self.climate_data.get(self.entity_description.data_field):
return float(value)
return None

View File

@@ -4,28 +4,13 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any
from pyanglianwater import AnglianWater from pyanglianwater import AnglianWater
from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError from pyanglianwater.exceptions import ExpiredAccessTokenError, UnknownEndpointError
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
statistics_during_period,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import VolumeConverter
from .const import CONF_ACCOUNT_NUMBER, DOMAIN from .const import CONF_ACCOUNT_NUMBER, DOMAIN
@@ -59,107 +44,6 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None: async def _async_update_data(self) -> None:
"""Update data from Anglian Water's API.""" """Update data from Anglian Water's API."""
try: try:
await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER]) return await self.api.update(self.config_entry.data[CONF_ACCOUNT_NUMBER])
await self._insert_statistics()
except (ExpiredAccessTokenError, UnknownEndpointError) as err: except (ExpiredAccessTokenError, UnknownEndpointError) as err:
raise UpdateFailed from err raise UpdateFailed from err
async def _insert_statistics(self) -> None:
"""Insert statistics for water meters into Home Assistant."""
for meter in self.api.meters.values():
id_prefix = (
f"{self.config_entry.data[CONF_ACCOUNT_NUMBER]}_{meter.serial_number}"
)
usage_statistic_id = f"{DOMAIN}:{id_prefix}_usage".lower()
_LOGGER.debug("Updating statistics for meter %s", meter.serial_number)
name_prefix = (
f"Anglian Water {self.config_entry.data[CONF_ACCOUNT_NUMBER]} "
f"{meter.serial_number}"
)
usage_metadata = StatisticMetaData(
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} Usage",
source=DOMAIN,
statistic_id=usage_statistic_id,
unit_class=VolumeConverter.UNIT_CLASS,
unit_of_measurement=UnitOfVolume.CUBIC_METERS,
)
last_stat = await get_instance(self.hass).async_add_executor_job(
get_last_statistics, self.hass, 1, usage_statistic_id, True, set()
)
if not last_stat:
_LOGGER.debug("Updating statistics for the first time")
usage_sum = 0.0
last_stats_time = None
else:
if not meter.readings or len(meter.readings) == 0:
_LOGGER.debug("No recent usage statistics found, skipping update")
continue
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
if not parsed_read_at:
_LOGGER.debug(
"Could not parse read_at time %s, skipping update",
meter.readings[0]["read_at"],
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
_LOGGER.debug("Getting statistics at %s", start)
for end in (start + timedelta(seconds=1), None):
stats = await get_instance(self.hass).async_add_executor_job(
statistics_during_period,
self.hass,
start,
end,
{
usage_statistic_id,
},
"hour",
None,
{"sum"},
)
if stats:
break
if end:
_LOGGER.debug(
"Not found, trying to find oldest statistic after %s",
start,
)
assert stats
def _safe_get_sum(records: list[Any]) -> float:
if records and "sum" in records[0]:
return float(records[0]["sum"])
return 0.0
usage_sum = _safe_get_sum(stats.get(usage_statistic_id, []))
last_stats_time = stats[usage_statistic_id][0]["start"]
usage_statistics = []
for read in meter.readings:
parsed_read_at = dt_util.parse_datetime(read["read_at"])
if not parsed_read_at:
_LOGGER.debug(
"Could not parse read_at time %s, skipping reading",
read["read_at"],
)
continue
start = dt_util.as_local(parsed_read_at) - timedelta(hours=1)
if last_stats_time is not None and start.timestamp() <= last_stats_time:
continue
usage_state = max(0, read["consumption"] / 1000)
usage_sum = max(0, read["read"])
usage_statistics.append(
StatisticData(
start=start,
state=usage_state,
sum=usage_sum,
)
)
_LOGGER.debug(
"Adding %s statistics for %s", len(usage_statistics), usage_statistic_id
)
async_add_external_statistics(self.hass, usage_metadata, usage_statistics)

View File

@@ -1,7 +1,6 @@
{ {
"domain": "anglian_water", "domain": "anglian_water",
"name": "Anglian Water", "name": "Anglian Water",
"after_dependencies": ["recorder"],
"codeowners": ["@pantherale0"], "codeowners": ["@pantherale0"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/anglian_water", "documentation": "https://www.home-assistant.io/integrations/anglian_water",

View File

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

View File

@@ -133,6 +133,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"lawn_mower", "lawn_mower",
"light", "light",
"media_player", "media_player",
"scene",
"switch", "switch",
"text", "text",
"update", "update",

View File

@@ -2,7 +2,6 @@
"domain": "blackbird", "domain": "blackbird",
"name": "Monoprice Blackbird Matrix Switch", "name": "Monoprice Blackbird Matrix Switch",
"codeowners": [], "codeowners": [],
"disabled": "This integration is disabled because it references pyserial-asyncio, which does blocking I/O in the asyncio loop and is not maintained.",
"documentation": "https://www.home-assistant.io/integrations/blackbird", "documentation": "https://www.home-assistant.io/integrations/blackbird",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyblackbird"], "loggers": ["pyblackbird"],

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from .const import CONF_SEND_WAKEUP_PROMPT, CONF_SWING_SUPPORT, DOMAIN from .const import CONF_SWING_SUPPORT, DOMAIN
from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
@@ -17,12 +17,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
"""Set up Coolmaster from a config entry.""" """Set up Coolmaster from a config entry."""
host = entry.data[CONF_HOST] host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT] port = entry.data[CONF_PORT]
send_wakeup_prompt = entry.data.get(CONF_SEND_WAKEUP_PROMPT, False)
if not entry.data.get(CONF_SWING_SUPPORT): if not entry.data.get(CONF_SWING_SUPPORT):
coolmaster = CoolMasterNet( coolmaster = CoolMasterNet(
host, host,
port, port,
send_initial_line_feed=send_wakeup_prompt,
) )
else: else:
# Swing support adds an additional request per unit. The requests are # Swing support adds an additional request per unit. The requests are
@@ -31,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
coolmaster = CoolMasterNet( coolmaster = CoolMasterNet(
host, host,
port, port,
send_initial_line_feed=send_wakeup_prompt,
read_timeout=5, read_timeout=5,
swing_support=True, swing_support=True,
) )

View File

@@ -12,13 +12,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import callback from homeassistant.core import callback
from .const import ( from .const import CONF_SUPPORTED_MODES, CONF_SWING_SUPPORT, DEFAULT_PORT, DOMAIN
CONF_SEND_WAKEUP_PROMPT,
CONF_SUPPORTED_MODES,
CONF_SWING_SUPPORT,
DEFAULT_PORT,
DOMAIN,
)
AVAILABLE_MODES = [ AVAILABLE_MODES = [
HVACMode.OFF.value, HVACMode.OFF.value,
@@ -31,15 +25,17 @@ AVAILABLE_MODES = [
MODES_SCHEMA = {vol.Required(mode, default=True): bool for mode in AVAILABLE_MODES} MODES_SCHEMA = {vol.Required(mode, default=True): bool for mode in AVAILABLE_MODES}
DATA_SCHEMA = { DATA_SCHEMA = vol.Schema(
vol.Required(CONF_HOST): str, {
**MODES_SCHEMA, vol.Required(CONF_HOST): str,
vol.Required(CONF_SWING_SUPPORT, default=False): bool, **MODES_SCHEMA,
} vol.Required(CONF_SWING_SUPPORT, default=False): bool,
}
)
async def _validate_connection(host: str, send_wakeup_prompt: bool) -> bool: async def _validate_connection(host: str) -> bool:
cool = CoolMasterNet(host, DEFAULT_PORT, send_initial_line_feed=send_wakeup_prompt) cool = CoolMasterNet(host, DEFAULT_PORT)
units = await cool.status() units = await cool.status()
return bool(units) return bool(units)
@@ -49,14 +45,6 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
def _get_data_schema(self) -> vol.Schema:
schema_dict = DATA_SCHEMA.copy()
if self.show_advanced_options:
schema_dict[vol.Required(CONF_SEND_WAKEUP_PROMPT, default=False)] = bool
return vol.Schema(schema_dict)
@callback @callback
def _async_get_entry(self, data: dict[str, Any]) -> ConfigFlowResult: def _async_get_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
supported_modes = [ supported_modes = [
@@ -69,7 +57,6 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PORT: DEFAULT_PORT, CONF_PORT: DEFAULT_PORT,
CONF_SUPPORTED_MODES: supported_modes, CONF_SUPPORTED_MODES: supported_modes,
CONF_SWING_SUPPORT: data[CONF_SWING_SUPPORT], CONF_SWING_SUPPORT: data[CONF_SWING_SUPPORT],
CONF_SEND_WAKEUP_PROMPT: data.get(CONF_SEND_WAKEUP_PROMPT, False),
}, },
) )
@@ -77,19 +64,15 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
data_schema = self._get_data_schema()
if user_input is None: if user_input is None:
return self.async_show_form(step_id="user", data_schema=data_schema) return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA)
errors = {} errors = {}
host = user_input[CONF_HOST] host = user_input[CONF_HOST]
try: try:
result = await _validate_connection( result = await _validate_connection(host)
host, user_input.get(CONF_SEND_WAKEUP_PROMPT, False)
)
if not result: if not result:
errors["base"] = "no_units" errors["base"] = "no_units"
except OSError: except OSError:
@@ -97,7 +80,7 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
if errors: if errors:
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors step_id="user", data_schema=DATA_SCHEMA, errors=errors
) )
return self._async_get_entry(user_input) return self._async_get_entry(user_input)

View File

@@ -6,6 +6,5 @@ DEFAULT_PORT = 10102
CONF_SUPPORTED_MODES = "supported_modes" CONF_SUPPORTED_MODES = "supported_modes"
CONF_SWING_SUPPORT = "swing_support" CONF_SWING_SUPPORT = "swing_support"
CONF_SEND_WAKEUP_PROMPT = "send_wakeup_prompt"
MAX_RETRIES = 3 MAX_RETRIES = 3
BACKOFF_BASE_DELAY = 2 BACKOFF_BASE_DELAY = 2

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pycoolmasternet_async"], "loggers": ["pycoolmasternet_async"],
"requirements": ["pycoolmasternet-async==0.2.4"] "requirements": ["pycoolmasternet-async==0.2.2"]
} }

View File

@@ -14,12 +14,10 @@
"heat_cool": "Support automatic heat/cool mode", "heat_cool": "Support automatic heat/cool mode",
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"off": "Can be turned off", "off": "Can be turned off",
"send_wakeup_prompt": "Send wakeup prompt",
"swing_support": "Control swing mode" "swing_support": "Control swing mode"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of your CoolMasterNet device.", "host": "The hostname or IP address of your CoolMasterNet device."
"send_wakeup_prompt": "Send the coolmaster unit an empty commaand before issuing any actual command. This is required for serial models."
}, },
"description": "Set up your CoolMasterNet connection details." "description": "Set up your CoolMasterNet connection details."
} }

View File

@@ -9,7 +9,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["async_upnp_client"], "loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"], "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dms", "documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"integration_type": "service", "integration_type": "service",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["async-upnp-client==0.46.1"], "requirements": ["async-upnp-client==0.46.0"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1", "deviceType": "urn:schemas-upnp-org:device:MediaServer:1",

View File

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

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"], "mqtt": ["esphome/discover/#"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": [ "requirements": [
"aioesphomeapi==43.3.0", "aioesphomeapi==43.0.0",
"esphome-dashboard-api==1.3.0", "esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0" "bleak-esphome==3.4.0"
], ],

View File

@@ -5,7 +5,6 @@
"config_flow": true, "config_flow": true,
"dependencies": ["ffmpeg"], "dependencies": ["ffmpeg"],
"documentation": "https://www.home-assistant.io/integrations/freebox", "documentation": "https://www.home-assistant.io/integrations/freebox",
"integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["freebox_api"], "loggers": ["freebox_api"],
"requirements": ["freebox-api==1.2.2"], "requirements": ["freebox-api==1.2.2"],

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError
from pyfritzhome.devicetypes import FritzhomeTemplate, FritzhomeTrigger from pyfritzhome.devicetypes import FritzhomeTemplate
from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -27,7 +27,6 @@ class FritzboxCoordinatorData:
devices: dict[str, FritzhomeDevice] devices: dict[str, FritzhomeDevice]
templates: dict[str, FritzhomeTemplate] templates: dict[str, FritzhomeTemplate]
triggers: dict[str, FritzhomeTrigger]
supported_color_properties: dict[str, tuple[dict, list]] supported_color_properties: dict[str, tuple[dict, list]]
@@ -38,7 +37,6 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
configuration_url: str configuration_url: str
fritz: Fritzhome fritz: Fritzhome
has_templates: bool has_templates: bool
has_triggers: bool
def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None: def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None:
"""Initialize the Fritzbox Smarthome device coordinator.""" """Initialize the Fritzbox Smarthome device coordinator."""
@@ -52,9 +50,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.new_devices: set[str] = set() self.new_devices: set[str] = set()
self.new_templates: set[str] = set() self.new_templates: set[str] = set()
self.new_triggers: set[str] = set()
self.data = FritzboxCoordinatorData({}, {}, {}, {}) self.data = FritzboxCoordinatorData({}, {}, {})
async def async_setup(self) -> None: async def async_setup(self) -> None:
"""Set up the coordinator.""" """Set up the coordinator."""
@@ -77,11 +74,6 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
) )
LOGGER.debug("enable smarthome templates: %s", self.has_templates) LOGGER.debug("enable smarthome templates: %s", self.has_templates)
self.has_triggers = await self.hass.async_add_executor_job(
self.fritz.has_triggers
)
LOGGER.debug("enable smarthome triggers: %s", self.has_triggers)
self.configuration_url = self.fritz.get_prefixed_host() self.configuration_url = self.fritz.get_prefixed_host()
await self.async_config_entry_first_refresh() await self.async_config_entry_first_refresh()
@@ -100,7 +92,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
available_main_ains = [ available_main_ains = [
ain ain
for ain, dev in (data.devices | data.templates | data.triggers).items() for ain, dev in data.devices.items() | data.templates.items()
if dev.device_and_unit_id[1] is None if dev.device_and_unit_id[1] is None
] ]
device_reg = dr.async_get(self.hass) device_reg = dr.async_get(self.hass)
@@ -120,9 +112,6 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.fritz.update_devices(ignore_removed=False) self.fritz.update_devices(ignore_removed=False)
if self.has_templates: if self.has_templates:
self.fritz.update_templates(ignore_removed=False) self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
except RequestConnectionError as ex: except RequestConnectionError as ex:
raise UpdateFailed from ex raise UpdateFailed from ex
except HTTPError: except HTTPError:
@@ -134,8 +123,6 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.fritz.update_devices(ignore_removed=False) self.fritz.update_devices(ignore_removed=False)
if self.has_templates: if self.has_templates:
self.fritz.update_templates(ignore_removed=False) self.fritz.update_templates(ignore_removed=False)
if self.has_triggers:
self.fritz.update_triggers(ignore_removed=False)
devices = self.fritz.get_devices() devices = self.fritz.get_devices()
device_data = {} device_data = {}
@@ -169,20 +156,12 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
for template in templates: for template in templates:
template_data[template.ain] = template template_data[template.ain] = template
trigger_data = {}
if self.has_triggers:
triggers = self.fritz.get_triggers()
for trigger in triggers:
trigger_data[trigger.ain] = trigger
self.new_devices = device_data.keys() - self.data.devices.keys() self.new_devices = device_data.keys() - self.data.devices.keys()
self.new_templates = template_data.keys() - self.data.templates.keys() self.new_templates = template_data.keys() - self.data.templates.keys()
self.new_triggers = trigger_data.keys() - self.data.triggers.keys()
return FritzboxCoordinatorData( return FritzboxCoordinatorData(
devices=device_data, devices=device_data,
templates=template_data, templates=template_data,
triggers=trigger_data,
supported_color_properties=supported_color_properties, supported_color_properties=supported_color_properties,
) )
@@ -214,7 +193,6 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
if ( if (
self.data.devices.keys() - new_data.devices.keys() self.data.devices.keys() - new_data.devices.keys()
or self.data.templates.keys() - new_data.templates.keys() or self.data.templates.keys() - new_data.templates.keys()
or self.data.triggers.keys() - new_data.triggers.keys()
): ):
self.cleanup_removed_devices(new_data) self.cleanup_removed_devices(new_data)

View File

@@ -4,17 +4,14 @@ from __future__ import annotations
from typing import Any from typing import Any
from pyfritzhome.devicetypes import FritzhomeTrigger
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import FritzboxConfigEntry from .coordinator import FritzboxConfigEntry
from .entity import FritzBoxDeviceEntity, FritzBoxEntity from .entity import FritzBoxDeviceEntity
# Coordinator handles data updates, so we can allow unlimited parallel updates # Coordinator handles data updates, so we can allow unlimited parallel updates
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@@ -29,27 +26,21 @@ async def async_setup_entry(
coordinator = entry.runtime_data coordinator = entry.runtime_data
@callback @callback
def _add_entities( def _add_entities(devices: set[str] | None = None) -> None:
devices: set[str] | None = None, triggers: set[str] | None = None """Add devices."""
) -> None:
"""Add devices and triggers."""
if devices is None: if devices is None:
devices = coordinator.new_devices devices = coordinator.new_devices
if triggers is None: if not devices:
triggers = coordinator.new_triggers
if not devices and not triggers:
return return
entities = [ async_add_entities(
FritzboxSwitch(coordinator, ain) FritzboxSwitch(coordinator, ain)
for ain in devices for ain in devices
if coordinator.data.devices[ain].has_switch if coordinator.data.devices[ain].has_switch
] + [FritzboxTrigger(coordinator, ain) for ain in triggers] )
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_add_entities)) entry.async_on_unload(coordinator.async_add_listener(_add_entities))
_add_entities(set(coordinator.data.devices), set(coordinator.data.triggers)) _add_entities(set(coordinator.data.devices))
class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity): class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
@@ -79,42 +70,3 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="manual_switching_disabled", translation_key="manual_switching_disabled",
) )
class FritzboxTrigger(FritzBoxEntity, SwitchEntity):
"""The switch class for FRITZ!SmartHome triggers."""
@property
def data(self) -> FritzhomeTrigger:
"""Return the trigger data entity."""
return self.coordinator.data.triggers[self.ain]
@property
def device_info(self) -> DeviceInfo:
"""Return device specific attributes."""
return DeviceInfo(
name=self.data.name,
identifiers={(DOMAIN, self.ain)},
configuration_url=self.coordinator.configuration_url,
manufacturer="FRITZ!",
model="SmartHome Routine",
)
@property
def is_on(self) -> bool:
"""Return true if the trigger is active."""
return self.data.active # type: ignore [no-any-return]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Activate the trigger."""
await self.hass.async_add_executor_job(
self.coordinator.fritz.set_trigger_active, self.ain
)
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Deactivate the trigger."""
await self.hass.async_add_executor_job(
self.coordinator.fritz.set_trigger_inactive, self.ain
)
await self.coordinator.async_refresh()

View File

@@ -2,23 +2,15 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
from homeassistant.components.stream import (
CONF_RTSP_TRANSPORT,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_AUTHENTICATION, CONF_VERIFY_SSL, Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from .const import CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, SECTION_ADVANCED
DOMAIN = "generic" DOMAIN = "generic"
PLATFORMS = [Platform.CAMERA] PLATFORMS = [Platform.CAMERA]
_LOGGER = logging.getLogger(__name__)
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
@@ -55,38 +47,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 1:
# Migrate to advanced section
new_options = {**entry.options}
advanced = new_options[SECTION_ADVANCED] = {
CONF_FRAMERATE: new_options.pop(CONF_FRAMERATE),
CONF_VERIFY_SSL: new_options.pop(CONF_VERIFY_SSL),
}
# migrate optional fields
for key in (
CONF_RTSP_TRANSPORT,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
CONF_AUTHENTICATION,
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
):
if key in new_options:
advanced[key] = new_options.pop(key)
hass.config_entries.async_update_entry(entry, options=new_options, version=2)
_LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
return True

View File

@@ -41,7 +41,6 @@ from .const import (
CONF_STILL_IMAGE_URL, CONF_STILL_IMAGE_URL,
CONF_STREAM_SOURCE, CONF_STREAM_SOURCE,
GET_IMAGE_TIMEOUT, GET_IMAGE_TIMEOUT,
SECTION_ADVANCED,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -63,11 +62,9 @@ def generate_auth(device_info: Mapping[str, Any]) -> httpx.Auth | None:
"""Generate httpx.Auth object from credentials.""" """Generate httpx.Auth object from credentials."""
username: str | None = device_info.get(CONF_USERNAME) username: str | None = device_info.get(CONF_USERNAME)
password: str | None = device_info.get(CONF_PASSWORD) password: str | None = device_info.get(CONF_PASSWORD)
authentication = device_info.get(CONF_AUTHENTICATION)
if username and password: if username and password:
if ( if authentication == HTTP_DIGEST_AUTHENTICATION:
device_info[SECTION_ADVANCED].get(CONF_AUTHENTICATION)
== HTTP_DIGEST_AUTHENTICATION
):
return httpx.DigestAuth(username=username, password=password) return httpx.DigestAuth(username=username, password=password)
return httpx.BasicAuth(username=username, password=password) return httpx.BasicAuth(username=username, password=password)
return None return None
@@ -102,16 +99,14 @@ class GenericCamera(Camera):
if self._stream_source: if self._stream_source:
self._stream_source = Template(self._stream_source, hass) self._stream_source = Template(self._stream_source, hass)
self._attr_supported_features = CameraEntityFeature.STREAM self._attr_supported_features = CameraEntityFeature.STREAM
self._limit_refetch = device_info[SECTION_ADVANCED].get( self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False)
CONF_LIMIT_REFETCH_TO_URL_CHANGE, False self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
)
self._attr_frame_interval = 1 / device_info[SECTION_ADVANCED][CONF_FRAMERATE]
self.content_type = device_info[CONF_CONTENT_TYPE] self.content_type = device_info[CONF_CONTENT_TYPE]
self.verify_ssl = device_info[SECTION_ADVANCED][CONF_VERIFY_SSL] self.verify_ssl = device_info[CONF_VERIFY_SSL]
if rtsp_transport := device_info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT): if device_info.get(CONF_RTSP_TRANSPORT):
self.stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport self.stream_options[CONF_RTSP_TRANSPORT] = device_info[CONF_RTSP_TRANSPORT]
self._auth = generate_auth(device_info) self._auth = generate_auth(device_info)
if device_info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): if device_info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
self.stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True self.stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
self._last_url = None self._last_url = None

View File

@@ -50,18 +50,10 @@ from homeassistant.const import (
HTTP_DIGEST_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import section
from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers import config_validation as cv, template as template_helper
from homeassistant.helpers.entity_platform import PlatformData from homeassistant.helpers.entity_platform import PlatformData
from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.network import get_url
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.util import slugify from homeassistant.util import slugify
from .camera import GenericCamera, generate_auth from .camera import GenericCamera, generate_auth
@@ -75,20 +67,17 @@ from .const import (
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
GET_IMAGE_TIMEOUT, GET_IMAGE_TIMEOUT,
SECTION_ADVANCED,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_DATA = { DEFAULT_DATA = {
CONF_NAME: DEFAULT_NAME, CONF_NAME: DEFAULT_NAME,
SECTION_ADVANCED: { CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, CONF_FRAMERATE: 2,
CONF_FRAMERATE: 2, CONF_VERIFY_SSL: True,
CONF_VERIFY_SSL: True, CONF_RTSP_TRANSPORT: "tcp",
CONF_RTSP_TRANSPORT: "tcp",
},
} }
SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"} SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
@@ -105,47 +94,58 @@ class InvalidStreamException(HomeAssistantError):
def build_schema( def build_schema(
user_input: Mapping[str, Any],
is_options_flow: bool = False, is_options_flow: bool = False,
show_advanced_options: bool = False, show_advanced_options: bool = False,
) -> vol.Schema: ) -> vol.Schema:
"""Create schema for camera config setup.""" """Create schema for camera config setup."""
rtsp_options = [
SelectOptionDict(
value=value,
label=name,
)
for value, name in RTSP_TRANSPORTS.items()
]
advanced_section = {
vol.Required(CONF_FRAMERATE): vol.All(
vol.Range(min=0, min_included=False), cv.positive_float
),
vol.Required(CONF_VERIFY_SSL): bool,
vol.Optional(CONF_RTSP_TRANSPORT): SelectSelector(
SelectSelectorConfig(
options=rtsp_options,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_AUTHENTICATION): vol.In(
[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]
),
}
spec = { spec = {
vol.Optional(CONF_STREAM_SOURCE): str, vol.Optional(
vol.Optional(CONF_STILL_IMAGE_URL): str, CONF_STILL_IMAGE_URL,
vol.Optional(CONF_USERNAME): str, description={"suggested_value": user_input.get(CONF_STILL_IMAGE_URL, "")},
vol.Optional(CONF_PASSWORD): str, ): str,
vol.Required(SECTION_ADVANCED): section( vol.Optional(
vol.Schema(advanced_section), {"collapsed": True} CONF_STREAM_SOURCE,
), description={"suggested_value": user_input.get(CONF_STREAM_SOURCE, "")},
): str,
vol.Optional(
CONF_RTSP_TRANSPORT,
description={"suggested_value": user_input.get(CONF_RTSP_TRANSPORT)},
): vol.In(RTSP_TRANSPORTS),
vol.Optional(
CONF_AUTHENTICATION,
description={"suggested_value": user_input.get(CONF_AUTHENTICATION)},
): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
vol.Optional(
CONF_USERNAME,
description={"suggested_value": user_input.get(CONF_USERNAME, "")},
): str,
vol.Optional(
CONF_PASSWORD,
description={"suggested_value": user_input.get(CONF_PASSWORD, "")},
): str,
vol.Required(
CONF_FRAMERATE,
description={"suggested_value": user_input.get(CONF_FRAMERATE, 2)},
): vol.All(vol.Range(min=0, min_included=False), cv.positive_float),
vol.Required(
CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True)
): bool,
} }
if is_options_flow: if is_options_flow:
advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool spec[
vol.Required(
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False),
)
] = bool
if show_advanced_options: if show_advanced_options:
advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool spec[
vol.Required(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False),
)
] = bool
return vol.Schema(spec) return vol.Schema(spec)
@@ -187,7 +187,7 @@ async def async_test_still(
return {CONF_STILL_IMAGE_URL: "malformed_url"}, None return {CONF_STILL_IMAGE_URL: "malformed_url"}, None
if not yarl_url.is_absolute(): if not yarl_url.is_absolute():
return {CONF_STILL_IMAGE_URL: "relative_url"}, None return {CONF_STILL_IMAGE_URL: "relative_url"}, None
verify_ssl = info[SECTION_ADVANCED][CONF_VERIFY_SSL] verify_ssl = info[CONF_VERIFY_SSL]
auth = generate_auth(info) auth = generate_auth(info)
try: try:
async_client = get_async_client(hass, verify_ssl=verify_ssl) async_client = get_async_client(hass, verify_ssl=verify_ssl)
@@ -268,9 +268,9 @@ async def async_test_and_preview_stream(
_LOGGER.warning("Problem rendering template %s: %s", stream_source, err) _LOGGER.warning("Problem rendering template %s: %s", stream_source, err)
raise InvalidStreamException("template_error") from err raise InvalidStreamException("template_error") from err
stream_options: dict[str, str | bool | float] = {} stream_options: dict[str, str | bool | float] = {}
if rtsp_transport := info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT): if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
if info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
try: try:
@@ -326,7 +326,7 @@ def register_still_preview(hass: HomeAssistant) -> None:
class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for generic IP camera.""" """Config flow for generic IP camera."""
VERSION = 2 VERSION = 1
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize Generic ConfigFlow.""" """Initialize Generic ConfigFlow."""
@@ -381,7 +381,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
user_input = DEFAULT_DATA.copy() user_input = DEFAULT_DATA.copy()
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=self.add_suggested_values_to_schema(build_schema(), user_input), data_schema=build_schema(user_input),
errors=errors, errors=errors,
) )
@@ -449,19 +449,13 @@ class GenericOptionsFlowHandler(OptionsFlow):
self.preview_stream = None self.preview_stream = None
if not errors: if not errors:
data = { data = {
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
),
**user_input, **user_input,
CONF_CONTENT_TYPE: still_format CONF_CONTENT_TYPE: still_format
or self.config_entry.options.get(CONF_CONTENT_TYPE), or self.config_entry.options.get(CONF_CONTENT_TYPE),
} }
if (
CONF_USE_WALLCLOCK_AS_TIMESTAMPS
not in user_input[SECTION_ADVANCED]
):
data[SECTION_ADVANCED][CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = (
self.config_entry.options[SECTION_ADVANCED].get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
)
)
self.user_input = data self.user_input = data
# temporary preview for user to check the image # temporary preview for user to check the image
self.preview_image_settings = data self.preview_image_settings = data
@@ -470,12 +464,10 @@ class GenericOptionsFlowHandler(OptionsFlow):
user_input = self.user_input user_input = self.user_input
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=self.add_suggested_values_to_schema( data_schema=build_schema(
build_schema(
True,
self.show_advanced_options,
),
user_input or self.config_entry.options, user_input or self.config_entry.options,
True,
self.show_advanced_options,
), ),
errors=errors, errors=errors,
) )
@@ -591,8 +583,7 @@ async def ws_start_preview(
_LOGGER.debug("Got preview still URL: %s", ha_still_url) _LOGGER.debug("Got preview still URL: %s", ha_still_url)
if ha_stream := flow.preview_stream: if ha_stream := flow.preview_stream:
# HLS player needs an absolute URL as base for constructing child playlist URLs ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER)
ha_stream_url = f"{get_url(hass)}{ha_stream.endpoint_url(HLS_PROVIDER)}"
_LOGGER.debug("Got preview stream URL: %s", ha_stream_url) _LOGGER.debug("Got preview stream URL: %s", ha_stream_url)
connection.send_message( connection.send_message(

View File

@@ -9,4 +9,3 @@ CONF_STILL_IMAGE_URL = "still_image_url"
CONF_STREAM_SOURCE = "stream_source" CONF_STREAM_SOURCE = "stream_source"
CONF_FRAMERATE = "framerate" CONF_FRAMERATE = "framerate"
GET_IMAGE_TIMEOUT = 10 GET_IMAGE_TIMEOUT = 10
SECTION_ADVANCED = "advanced"

View File

@@ -26,24 +26,17 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"authentication": "Authentication",
"framerate": "Frame rate (Hz)",
"limit_refetch_to_url_change": "Limit refetch to URL change",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"rtsp_transport": "RTSP transport protocol",
"still_image_url": "Still image URL (e.g. http://...)", "still_image_url": "Still image URL (e.g. http://...)",
"stream_source": "Stream source URL (e.g. rtsp://...)", "stream_source": "Stream source URL (e.g. rtsp://...)",
"username": "[%key:common::config_flow::data::username%]" "username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}, },
"sections": { "description": "Enter the settings to connect to the camera."
"advanced": {
"data": {
"authentication": "Authentication",
"framerate": "Frame rate (Hz)",
"limit_refetch_to_url_change": "Limit refetch to URL change",
"rtsp_transport": "RTSP transport protocol",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"description": "Advanced settings are only needed for special cases. Leave them unchanged unless you know what you are doing.",
"name": "Advanced settings"
}
}
}, },
"user_confirm": { "user_confirm": {
"data": { "data": {
@@ -77,27 +70,19 @@
"step": { "step": {
"init": { "init": {
"data": { "data": {
"authentication": "[%key:component::generic::config::step::user::data::authentication%]",
"framerate": "[%key:component::generic::config::step::user::data::framerate%]",
"limit_refetch_to_url_change": "[%key:component::generic::config::step::user::data::limit_refetch_to_url_change%]",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"rtsp_transport": "[%key:component::generic::config::step::user::data::rtsp_transport%]",
"still_image_url": "[%key:component::generic::config::step::user::data::still_image_url%]", "still_image_url": "[%key:component::generic::config::step::user::data::still_image_url%]",
"stream_source": "[%key:component::generic::config::step::user::data::stream_source%]", "stream_source": "[%key:component::generic::config::step::user::data::stream_source%]",
"username": "[%key:common::config_flow::data::username%]" "use_wallclock_as_timestamps": "Use wallclock as timestamps",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}, },
"sections": { "data_description": {
"advanced": { "use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
"data": {
"authentication": "[%key:component::generic::config::step::user::sections::advanced::data::authentication%]",
"framerate": "[%key:component::generic::config::step::user::sections::advanced::data::framerate%]",
"limit_refetch_to_url_change": "[%key:component::generic::config::step::user::sections::advanced::data::limit_refetch_to_url_change%]",
"rtsp_transport": "[%key:component::generic::config::step::user::sections::advanced::data::rtsp_transport%]",
"use_wallclock_as_timestamps": "Use wallclock as timestamps",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
},
"description": "[%key:component::generic::config::step::user::sections::advanced::description%]",
"name": "[%key:component::generic::config::step::user::sections::advanced::name%]"
}
} }
}, },
"user_confirm": { "user_confirm": {

View File

@@ -8,4 +8,4 @@ HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA) # When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
# in script/hassfest/docker.py. # in script/hassfest/docker.py.
RECOMMENDED_VERSION = "1.9.13" RECOMMENDED_VERSION = "1.9.12"

View File

@@ -12,7 +12,6 @@
"homekit": { "homekit": {
"models": ["iSmartGate"] "models": ["iSmartGate"]
}, },
"integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["ismartgate"], "loggers": ["ismartgate"],
"requirements": ["ismartgate==5.0.2"] "requirements": ["ismartgate==5.0.2"]

View File

@@ -7,7 +7,6 @@ ATTR_CC = "cc"
ATTR_ENABLED = "enabled" ATTR_ENABLED = "enabled"
ATTR_END = "end" ATTR_END = "end"
ATTR_FROM = "from" ATTR_FROM = "from"
ATTR_ALIAS_FROM = "alias_from"
ATTR_ME = "me" ATTR_ME = "me"
ATTR_MESSAGE = "message" ATTR_MESSAGE = "message"
ATTR_PLAIN_TEXT = "plain_text" ATTR_PLAIN_TEXT = "plain_text"

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import base64 import base64
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formataddr
from typing import Any from typing import Any
from googleapiclient.http import HttpRequest from googleapiclient.http import HttpRequest
@@ -18,20 +17,10 @@ from homeassistant.components.notify import (
BaseNotificationService, BaseNotificationService,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .api import AsyncConfigEntryAuth from .api import AsyncConfigEntryAuth
from .const import ( from .const import ATTR_BCC, ATTR_CC, ATTR_FROM, ATTR_ME, ATTR_SEND, DATA_AUTH
ATTR_ALIAS_FROM,
ATTR_BCC,
ATTR_CC,
ATTR_FROM,
ATTR_ME,
ATTR_SEND,
DATA_AUTH,
DOMAIN,
)
async def async_get_service( async def async_get_service(
@@ -58,17 +47,7 @@ class GMailNotificationService(BaseNotificationService):
email = MIMEText(message, "html") email = MIMEText(message, "html")
if to_addrs := kwargs.get(ATTR_TARGET): if to_addrs := kwargs.get(ATTR_TARGET):
email["To"] = ", ".join(to_addrs) email["To"] = ", ".join(to_addrs)
email["From"] = data.get(ATTR_FROM, ATTR_ME)
email_from = data.get(ATTR_FROM, ATTR_ME)
if alias := data.get(ATTR_ALIAS_FROM):
if email_from == ATTR_ME:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_from_for_alias",
)
email["From"] = formataddr((alias, email_from))
else:
email["From"] = email_from
email["Subject"] = title email["Subject"] = title
email[ATTR_CC] = ", ".join(data.get(ATTR_CC, [])) email[ATTR_CC] = ", ".join(data.get(ATTR_CC, []))
email[ATTR_BCC] = ", ".join(data.get(ATTR_BCC, [])) email[ATTR_BCC] = ", ".join(data.get(ATTR_BCC, []))
@@ -78,9 +57,9 @@ class GMailNotificationService(BaseNotificationService):
msg: HttpRequest msg: HttpRequest
users = (await self.auth.get_resource()).users() users = (await self.auth.get_resource()).users()
if data.get(ATTR_SEND) is False: if data.get(ATTR_SEND) is False:
msg = users.drafts().create(userId=email_from, body={ATTR_MESSAGE: body}) msg = users.drafts().create(userId=email["From"], body={ATTR_MESSAGE: body})
else: else:
if not to_addrs: if not to_addrs:
raise ValueError("recipient address required") raise ValueError("recipient address required")
msg = users.messages().send(userId=email_from, body=body) msg = users.messages().send(userId=email["From"], body=body)
await self.hass.async_add_executor_job(msg.execute) await self.hass.async_add_executor_job(msg.execute)

View File

@@ -47,11 +47,6 @@
} }
} }
}, },
"exceptions": {
"missing_from_for_alias": {
"message": "Missing 'from' email when setting an alias to show. You have to provide a 'from' email"
}
},
"services": { "services": {
"set_vacation": { "set_vacation": {
"description": "Sets vacation responder settings for Google Mail.", "description": "Sets vacation responder settings for Google Mail.",

View File

@@ -51,12 +51,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
try: try:
camera = await hass.async_add_executor_job( camera = await hass.async_add_executor_job(
HikCamera, url, port, username, password, ssl HikCamera, url, port, username, password
) )
except requests.exceptions.RequestException as err: except requests.exceptions.RequestException as err:
raise ConfigEntryNotReady(f"Unable to connect to {host}") from err raise ConfigEntryNotReady(f"Unable to connect to {host}") from err
device_id = camera.get_id device_id = camera.get_id()
if device_id is None: if device_id is None:
raise ConfigEntryNotReady(f"Unable to get device ID from {host}") raise ConfigEntryNotReady(f"Unable to get device ID from {host}")

View File

@@ -49,14 +49,14 @@ class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
try: try:
camera = await self.hass.async_add_executor_job( camera = await self.hass.async_add_executor_job(
HikCamera, url, port, username, password, ssl HikCamera, url, port, username, password
) )
device_id = camera.get_id()
device_name = camera.get_name
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
_LOGGER.exception("Error connecting to Hikvision device") _LOGGER.exception("Error connecting to Hikvision device")
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
else: else:
device_id = camera.get_id
device_name = camera.get_name
if device_id is None: if device_id is None:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
else: else:
@@ -102,16 +102,16 @@ class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
try: try:
camera = await self.hass.async_add_executor_job( camera = await self.hass.async_add_executor_job(
HikCamera, url, port, username, password, ssl HikCamera, url, port, username, password
) )
device_id = camera.get_id()
device_name = camera.get_name
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
_LOGGER.exception( _LOGGER.exception(
"Error connecting to Hikvision device during import, aborting" "Error connecting to Hikvision device during import, aborting"
) )
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
device_id = camera.get_id
device_name = camera.get_name
if device_id is None: if device_id is None:
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")

View File

@@ -1,12 +1,12 @@
{ {
"domain": "hikvision", "domain": "hikvision",
"name": "Hikvision", "name": "Hikvision",
"codeowners": ["@mezz64", "@ptarjan"], "codeowners": ["@mezz64"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hikvision", "documentation": "https://www.home-assistant.io/integrations/hikvision",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pyhik"], "loggers": ["pyhik"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["pyHik==0.3.4"] "requirements": ["pyHik==0.3.2"]
} }

View File

@@ -4,7 +4,6 @@
"codeowners": ["@bannhead"], "codeowners": ["@bannhead"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hisense_aehw4a1", "documentation": "https://www.home-assistant.io/integrations/hisense_aehw4a1",
"integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyaehw4a1"], "loggers": ["pyaehw4a1"],
"requirements": ["pyaehw4a1==0.3.9"] "requirements": ["pyaehw4a1==0.3.9"]

View File

@@ -16,7 +16,7 @@ from .entity import HomeWizardEntity
def homewizard_exception_handler[_HomeWizardEntityT: HomeWizardEntity, **_P]( def homewizard_exception_handler[_HomeWizardEntityT: HomeWizardEntity, **_P](
func: Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, Any]], func: Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, None]]: ) -> Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate HomeWizard calls to handle HomeWizardEnergy exceptions. """Decorate HomeWizard Energy calls to handle HomeWizardEnergy exceptions.
A decorator that wraps the passed in function, catches HomeWizardEnergy errors, A decorator that wraps the passed in function, catches HomeWizardEnergy errors,
and reloads the integration when the API was disabled so the reauth flow is and reloads the integration when the API was disabled so the reauth flow is

View File

@@ -1,6 +1,6 @@
{ {
"domain": "homewizard", "domain": "homewizard",
"name": "HomeWizard", "name": "HomeWizard Energy",
"codeowners": ["@DCSBL"], "codeowners": ["@DCSBL"],
"config_flow": true, "config_flow": true,
"dhcp": [ "dhcp": [
@@ -13,6 +13,6 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["homewizard_energy"], "loggers": ["homewizard_energy"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["python-homewizard-energy==10.0.0"], "requirements": ["python-homewizard-energy==9.3.0"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
} }

View File

@@ -2,7 +2,12 @@
from __future__ import annotations from __future__ import annotations
from homewizard_energy.models import Batteries from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from homewizard_energy import HomeWizardEnergy
from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@@ -16,59 +21,69 @@ from .helpers import homewizard_exception_handler
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class HomeWizardSelectEntityDescription(SelectEntityDescription):
"""Class describing HomeWizard select entities."""
available_fn: Callable[[DeviceResponseEntry], bool]
create_fn: Callable[[DeviceResponseEntry], bool]
current_fn: Callable[[DeviceResponseEntry], str | None]
set_fn: Callable[[HomeWizardEnergy, str], Awaitable[Any]]
DESCRIPTIONS = [
HomeWizardSelectEntityDescription(
key="battery_group_mode",
translation_key="battery_group_mode",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
options=[Batteries.Mode.ZERO, Batteries.Mode.STANDBY, Batteries.Mode.TO_FULL],
available_fn=lambda x: x.batteries is not None,
create_fn=lambda x: x.batteries is not None,
current_fn=lambda x: x.batteries.mode if x.batteries else None,
set_fn=lambda api, mode: api.batteries(mode=Batteries.Mode(mode)),
),
]
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: HomeWizardConfigEntry, entry: HomeWizardConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up HomeWizard select based on a config entry.""" """Set up HomeWizard select based on a config entry."""
if entry.runtime_data.data.device.supports_batteries(): async_add_entities(
async_add_entities( HomeWizardSelectEntity(
[ coordinator=entry.runtime_data,
HomeWizardBatteryModeSelectEntity( description=description,
coordinator=entry.runtime_data,
)
]
) )
for description in DESCRIPTIONS
if description.create_fn(entry.runtime_data.data)
)
class HomeWizardBatteryModeSelectEntity(HomeWizardEntity, SelectEntity): class HomeWizardSelectEntity(HomeWizardEntity, SelectEntity):
"""Defines a HomeWizard select entity.""" """Defines a HomeWizard select entity."""
entity_description: SelectEntityDescription entity_description: HomeWizardSelectEntityDescription
def __init__( def __init__(
self, self,
coordinator: HWEnergyDeviceUpdateCoordinator, coordinator: HWEnergyDeviceUpdateCoordinator,
description: HomeWizardSelectEntityDescription,
) -> None: ) -> None:
"""Initialize the switch.""" """Initialize the switch."""
super().__init__(coordinator) super().__init__(coordinator)
description = SelectEntityDescription(
key="battery_group_mode",
translation_key="battery_group_mode",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
options=[
str(mode)
for mode in (coordinator.data.device.supported_battery_modes() or [])
],
)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
@property @property
def current_option(self) -> str | None: def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state.""" """Return the selected entity option to represent the entity state."""
return ( return self.entity_description.current_fn(self.coordinator.data)
self.coordinator.data.batteries.mode
if self.coordinator.data.batteries and self.coordinator.data.batteries.mode
else None
)
@homewizard_exception_handler @homewizard_exception_handler
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Change the selected option.""" """Change the selected option."""
await self.coordinator.api.batteries(Batteries.Mode(option)) await self.entity_description.set_fn(self.coordinator.api, option)
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()

View File

@@ -12,13 +12,13 @@
"wrong_device": "The configured device is not the same found on this IP address." "wrong_device": "The configured device is not the same found on this IP address."
}, },
"error": { "error": {
"api_not_enabled": "The local API is disabled. Go to the HomeWizard app and enable the API in the device settings.", "api_not_enabled": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings.",
"authorization_failed": "Failed to authorize, make sure to press the button of the device within 30 seconds", "authorization_failed": "Failed to authorize, make sure to press the button of the device within 30 seconds",
"network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network" "network_error": "Device unreachable, make sure that you have entered the correct IP address and that the device is available in your network"
}, },
"step": { "step": {
"authorize": { "authorize": {
"description": "Press the button on the HomeWizard device for two seconds, then select the button below.", "description": "Press the button on the HomeWizard Energy device for two seconds, then select the button below.",
"title": "Authorize" "title": "Authorize"
}, },
"discovery_confirm": { "discovery_confirm": {
@@ -30,7 +30,7 @@
"title": "Re-authenticate" "title": "Re-authenticate"
}, },
"reauth_enable_api": { "reauth_enable_api": {
"description": "The local API is disabled. Go to the HomeWizard app and enable the API in the device settings." "description": "The local API is disabled. Go to the HomeWizard Energy app and enable the API in the device settings."
}, },
"reconfigure": { "reconfigure": {
"data": { "data": {
@@ -46,9 +46,9 @@
"ip_address": "[%key:common::config_flow::data::ip%]" "ip_address": "[%key:common::config_flow::data::ip%]"
}, },
"data_description": { "data_description": {
"ip_address": "The IP address of your HomeWizard device." "ip_address": "The IP address of your HomeWizard Energy device."
}, },
"description": "Enter the IP address of your HomeWizard device to integrate with Home Assistant.", "description": "Enter the IP address of your HomeWizard Energy device to integrate with Home Assistant.",
"title": "Configure device" "title": "Configure device"
} }
} }
@@ -65,9 +65,7 @@
"state": { "state": {
"standby": "Standby", "standby": "Standby",
"to_full": "Manual charge mode", "to_full": "Manual charge mode",
"zero": "Zero mode", "zero": "Zero mode"
"zero_charge_only": "Zero mode (charge only)",
"zero_discharge_only": "Zero mode (discharge only)"
} }
} }
}, },
@@ -174,7 +172,7 @@
"message": "The local API is unauthorized. Restore API access by following the instructions in the repair issue." "message": "The local API is unauthorized. Restore API access by following the instructions in the repair issue."
}, },
"communication_error": { "communication_error": {
"message": "An error occurred while communicating with your HomeWizard device" "message": "An error occurred while communicating with your HomeWizard Energy device"
} }
}, },
"issues": { "issues": {

View File

@@ -1,4 +1,4 @@
"""Creates HomeWizard switch entities.""" """Creates HomeWizard Energy switch entities."""
from __future__ import annotations from __future__ import annotations

View File

@@ -4,7 +4,6 @@
"codeowners": ["@dennisschroer"], "codeowners": ["@dennisschroer"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/huisbaasje", "documentation": "https://www.home-assistant.io/integrations/huisbaasje",
"integration_type": "device",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["energyflip"], "loggers": ["energyflip"],
"requirements": ["energyflip-client==0.2.2"] "requirements": ["energyflip-client==0.2.2"]

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ from pyicloud.exceptions import (
) )
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
@@ -155,8 +155,8 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold, CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold,
} }
# If this is a password update attempt, don't try and creating one # If this is a password update attempt, update the entry instead of creating one
if self.source == SOURCE_USER: if step_id == "user":
return self.async_create_entry(title=self._username, data=data) return self.async_create_entry(title=self._username, data=data)
entry = await self.async_set_unique_id(self.unique_id) entry = await self.async_set_unique_id(self.unique_id)

View File

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

View File

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

View File

@@ -4,7 +4,6 @@
"codeowners": ["@dgomes"], "codeowners": ["@dgomes"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/kmtronic", "documentation": "https://www.home-assistant.io/integrations/kmtronic",
"integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pykmtronic"], "loggers": ["pykmtronic"],
"requirements": ["pykmtronic==0.3.0"] "requirements": ["pykmtronic==0.3.0"]

View File

@@ -94,8 +94,6 @@ SERVICE_KNX_EVENT_REGISTER: Final = "event_register"
SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register" SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register"
SERVICE_KNX_READ: Final = "read" SERVICE_KNX_READ: Final = "read"
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY: Final = "data_secure_group_key_issue"
class KNXConfigEntryData(TypedDict, total=False): class KNXConfigEntryData(TypedDict, total=False):
"""Config entry for the KNX integration.""" """Config entry for the KNX integration."""
@@ -165,7 +163,6 @@ SUPPORTED_PLATFORMS_UI: Final = {
Platform.CLIMATE, Platform.CLIMATE,
Platform.COVER, Platform.COVER,
Platform.DATE, Platform.DATE,
Platform.FAN,
Platform.DATETIME, Platform.DATETIME,
Platform.LIGHT, Platform.LIGHT,
Platform.SWITCH, Platform.SWITCH,
@@ -220,9 +217,3 @@ class ClimateConf:
FAN_MAX_STEP: Final = "fan_max_step" FAN_MAX_STEP: Final = "fan_max_step"
FAN_SPEED_MODE: Final = "fan_speed_mode" FAN_SPEED_MODE: Final = "fan_speed_mode"
FAN_ZERO_MODE: Final = "fan_zero_mode" FAN_ZERO_MODE: Final = "fan_zero_mode"
class FanConf:
"""Common config keys for fan."""
MAX_STEP: Final = "max_step"

View File

@@ -77,11 +77,6 @@ class _KnxEntityBase(Entity):
"""Store register state change callback and start device object.""" """Store register state change callback and start device object."""
self._device.register_device_updated_cb(self.after_update_callback) self._device.register_device_updated_cb(self.after_update_callback)
self._device.xknx.devices.async_add(self._device) self._device.xknx.devices.async_add(self._device)
if uid := self.unique_id:
self._knx_module.add_to_group_address_entities(
group_addresses=self._device.group_addresses(),
identifier=(self.platform_data.domain, uid),
)
# super call needed to have methods of multi-inherited classes called # super call needed to have methods of multi-inherited classes called
# eg. for restoring state (like _KNXSwitch) # eg. for restoring state (like _KNXSwitch)
await super().async_added_to_hass() await super().async_added_to_hass()
@@ -90,11 +85,6 @@ class _KnxEntityBase(Entity):
"""Disconnect device object when removed.""" """Disconnect device object when removed."""
self._device.unregister_device_updated_cb(self.after_update_callback) self._device.unregister_device_updated_cb(self.after_update_callback)
self._device.xknx.devices.async_remove(self._device) self._device.xknx.devices.async_remove(self._device)
if uid := self.unique_id:
self._knx_module.remove_from_group_address_entities(
group_addresses=self._device.group_addresses(),
identifier=(self.platform_data.domain, uid),
)
class KnxYamlEntity(_KnxEntityBase): class KnxYamlEntity(_KnxEntityBase):

View File

@@ -5,17 +5,13 @@ from __future__ import annotations
import math import math
from typing import Any, Final from typing import Any, Final
from propcache.api import cached_property
from xknx.devices import Fan as XknxFan from xknx.devices import Fan as XknxFan
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.util.percentage import ( from homeassistant.util.percentage import (
percentage_to_ranged_value, percentage_to_ranged_value,
@@ -23,18 +19,10 @@ from homeassistant.util.percentage import (
) )
from homeassistant.util.scaling import int_states_in_range from homeassistant.util.scaling import int_states_in_range
from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, FanConf from .const import KNX_ADDRESS, KNX_MODULE_KEY
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .entity import KnxYamlEntity
from .knx_module import KNXModule from .knx_module import KNXModule
from .schema import FanSchema from .schema import FanSchema
from .storage.const import (
CONF_ENTITY,
CONF_GA_OSCILLATION,
CONF_GA_SPEED,
CONF_GA_STEP,
CONF_SPEED,
)
from .storage.util import ConfigExtractor
DEFAULT_PERCENTAGE: Final = 50 DEFAULT_PERCENTAGE: Final = 50
@@ -46,36 +34,40 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up fan(s) for KNX platform.""" """Set up fan(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY] knx_module = hass.data[KNX_MODULE_KEY]
platform = async_get_current_platform() config: list[ConfigType] = knx_module.config_yaml[Platform.FAN]
knx_module.config_store.add_platform(
platform=Platform.FAN,
controller=KnxUiEntityPlatformController(
knx_module=knx_module,
entity_platform=platform,
entity_class=KnxUiFan,
),
)
entities: list[_KnxFan] = [] async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config)
if yaml_platform_config := knx_module.config_yaml.get(Platform.FAN):
entities.extend(
KnxYamlFan(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.FAN):
entities.extend(
KnxUiFan(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
)
if entities:
async_add_entities(entities)
class _KnxFan(FanEntity): class KNXFan(KnxYamlEntity, FanEntity):
"""Representation of a KNX fan.""" """Representation of a KNX fan."""
_device: XknxFan _device: XknxFan
_step_range: tuple[int, int] | None
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX fan."""
max_step = config.get(FanSchema.CONF_MAX_STEP)
super().__init__(
knx_module=knx_module,
device=XknxFan(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
group_address_oscillation=config.get(
FanSchema.CONF_OSCILLATION_ADDRESS
),
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
max_step=max_step,
),
)
# FanSpeedMode.STEP if max_step is set
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.speed.group_address)
async def async_set_percentage(self, percentage: int) -> None: async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage.""" """Set the speed of the fan, as a percentage."""
@@ -85,7 +77,7 @@ class _KnxFan(FanEntity):
else: else:
await self._device.set_speed(percentage) await self._device.set_speed(percentage)
@cached_property @property
def supported_features(self) -> FanEntityFeature: def supported_features(self) -> FanEntityFeature:
"""Flag supported features.""" """Flag supported features."""
flags = ( flags = (
@@ -111,7 +103,7 @@ class _KnxFan(FanEntity):
) )
return self._device.current_speed return self._device.current_speed
@cached_property @property
def speed_count(self) -> int: def speed_count(self) -> int:
"""Return the number of speeds the fan supports.""" """Return the number of speeds the fan supports."""
if self._step_range is None: if self._step_range is None:
@@ -142,76 +134,3 @@ class _KnxFan(FanEntity):
def oscillating(self) -> bool | None: def oscillating(self) -> bool | None:
"""Return whether or not the fan is currently oscillating.""" """Return whether or not the fan is currently oscillating."""
return self._device.current_oscillation return self._device.current_oscillation
class KnxYamlFan(_KnxFan, KnxYamlEntity):
"""Representation of a KNX fan configured from YAML."""
_device: XknxFan
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX fan."""
max_step = config.get(FanConf.MAX_STEP)
super().__init__(
knx_module=knx_module,
device=XknxFan(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
group_address_oscillation=config.get(
FanSchema.CONF_OSCILLATION_ADDRESS
),
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
max_step=max_step,
),
)
# FanSpeedMode.STEP if max_step is set
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.speed.group_address)
class KnxUiFan(_KnxFan, KnxUiEntity):
"""Representation of a KNX fan configured from UI."""
_device: XknxFan
def __init__(
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
) -> None:
"""Initialize of KNX fan."""
knx_conf = ConfigExtractor(config[DOMAIN])
# max_step is required for step mode, thus can be used to differentiate modes
max_step: int | None = knx_conf.get(CONF_SPEED, FanConf.MAX_STEP)
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
if max_step:
# step control
speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_STEP)
speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_STEP)
else:
# percentage control
speed_write = knx_conf.get_write(CONF_SPEED, CONF_GA_SPEED)
speed_state = knx_conf.get_state_and_passive(CONF_SPEED, CONF_GA_SPEED)
self._device = XknxFan(
xknx=knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
group_address_speed=speed_write,
group_address_speed_state=speed_state,
group_address_oscillation=knx_conf.get_write(CONF_GA_OSCILLATION),
group_address_oscillation_state=knx_conf.get_state_and_passive(
CONF_GA_OSCILLATION
),
max_step=max_step,
sync_state=knx_conf.get(CONF_SYNC_STATE),
)
# FanSpeedMode.STEP if max_step is set
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None

View File

@@ -56,7 +56,6 @@ from .const import (
from .device import KNXInterfaceDevice from .device import KNXInterfaceDevice
from .expose import KNXExposeSensor, KNXExposeTime from .expose import KNXExposeSensor, KNXExposeTime
from .project import KNXProject from .project import KNXProject
from .repairs import data_secure_group_key_issue_dispatcher
from .storage.config_store import KNXConfigStore from .storage.config_store import KNXConfigStore
from .telegrams import Telegrams from .telegrams import Telegrams
@@ -108,12 +107,8 @@ class KNXModule:
self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {}
self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {}
self.group_address_entities: dict[
DeviceGroupAddress, set[tuple[str, str]] # {(platform, unique_id),}
] = {}
self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback()
self.entry.async_on_unload(data_secure_group_key_issue_dispatcher(self))
self.entry.async_on_unload( self.entry.async_on_unload(
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
) )
@@ -230,29 +225,6 @@ class KNXModule:
threaded=True, threaded=True,
) )
def add_to_group_address_entities(
self,
group_addresses: set[DeviceGroupAddress],
identifier: tuple[str, str], # (platform, unique_id)
) -> None:
"""Register entity in group_address_entities map."""
for ga in group_addresses:
if ga not in self.group_address_entities:
self.group_address_entities[ga] = set()
self.group_address_entities[ga].add(identifier)
def remove_from_group_address_entities(
self,
group_addresses: set[DeviceGroupAddress],
identifier: tuple[str, str],
) -> None:
"""Unregister entity from group_address_entities map."""
for ga in group_addresses:
if ga in self.group_address_entities:
self.group_address_entities[ga].discard(identifier)
if not self.group_address_entities[ga]:
del self.group_address_entities[ga]
def connection_state_changed_cb(self, state: XknxConnectionState) -> None: def connection_state_changed_cb(self, state: XknxConnectionState) -> None:
"""Call invoked after a KNX connection state change was received.""" """Call invoked after a KNX connection state change was received."""
self.connected = state == XknxConnectionState.CONNECTED self.connected = state == XknxConnectionState.CONNECTED

View File

@@ -9,9 +9,9 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["xknx", "xknxproject"], "loggers": ["xknx", "xknxproject"],
"quality_scale": "platinum", "quality_scale": "silver",
"requirements": [ "requirements": [
"xknx==3.13.0", "xknx==3.12.0",
"xknxproject==3.8.2", "xknxproject==3.8.2",
"knx-frontend==2025.10.31.195356" "knx-frontend==2025.10.31.195356"
], ],

View File

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

View File

@@ -1,175 +0,0 @@
"""Repairs for KNX integration."""
from __future__ import annotations
from collections.abc import Callable
from functools import partial
from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol
from xknx.exceptions.exception import InvalidSecureConfiguration
from xknx.telegram import GroupAddress, IndividualAddress, Telegram
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir, selector
from homeassistant.helpers.dispatcher import async_dispatcher_connect
if TYPE_CHECKING:
from .knx_module import KNXModule
from .const import (
CONF_KNX_KNXKEY_PASSWORD,
DOMAIN,
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
KNXConfigEntryData,
)
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
from .telegrams import SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, TelegramDict
CONF_KEYRING_FILE: Final = "knxkeys_file"
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
if issue_id == REPAIR_ISSUE_DATA_SECURE_GROUP_KEY:
return DataSecureGroupIssueRepairFlow()
# If KNX adds confirm-only repairs in the future, this should be changed
# to return a ConfirmRepairFlow instead of raising a ValueError
raise ValueError(f"unknown repair {issue_id}")
######################
# DataSecure key issue
######################
@callback
def data_secure_group_key_issue_dispatcher(knx_module: KNXModule) -> Callable[[], None]:
"""Watcher for DataSecure group key issues."""
return async_dispatcher_connect(
knx_module.hass,
signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
target=partial(_data_secure_group_key_issue_handler, knx_module),
)
@callback
def _data_secure_group_key_issue_handler(
knx_module: KNXModule, telegram: Telegram, telegram_dict: TelegramDict
) -> None:
"""Handle DataSecure group key issue telegrams."""
if telegram.destination_address not in knx_module.group_address_entities:
# Only report issues for configured group addresses
return
issue_registry = ir.async_get(knx_module.hass)
new_ga = str(telegram.destination_address)
new_ia = str(telegram.source_address)
new_data = {new_ga: new_ia}
if existing_issue := issue_registry.async_get_issue(
DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY
):
assert isinstance(existing_issue.data, dict)
existing_data: dict[str, str] = existing_issue.data # type: ignore[assignment]
if new_ga in existing_data:
current_ias = existing_data[new_ga].split(", ")
if new_ia in current_ias:
return
current_ias = sorted([*current_ias, new_ia], key=IndividualAddress)
new_data[new_ga] = ", ".join(current_ias)
new_data_unsorted = existing_data | new_data
new_data = {
key: new_data_unsorted[key]
for key in sorted(new_data_unsorted, key=GroupAddress)
}
issue_registry.async_get_or_create(
DOMAIN,
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
data=new_data, # type: ignore[arg-type]
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.ERROR,
translation_key=REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
translation_placeholders={
"addresses": "\n".join(
f"`{ga}` from {ias}" for ga, ias in new_data.items()
),
"interface": str(knx_module.xknx.current_address),
},
)
class DataSecureGroupIssueRepairFlow(RepairsFlow):
"""Handler for an issue fixing flow for outdated DataSecure keys."""
@callback
def _async_get_placeholders(self) -> dict[str, str]:
issue_registry = ir.async_get(self.hass)
issue = issue_registry.async_get_issue(self.handler, self.issue_id)
assert issue is not None
return issue.translation_placeholders or {}
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_secure_knxkeys()
async def async_step_secure_knxkeys(
self, user_input: dict[str, Any] | None = None
) -> data_entry_flow.FlowResult:
"""Manage upload of new KNX Keyring file."""
errors: dict[str, str] = {}
if user_input is not None:
password = user_input[CONF_KNX_KNXKEY_PASSWORD]
keyring = None
try:
keyring = await save_uploaded_knxkeys_file(
self.hass,
uploaded_file_id=user_input[CONF_KEYRING_FILE],
password=password,
)
except InvalidSecureConfiguration:
errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature"
if not errors and keyring:
new_entry_data = KNXConfigEntryData(
knxkeys_filename=f"{DOMAIN}/{DEFAULT_KNX_KEYRING_FILENAME}",
knxkeys_password=password,
)
return self.finish_flow(new_entry_data)
fields = {
vol.Required(CONF_KEYRING_FILE): selector.FileSelector(
config=selector.FileSelectorConfig(accept=".knxkeys")
),
vol.Required(CONF_KNX_KNXKEY_PASSWORD): selector.TextSelector(),
}
return self.async_show_form(
step_id="secure_knxkeys",
data_schema=vol.Schema(fields),
description_placeholders=self._async_get_placeholders(),
errors=errors,
)
@callback
def finish_flow(
self, new_entry_data: KNXConfigEntryData
) -> data_entry_flow.FlowResult:
"""Finish the repair flow. Reload the config entry."""
knx_config_entries = self.hass.config_entries.async_entries(DOMAIN)
if knx_config_entries:
config_entry = knx_config_entries[0] # single_config_entry
new_data = {**config_entry.data, **new_entry_data}
self.hass.config_entries.async_update_entry(config_entry, data=new_data)
self.hass.config_entries.async_schedule_reload(config_entry.entry_id)
return self.async_create_entry(data={})

View File

@@ -59,7 +59,6 @@ from .const import (
ClimateConf, ClimateConf,
ColorTempModes, ColorTempModes,
CoverConf, CoverConf,
FanConf,
FanZeroMode, FanZeroMode,
) )
from .validation import ( from .validation import (
@@ -576,6 +575,7 @@ class FanSchema(KNXPlatformSchema):
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
CONF_OSCILLATION_ADDRESS = "oscillation_address" CONF_OSCILLATION_ADDRESS = "oscillation_address"
CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address" CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address"
CONF_MAX_STEP = "max_step"
DEFAULT_NAME = "KNX Fan" DEFAULT_NAME = "KNX Fan"
@@ -586,7 +586,7 @@ class FanSchema(KNXPlatformSchema):
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator, vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator, vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
vol.Optional(FanConf.MAX_STEP): cv.byte, vol.Optional(CONF_MAX_STEP): cv.byte,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
} }
) )

View File

@@ -17,8 +17,6 @@ CONF_GA_DATE: Final = "ga_date"
CONF_GA_DATETIME: Final = "ga_datetime" CONF_GA_DATETIME: Final = "ga_datetime"
CONF_GA_TIME: Final = "ga_time" CONF_GA_TIME: Final = "ga_time"
CONF_GA_STEP: Final = "ga_step"
# Climate # Climate
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current" CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current" CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current"
@@ -44,15 +42,11 @@ CONF_GA_FAN_SWING_HORIZONTAL: Final = "ga_fan_swing_horizontal"
# Cover # Cover
CONF_GA_UP_DOWN: Final = "ga_up_down" CONF_GA_UP_DOWN: Final = "ga_up_down"
CONF_GA_STOP: Final = "ga_stop" CONF_GA_STOP: Final = "ga_stop"
CONF_GA_STEP: Final = "ga_step"
CONF_GA_POSITION_SET: Final = "ga_position_set" CONF_GA_POSITION_SET: Final = "ga_position_set"
CONF_GA_POSITION_STATE: Final = "ga_position_state" CONF_GA_POSITION_STATE: Final = "ga_position_state"
CONF_GA_ANGLE: Final = "ga_angle" CONF_GA_ANGLE: Final = "ga_angle"
# Fan
CONF_SPEED: Final = "speed"
CONF_GA_SPEED: Final = "ga_speed"
CONF_GA_OSCILLATION: Final = "ga_oscillation"
# Light # Light
CONF_COLOR_TEMP_MIN: Final = "color_temp_min" CONF_COLOR_TEMP_MIN: Final = "color_temp_min"
CONF_COLOR_TEMP_MAX: Final = "color_temp_max" CONF_COLOR_TEMP_MAX: Final = "color_temp_max"

View File

@@ -28,7 +28,6 @@ from ..const import (
ClimateConf, ClimateConf,
ColorTempModes, ColorTempModes,
CoverConf, CoverConf,
FanConf,
FanZeroMode, FanZeroMode,
) )
from .const import ( from .const import (
@@ -63,7 +62,6 @@ from .const import (
CONF_GA_OP_MODE_PROTECTION, CONF_GA_OP_MODE_PROTECTION,
CONF_GA_OP_MODE_STANDBY, CONF_GA_OP_MODE_STANDBY,
CONF_GA_OPERATION_MODE, CONF_GA_OPERATION_MODE,
CONF_GA_OSCILLATION,
CONF_GA_POSITION_SET, CONF_GA_POSITION_SET,
CONF_GA_POSITION_STATE, CONF_GA_POSITION_STATE,
CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_BRIGHTNESS,
@@ -71,7 +69,6 @@ from .const import (
CONF_GA_SATURATION, CONF_GA_SATURATION,
CONF_GA_SENSOR, CONF_GA_SENSOR,
CONF_GA_SETPOINT_SHIFT, CONF_GA_SETPOINT_SHIFT,
CONF_GA_SPEED,
CONF_GA_STEP, CONF_GA_STEP,
CONF_GA_STOP, CONF_GA_STOP,
CONF_GA_SWITCH, CONF_GA_SWITCH,
@@ -83,7 +80,6 @@ from .const import (
CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_BRIGHTNESS,
CONF_GA_WHITE_SWITCH, CONF_GA_WHITE_SWITCH,
CONF_IGNORE_AUTO_MODE, CONF_IGNORE_AUTO_MODE,
CONF_SPEED,
CONF_TARGET_TEMPERATURE, CONF_TARGET_TEMPERATURE,
) )
from .knx_selector import ( from .knx_selector import (
@@ -224,42 +220,6 @@ DATETIME_KNX_SCHEMA = vol.Schema(
} }
) )
FAN_KNX_SCHEMA = vol.Schema(
{
vol.Required(CONF_SPEED): GroupSelect(
GroupSelectOption(
translation_key="percentage_mode",
schema={
vol.Required(CONF_GA_SPEED): GASelector(
write_required=True, valid_dpt="5.001"
),
},
),
GroupSelectOption(
translation_key="step_mode",
schema={
vol.Required(CONF_GA_STEP): GASelector(
write_required=True, valid_dpt="5.010"
),
vol.Required(FanConf.MAX_STEP, default=3): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=100,
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
},
),
collapsible=False,
),
vol.Optional(CONF_GA_OSCILLATION): GASelector(
write_required=True, valid_dpt="1"
),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
}
)
@unique @unique
class LightColorMode(StrEnum): class LightColorMode(StrEnum):
@@ -553,7 +513,6 @@ KNX_SCHEMA_FOR_PLATFORM = {
Platform.COVER: COVER_KNX_SCHEMA, Platform.COVER: COVER_KNX_SCHEMA,
Platform.DATE: DATE_KNX_SCHEMA, Platform.DATE: DATE_KNX_SCHEMA,
Platform.DATETIME: DATETIME_KNX_SCHEMA, Platform.DATETIME: DATETIME_KNX_SCHEMA,
Platform.FAN: FAN_KNX_SCHEMA,
Platform.LIGHT: LIGHT_KNX_SCHEMA, Platform.LIGHT: LIGHT_KNX_SCHEMA,
Platform.SWITCH: SWITCH_KNX_SCHEMA, Platform.SWITCH: SWITCH_KNX_SCHEMA,
Platform.TIME: TIME_KNX_SCHEMA, Platform.TIME: TIME_KNX_SCHEMA,

View File

@@ -10,10 +10,9 @@ from xknx.secure.keyring import Keyring, sync_load_keyring
from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.storage import STORAGE_DIR
from ..const import DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY from ..const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -46,11 +45,4 @@ async def save_uploaded_knxkeys_file(
shutil.move(file_path, dest_file) shutil.move(file_path, dest_file)
return keyring return keyring
keyring = await hass.async_add_executor_job(_process_upload) return await hass.async_add_executor_job(_process_upload)
# If there is an existing DataSecure group key issue, remove it.
# GAs might not be DataSecure anymore after uploading a valid keyring,
# if they are, we raise the issue again when receiving a telegram.
ir.async_delete_issue(hass, DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY)
return keyring

View File

@@ -460,41 +460,6 @@
} }
} }
}, },
"fan": {
"description": "The KNX fan platform is used as an interface to fan actuators.",
"knx": {
"ga_oscillation": {
"description": "Toggle oscillation of the fan.",
"label": "Oscillation"
},
"speed": {
"description": "Control the speed of the fan.",
"ga_speed": {
"description": "Group address to control the current speed of the fan as a percentage value.",
"label": "Speed"
},
"ga_step": {
"description": "Group address to control the current speed step.",
"label": "Step"
},
"max_step": {
"description": "Number of discrete fan speed steps (Off excluded).",
"label": "Fan steps"
},
"options": {
"percentage_mode": {
"description": "Set the fan speed as a percentage value (0-100%).",
"label": "Percentage"
},
"step_mode": {
"description": "Set the fan speed in discrete steps.",
"label": "Steps"
}
},
"title": "Fan speed"
}
}
},
"header": "Create new entity", "header": "Create new entity",
"light": { "light": {
"description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.", "description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.",
@@ -710,30 +675,6 @@
"message": "Invalid type for `knx.send` service: {type}" "message": "Invalid type for `knx.send` service: {type}"
} }
}, },
"issues": {
"data_secure_group_key_issue": {
"fix_flow": {
"error": {
"keyfile_invalid_signature": "[%key:component::knx::config::error::keyfile_invalid_signature%]"
},
"step": {
"secure_knxkeys": {
"data": {
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]",
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]"
},
"data_description": {
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]",
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]"
},
"description": "Telegrams for group addresses used in Home Assistant could not be decrypted because Data Secure keys are missing or invalid:\n\n{addresses}\n\nTo fix this, update the sending devices configurations via ETS and provide an updated KNX Keyring file. Make sure that the group addresses used in Home Assistant are associated with the interface used by Home Assistant (`{interface}` when the issue last occurred).",
"title": "Update KNX Keyring"
}
}
},
"title": "KNX Data Secure telegrams can't be decrypted"
}
},
"options": { "options": {
"step": { "step": {
"communication_settings": { "communication_settings": {

View File

@@ -26,9 +26,6 @@ STORAGE_KEY: Final = f"{DOMAIN}/telegrams_history.json"
# dispatcher signal for KNX interface device triggers # dispatcher signal for KNX interface device triggers
SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram") SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram")
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType(
"knx_data_secure_issue_telegram"
)
class DecodedTelegramPayload(TypedDict): class DecodedTelegramPayload(TypedDict):
@@ -77,11 +74,6 @@ class Telegrams:
match_for_outgoing=True, match_for_outgoing=True,
) )
) )
self._xknx_data_secure_group_key_issue_cb_handle = (
xknx.telegram_queue.register_data_secure_group_key_issue_cb(
self._xknx_data_secure_group_key_issue_cb,
)
)
self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size) self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size)
self.last_ga_telegrams: dict[str, TelegramDict] = {} self.last_ga_telegrams: dict[str, TelegramDict] = {}
@@ -115,14 +107,6 @@ class Telegrams:
self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict
async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict) async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict)
def _xknx_data_secure_group_key_issue_cb(self, telegram: Telegram) -> None:
"""Handle telegrams with undecodable data secure payload from xknx."""
telegram_dict = self.telegram_to_dict(telegram)
self.recent_telegrams.append(telegram_dict)
async_dispatcher_send(
self.hass, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, telegram, telegram_dict
)
def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: def telegram_to_dict(self, telegram: Telegram) -> TelegramDict:
"""Convert a Telegram to a dict.""" """Convert a Telegram to a dict."""
dst_name = "" dst_name = ""

View File

@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from contextlib import ExitStack
from functools import wraps from functools import wraps
import inspect import inspect
from typing import TYPE_CHECKING, Any, Final, overload from typing import TYPE_CHECKING, Any, Final, overload
@@ -35,11 +34,7 @@ from .storage.entity_store_validation import (
validate_entity_data, validate_entity_data,
) )
from .storage.serialize import get_serialized_schema from .storage.serialize import get_serialized_schema
from .telegrams import ( from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
SIGNAL_KNX_TELEGRAM,
TelegramDict,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from .knx_module import KNXModule from .knx_module import KNXModule
@@ -339,23 +334,11 @@ def ws_subscribe_telegram(
telegram_dict, telegram_dict,
) )
stack = ExitStack() connection.subscriptions[msg["id"]] = async_dispatcher_connect(
stack.callback( hass,
async_dispatcher_connect( signal=SIGNAL_KNX_TELEGRAM,
hass, target=forward_telegram,
signal=SIGNAL_KNX_TELEGRAM,
target=forward_telegram,
)
) )
stack.callback(
async_dispatcher_connect(
hass,
signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
target=forward_telegram,
)
)
connection.subscriptions[msg["id"]] = stack.close
connection.send_result(msg["id"]) connection.send_result(msg["id"])

View File

@@ -5,7 +5,6 @@
"codeowners": ["@OnFreund"], "codeowners": ["@OnFreund"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/kodi", "documentation": "https://www.home-assistant.io/integrations/kodi",
"integration_type": "service",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["jsonrpc_async", "jsonrpc_base", "jsonrpc_websocket", "pykodi"], "loggers": ["jsonrpc_async", "jsonrpc_base", "jsonrpc_websocket", "pykodi"],
"requirements": ["pykodi==0.2.7"], "requirements": ["pykodi==0.2.7"],

View File

@@ -4,7 +4,6 @@
"codeowners": ["@stegm"], "codeowners": ["@stegm"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/kostal_plenticore", "documentation": "https://www.home-assistant.io/integrations/kostal_plenticore",
"integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["kostal"], "loggers": ["kostal"],
"requirements": ["pykoplenti==1.3.0"] "requirements": ["pykoplenti==1.3.0"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@eifinger"], "codeowners": ["@eifinger"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/kraken", "documentation": "https://www.home-assistant.io/integrations/kraken",
"integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["krakenex", "pykrakenapi"], "loggers": ["krakenex", "pykrakenapi"],
"requirements": ["krakenex==2.2.2", "pykrakenapi==0.1.8"] "requirements": ["krakenex==2.2.2", "pykrakenapi==0.1.8"]

View File

@@ -10,7 +10,6 @@
"config_flow": true, "config_flow": true,
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/kulersky", "documentation": "https://www.home-assistant.io/integrations/kulersky",
"integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["bleak", "pykulersky"], "loggers": ["bleak", "pykulersky"],
"requirements": ["pykulersky==0.5.8"] "requirements": ["pykulersky==0.5.8"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@IceBotYT"], "codeowners": ["@IceBotYT"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lacrosse_view", "documentation": "https://www.home-assistant.io/integrations/lacrosse_view",
"integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["lacrosse_view"], "loggers": ["lacrosse_view"],
"requirements": ["lacrosse-view==1.1.1"] "requirements": ["lacrosse-view==1.1.1"]

View File

@@ -5,7 +5,6 @@
"config_flow": true, "config_flow": true,
"dependencies": ["usb"], "dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter",
"integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["ultraheat-api==0.5.7"] "requirements": ["ultraheat-api==0.5.7"]
} }

View File

@@ -4,7 +4,6 @@
"codeowners": ["@joostlek"], "codeowners": ["@joostlek"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lastfm", "documentation": "https://www.home-assistant.io/integrations/lastfm",
"integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pylast"], "loggers": ["pylast"],
"requirements": ["pylast==5.1.0"] "requirements": ["pylast==5.1.0"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@xLarry"], "codeowners": ["@xLarry"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/laundrify", "documentation": "https://www.home-assistant.io/integrations/laundrify",
"integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["laundrify-aio==1.2.2"] "requirements": ["laundrify-aio==1.2.2"]
} }

View File

@@ -19,8 +19,8 @@ from .const import CONF_DOMAIN_DATA
from .entity import LcnEntity from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 2 PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=10) SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_entities( def add_lcn_entities(

View File

@@ -36,7 +36,7 @@ from .const import (
from .entity import LcnEntity from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 2 PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1) SCAN_INTERVAL = timedelta(minutes=1)

View File

@@ -27,7 +27,7 @@ from .const import (
from .entity import LcnEntity from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 2 PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1) SCAN_INTERVAL = timedelta(minutes=1)

View File

@@ -33,8 +33,8 @@ from .helpers import InputType, LcnConfigEntry
BRIGHTNESS_SCALE = (1, 100) BRIGHTNESS_SCALE = (1, 100)
PARALLEL_UPDATES = 2 PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=10) SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_entities( def add_lcn_entities(

View File

@@ -6,9 +6,8 @@
"config_flow": true, "config_flow": true,
"dependencies": ["http", "websocket_api"], "dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/lcn", "documentation": "https://www.home-assistant.io/integrations/lcn",
"integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pypck"], "loggers": ["pypck"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["pypck==0.9.8", "lcn-frontend==0.2.7"] "requirements": ["pypck==0.9.7", "lcn-frontend==0.2.7"]
} }

View File

@@ -22,7 +22,7 @@ from .const import (
from .entity import LcnEntity from .entity import LcnEntity
from .helpers import LcnConfigEntry from .helpers import LcnConfigEntry
PARALLEL_UPDATES = 2 PARALLEL_UPDATES = 0
def add_lcn_entities( def add_lcn_entities(

View File

@@ -40,7 +40,7 @@ from .const import (
from .entity import LcnEntity from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 2 PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1) SCAN_INTERVAL = timedelta(minutes=1)

View File

@@ -17,8 +17,8 @@ from .const import CONF_DOMAIN_DATA, CONF_OUTPUT, OUTPUT_PORTS, RELAY_PORTS, SET
from .entity import LcnEntity from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 2 PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=10) SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_switch_entities( def add_lcn_switch_entities(

View File

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

View File

@@ -34,7 +34,6 @@
"config_flow": true, "config_flow": true,
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble", "documentation": "https://www.home-assistant.io/integrations/led_ble",
"integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.7"] "requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.7"]
} }

View File

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

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from typing import Any from typing import Any
@@ -242,7 +241,6 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
# If device is off, turn on first. # If device is off, turn on first.
if not self.data.is_on: if not self.data.is_on:
await self.async_turn_on() await self.async_turn_on()
await asyncio.sleep(2)
_LOGGER.debug( _LOGGER.debug(
"[%s:%s] async_set_hvac_mode: %s", "[%s:%s] async_set_hvac_mode: %s",
@@ -326,11 +324,10 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
# If device is off, turn on first. # If device is off, turn on first.
if not self.data.is_on: if not self.data.is_on:
await self.async_turn_on() await self.async_turn_on()
await asyncio.sleep(2)
if hvac_mode and hvac_mode != self.hvac_mode: if hvac_mode and hvac_mode != self.hvac_mode:
await self.async_set_hvac_mode(HVACMode(hvac_mode)) await self.async_set_hvac_mode(HVACMode(hvac_mode))
await asyncio.sleep(2)
_LOGGER.debug( _LOGGER.debug(
"[%s:%s] async_set_temperature: %s", "[%s:%s] async_set_temperature: %s",
self.coordinator.device_name, self.coordinator.device_name,

View File

@@ -3,13 +3,8 @@
"name": "LG ThinQ", "name": "LG ThinQ",
"codeowners": ["@LG-ThinQ-Integration"], "codeowners": ["@LG-ThinQ-Integration"],
"config_flow": true, "config_flow": true,
"dhcp": [ "dhcp": [{ "macaddress": "34E6E6*" }],
{
"macaddress": "34E6E6*"
}
],
"documentation": "https://www.home-assistant.io/integrations/lg_thinq", "documentation": "https://www.home-assistant.io/integrations/lg_thinq",
"integration_type": "hub",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["thinqconnect"], "loggers": ["thinqconnect"],
"requirements": ["thinqconnect==1.0.9"] "requirements": ["thinqconnect==1.0.9"]

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Sab44"], "codeowners": ["@Sab44"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor", "documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
"integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["librehardwaremonitor-api==1.5.0"] "requirements": ["librehardwaremonitor-api==1.5.0"]

View File

@@ -49,7 +49,6 @@
"LIFX Z" "LIFX Z"
] ]
}, },
"integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "loggers": ["aiolifx", "aiolifx_effects", "bitstring"],
"requirements": [ "requirements": [

View File

@@ -4,7 +4,6 @@
"codeowners": ["@StefanIacobLivisi", "@planbnet"], "codeowners": ["@StefanIacobLivisi", "@planbnet"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/livisi", "documentation": "https://www.home-assistant.io/integrations/livisi",
"integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["livisi==0.0.25"] "requirements": ["livisi==0.0.25"]
} }

View File

@@ -4,7 +4,6 @@
"codeowners": ["@ANMalko", "@bdraco"], "codeowners": ["@ANMalko", "@bdraco"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lookin", "documentation": "https://www.home-assistant.io/integrations/lookin",
"integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiolookin"], "loggers": ["aiolookin"],
"requirements": ["aiolookin==1.0.0"], "requirements": ["aiolookin==1.0.0"],

View File

@@ -6,7 +6,6 @@
"config_flow": true, "config_flow": true,
"dependencies": ["webhook"], "dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/loqed", "documentation": "https://www.home-assistant.io/integrations/loqed",
"integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["loqedAPI==2.1.10"], "requirements": ["loqedAPI==2.1.10"],
"zeroconf": [ "zeroconf": [

View File

@@ -4,7 +4,6 @@
"codeowners": ["@majuss", "@suaveolent"], "codeowners": ["@majuss", "@suaveolent"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lupusec", "documentation": "https://www.home-assistant.io/integrations/lupusec",
"integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["lupupy"], "loggers": ["lupupy"],
"requirements": ["lupupy==0.3.2"] "requirements": ["lupupy==0.3.2"]

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