mirror of
https://github.com/home-assistant/core.git
synced 2025-12-18 22:08:14 +00:00
Compare commits
2 Commits
dev
...
scene_trig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
937ccc7867 | ||
|
|
385ab8f5d3 |
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -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+
|
||||||
|
|||||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -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"
|
||||||
|
|||||||
@@ -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
10
CODEOWNERS
generated
@@ -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
2
Dockerfile
generated
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
|||||||
"lawn_mower",
|
"lawn_mower",
|
||||||
"light",
|
"light",
|
||||||
"media_player",
|
"media_player",
|
||||||
|
"scene",
|
||||||
"switch",
|
"switch",
|
||||||
"text",
|
"text",
|
||||||
"update",
|
"update",
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Creates HomeWizard switch entities."""
|
"""Creates HomeWizard Energy switch entities."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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={})
|
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user