mirror of
https://github.com/home-assistant/core.git
synced 2025-12-19 06:18:08 +00:00
Compare commits
66 Commits
schedule/a
...
knx-fan-sw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f99038159 | ||
|
|
fafaab7f6c | ||
|
|
33dcde7de1 | ||
|
|
c449b2e2e8 | ||
|
|
f40f7072c8 | ||
|
|
4163ecd833 | ||
|
|
9c59d528af | ||
|
|
c2440c4ebd | ||
|
|
cb275f65ba | ||
|
|
b1923df3ca | ||
|
|
7ddfd155ca | ||
|
|
e01df6d10d | ||
|
|
54010728d5 | ||
|
|
62a3b3827f | ||
|
|
b9abfba20f | ||
|
|
eca9f36e55 | ||
|
|
3c865c6f41 | ||
|
|
3b32c4bcbf | ||
|
|
fcdc1cfed9 | ||
|
|
0fd782c4ab | ||
|
|
bbcaf69973 | ||
|
|
f2b713acac | ||
|
|
6c944d6b15 | ||
|
|
4dd3abb16a | ||
|
|
d2672b9ddf | ||
|
|
ff30492919 | ||
|
|
b5ccdf8165 | ||
|
|
b3c745cfa7 | ||
|
|
67aeafa797 | ||
|
|
3d71b6de44 | ||
|
|
5349045932 | ||
|
|
4960871c84 | ||
|
|
af3861cd6b | ||
|
|
f9a070e9b3 | ||
|
|
fd503b2e33 | ||
|
|
e5a73fcf57 | ||
|
|
6991e01489 | ||
|
|
c8636ee6f3 | ||
|
|
52229dc5a8 | ||
|
|
f013455843 | ||
|
|
cae5bca546 | ||
|
|
49299b06c6 | ||
|
|
8e39027ad5 | ||
|
|
2a1ce2df61 | ||
|
|
7a6d929150 | ||
|
|
6f4a112dbb | ||
|
|
2197b910fb | ||
|
|
7e2a9cd7f9 | ||
|
|
e7ed7a8ed2 | ||
|
|
9ba2d0defe | ||
|
|
231300919c | ||
|
|
664c50586f | ||
|
|
43b9ecfc2b | ||
|
|
f1237ed52a | ||
|
|
ecf8f55cc4 | ||
|
|
ff36693057 | ||
|
|
005785997c | ||
|
|
9917b82b66 | ||
|
|
9c927406ac | ||
|
|
972d95602a | ||
|
|
5e0549a18f | ||
|
|
bcbb159fb2 | ||
|
|
0123ca656a | ||
|
|
1f699c729c | ||
|
|
50c3fcfeba | ||
|
|
2af1e098cc |
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -51,6 +51,9 @@ rules:
|
||||
- **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)
|
||||
|
||||
**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
|
||||
|
||||
- **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
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -567,6 +567,7 @@ homeassistant.components.wake_word.*
|
||||
homeassistant.components.wallbox.*
|
||||
homeassistant.components.waqi.*
|
||||
homeassistant.components.water_heater.*
|
||||
homeassistant.components.watts.*
|
||||
homeassistant.components.watttime.*
|
||||
homeassistant.components.weather.*
|
||||
homeassistant.components.webhook.*
|
||||
|
||||
6
CODEOWNERS
generated
6
CODEOWNERS
generated
@@ -1195,8 +1195,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ourgroceries/ @OnFreund
|
||||
/homeassistant/components/overkiz/ @imicknl
|
||||
/tests/components/overkiz/ @imicknl
|
||||
/homeassistant/components/overseerr/ @joostlek
|
||||
/tests/components/overseerr/ @joostlek
|
||||
/homeassistant/components/overseerr/ @joostlek @AmGarera
|
||||
/tests/components/overseerr/ @joostlek @AmGarera
|
||||
/homeassistant/components/ovo_energy/ @timmo001
|
||||
/tests/components/ovo_energy/ @timmo001
|
||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||
@@ -1798,6 +1798,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/watergate/ @adam-the-hero
|
||||
/tests/components/watergate/ @adam-the-hero
|
||||
/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
|
||||
/tests/components/watttime/ @bachya
|
||||
/homeassistant/components/waze_travel_time/ @eifinger
|
||||
|
||||
2
Dockerfile
generated
2
Dockerfile
generated
@@ -24,7 +24,7 @@ ENV \
|
||||
COPY rootfs /
|
||||
|
||||
# Add go2rtc binary
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640 /usr/local/bin/go2rtc /bin/go2rtc
|
||||
COPY --from=ghcr.io/alexxit/go2rtc@sha256:f394f6329f5389a4c9a7fc54b09fdec9621bbb78bf7a672b973440bbdfb02241 /usr/local/bin/go2rtc /bin/go2rtc
|
||||
|
||||
RUN \
|
||||
# Verify go2rtc can be executed
|
||||
|
||||
@@ -148,7 +148,7 @@ class ActronSystemClimate(BaseClimateEntity):
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
fan_mode = self._status.user_aircon_settings.fan_mode
|
||||
fan_mode = self._status.user_aircon_settings.base_fan_mode
|
||||
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
|
||||
|
||||
@property
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["actron-neo-api==0.2.0"]
|
||||
"requirements": ["actron-neo-api==0.4.1"]
|
||||
}
|
||||
|
||||
@@ -88,21 +88,11 @@ class AirPatrolClimate(AirPatrolEntity, ClimateEntity):
|
||||
super().__init__(coordinator, 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
|
||||
def params(self) -> dict[str, Any]:
|
||||
"""Return the current parameters for the climate entity."""
|
||||
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
|
||||
def current_humidity(self) -> float | None:
|
||||
"""Return the current humidity."""
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.const import Platform
|
||||
DOMAIN = "airpatrol"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
AIRPATROL_ERRORS = (AirPatrolAuthenticationError, AirPatrolError)
|
||||
|
||||
@@ -38,7 +38,17 @@ class AirPatrolEntity(CoordinatorEntity[AirPatrolDataUpdateCoordinator]):
|
||||
"""Return the device data."""
|
||||
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
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._unit_id in self.coordinator.data
|
||||
return (
|
||||
super().available
|
||||
and self._unit_id in self.coordinator.data
|
||||
and "climate" in self.device_data
|
||||
and self.climate_data is not None
|
||||
)
|
||||
|
||||
89
homeassistant/components/airpatrol/sensor.py
Normal file
89
homeassistant/components/airpatrol/sensor.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""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
|
||||
@@ -2,6 +2,7 @@
|
||||
"domain": "blackbird",
|
||||
"name": "Monoprice Blackbird Matrix Switch",
|
||||
"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",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyblackbird"],
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.0.0",
|
||||
"aioesphomeapi==43.3.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError
|
||||
from pyfritzhome.devicetypes import FritzhomeTemplate
|
||||
from pyfritzhome.devicetypes import FritzhomeTemplate, FritzhomeTrigger
|
||||
from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -27,6 +27,7 @@ class FritzboxCoordinatorData:
|
||||
|
||||
devices: dict[str, FritzhomeDevice]
|
||||
templates: dict[str, FritzhomeTemplate]
|
||||
triggers: dict[str, FritzhomeTrigger]
|
||||
supported_color_properties: dict[str, tuple[dict, list]]
|
||||
|
||||
|
||||
@@ -37,6 +38,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
configuration_url: str
|
||||
fritz: Fritzhome
|
||||
has_templates: bool
|
||||
has_triggers: bool
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: FritzboxConfigEntry) -> None:
|
||||
"""Initialize the Fritzbox Smarthome device coordinator."""
|
||||
@@ -50,8 +52,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
self.new_devices: 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:
|
||||
"""Set up the coordinator."""
|
||||
@@ -74,6 +77,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
)
|
||||
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()
|
||||
|
||||
await self.async_config_entry_first_refresh()
|
||||
@@ -92,7 +100,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
available_main_ains = [
|
||||
ain
|
||||
for ain, dev in data.devices.items() | data.templates.items()
|
||||
for ain, dev in (data.devices | data.templates | data.triggers).items()
|
||||
if dev.device_and_unit_id[1] is None
|
||||
]
|
||||
device_reg = dr.async_get(self.hass)
|
||||
@@ -112,6 +120,9 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
self.fritz.update_devices(ignore_removed=False)
|
||||
if self.has_templates:
|
||||
self.fritz.update_templates(ignore_removed=False)
|
||||
if self.has_triggers:
|
||||
self.fritz.update_triggers(ignore_removed=False)
|
||||
|
||||
except RequestConnectionError as ex:
|
||||
raise UpdateFailed from ex
|
||||
except HTTPError:
|
||||
@@ -123,6 +134,8 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
self.fritz.update_devices(ignore_removed=False)
|
||||
if self.has_templates:
|
||||
self.fritz.update_templates(ignore_removed=False)
|
||||
if self.has_triggers:
|
||||
self.fritz.update_triggers(ignore_removed=False)
|
||||
|
||||
devices = self.fritz.get_devices()
|
||||
device_data = {}
|
||||
@@ -156,12 +169,20 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
for template in templates:
|
||||
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_templates = template_data.keys() - self.data.templates.keys()
|
||||
self.new_triggers = trigger_data.keys() - self.data.triggers.keys()
|
||||
|
||||
return FritzboxCoordinatorData(
|
||||
devices=device_data,
|
||||
templates=template_data,
|
||||
triggers=trigger_data,
|
||||
supported_color_properties=supported_color_properties,
|
||||
)
|
||||
|
||||
@@ -193,6 +214,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
if (
|
||||
self.data.devices.keys() - new_data.devices.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)
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyfritzhome.devicetypes import FritzhomeTrigger
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FritzboxConfigEntry
|
||||
from .entity import FritzBoxDeviceEntity
|
||||
from .entity import FritzBoxDeviceEntity, FritzBoxEntity
|
||||
|
||||
# Coordinator handles data updates, so we can allow unlimited parallel updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -26,21 +29,27 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
@callback
|
||||
def _add_entities(devices: set[str] | None = None) -> None:
|
||||
"""Add devices."""
|
||||
def _add_entities(
|
||||
devices: set[str] | None = None, triggers: set[str] | None = None
|
||||
) -> None:
|
||||
"""Add devices and triggers."""
|
||||
if devices is None:
|
||||
devices = coordinator.new_devices
|
||||
if not devices:
|
||||
if triggers is None:
|
||||
triggers = coordinator.new_triggers
|
||||
if not devices and not triggers:
|
||||
return
|
||||
async_add_entities(
|
||||
entities = [
|
||||
FritzboxSwitch(coordinator, ain)
|
||||
for ain in devices
|
||||
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))
|
||||
|
||||
_add_entities(set(coordinator.data.devices))
|
||||
_add_entities(set(coordinator.data.devices), set(coordinator.data.triggers))
|
||||
|
||||
|
||||
class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
|
||||
@@ -70,3 +79,42 @@ class FritzboxSwitch(FritzBoxDeviceEntity, SwitchEntity):
|
||||
translation_domain=DOMAIN,
|
||||
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,15 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
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.const import Platform
|
||||
from homeassistant.const import CONF_AUTHENTICATION, CONF_VERIFY_SSL, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, SECTION_ADVANCED
|
||||
|
||||
DOMAIN = "generic"
|
||||
PLATFORMS = [Platform.CAMERA]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
@@ -47,3 +55,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
|
||||
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,6 +41,7 @@ from .const import (
|
||||
CONF_STILL_IMAGE_URL,
|
||||
CONF_STREAM_SOURCE,
|
||||
GET_IMAGE_TIMEOUT,
|
||||
SECTION_ADVANCED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -62,9 +63,11 @@ def generate_auth(device_info: Mapping[str, Any]) -> httpx.Auth | None:
|
||||
"""Generate httpx.Auth object from credentials."""
|
||||
username: str | None = device_info.get(CONF_USERNAME)
|
||||
password: str | None = device_info.get(CONF_PASSWORD)
|
||||
authentication = device_info.get(CONF_AUTHENTICATION)
|
||||
if username and password:
|
||||
if authentication == HTTP_DIGEST_AUTHENTICATION:
|
||||
if (
|
||||
device_info[SECTION_ADVANCED].get(CONF_AUTHENTICATION)
|
||||
== HTTP_DIGEST_AUTHENTICATION
|
||||
):
|
||||
return httpx.DigestAuth(username=username, password=password)
|
||||
return httpx.BasicAuth(username=username, password=password)
|
||||
return None
|
||||
@@ -99,14 +102,16 @@ class GenericCamera(Camera):
|
||||
if self._stream_source:
|
||||
self._stream_source = Template(self._stream_source, hass)
|
||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
||||
self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False)
|
||||
self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
|
||||
self._limit_refetch = device_info[SECTION_ADVANCED].get(
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE, False
|
||||
)
|
||||
self._attr_frame_interval = 1 / device_info[SECTION_ADVANCED][CONF_FRAMERATE]
|
||||
self.content_type = device_info[CONF_CONTENT_TYPE]
|
||||
self.verify_ssl = device_info[CONF_VERIFY_SSL]
|
||||
if device_info.get(CONF_RTSP_TRANSPORT):
|
||||
self.stream_options[CONF_RTSP_TRANSPORT] = device_info[CONF_RTSP_TRANSPORT]
|
||||
self.verify_ssl = device_info[SECTION_ADVANCED][CONF_VERIFY_SSL]
|
||||
if rtsp_transport := device_info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT):
|
||||
self.stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
|
||||
self._auth = generate_auth(device_info)
|
||||
if device_info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
|
||||
if device_info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
|
||||
self.stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
|
||||
|
||||
self._last_url = None
|
||||
|
||||
@@ -50,10 +50,17 @@ from homeassistant.const import (
|
||||
HTTP_DIGEST_AUTHENTICATION,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import config_validation as cv, template as template_helper
|
||||
from homeassistant.helpers.entity_platform import PlatformData
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .camera import GenericCamera, generate_auth
|
||||
@@ -67,17 +74,20 @@ from .const import (
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
GET_IMAGE_TIMEOUT,
|
||||
SECTION_ADVANCED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DATA = {
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||
CONF_FRAMERATE: 2,
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_RTSP_TRANSPORT: "tcp",
|
||||
SECTION_ADVANCED: {
|
||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||
CONF_FRAMERATE: 2,
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_RTSP_TRANSPORT: "tcp",
|
||||
},
|
||||
}
|
||||
|
||||
SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
|
||||
@@ -94,58 +104,47 @@ class InvalidStreamException(HomeAssistantError):
|
||||
|
||||
|
||||
def build_schema(
|
||||
user_input: Mapping[str, Any],
|
||||
is_options_flow: bool = False,
|
||||
show_advanced_options: bool = False,
|
||||
) -> vol.Schema:
|
||||
"""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 = {
|
||||
vol.Optional(
|
||||
CONF_STILL_IMAGE_URL,
|
||||
description={"suggested_value": user_input.get(CONF_STILL_IMAGE_URL, "")},
|
||||
): str,
|
||||
vol.Optional(
|
||||
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,
|
||||
vol.Optional(CONF_STREAM_SOURCE): str,
|
||||
vol.Optional(CONF_STILL_IMAGE_URL): str,
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
vol.Required(SECTION_ADVANCED): section(
|
||||
vol.Schema(advanced_section), {"collapsed": True}
|
||||
),
|
||||
}
|
||||
if is_options_flow:
|
||||
spec[
|
||||
vol.Required(
|
||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
|
||||
default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False),
|
||||
)
|
||||
] = bool
|
||||
advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool
|
||||
if show_advanced_options:
|
||||
spec[
|
||||
vol.Required(
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
|
||||
default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False),
|
||||
)
|
||||
] = bool
|
||||
advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool
|
||||
|
||||
return vol.Schema(spec)
|
||||
|
||||
|
||||
@@ -187,7 +186,7 @@ async def async_test_still(
|
||||
return {CONF_STILL_IMAGE_URL: "malformed_url"}, None
|
||||
if not yarl_url.is_absolute():
|
||||
return {CONF_STILL_IMAGE_URL: "relative_url"}, None
|
||||
verify_ssl = info[CONF_VERIFY_SSL]
|
||||
verify_ssl = info[SECTION_ADVANCED][CONF_VERIFY_SSL]
|
||||
auth = generate_auth(info)
|
||||
try:
|
||||
async_client = get_async_client(hass, verify_ssl=verify_ssl)
|
||||
@@ -268,9 +267,9 @@ async def async_test_and_preview_stream(
|
||||
_LOGGER.warning("Problem rendering template %s: %s", stream_source, err)
|
||||
raise InvalidStreamException("template_error") from err
|
||||
stream_options: dict[str, str | bool | float] = {}
|
||||
if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
|
||||
if rtsp_transport := info[SECTION_ADVANCED].get(CONF_RTSP_TRANSPORT):
|
||||
stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
|
||||
if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
|
||||
if info[SECTION_ADVANCED].get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
|
||||
stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
|
||||
|
||||
try:
|
||||
@@ -326,7 +325,7 @@ def register_still_preview(hass: HomeAssistant) -> None:
|
||||
class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for generic IP camera."""
|
||||
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize Generic ConfigFlow."""
|
||||
@@ -381,7 +380,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
user_input = DEFAULT_DATA.copy()
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=build_schema(user_input),
|
||||
data_schema=self.add_suggested_values_to_schema(build_schema(), user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -449,13 +448,19 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
self.preview_stream = None
|
||||
if not errors:
|
||||
data = {
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
|
||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
|
||||
),
|
||||
**user_input,
|
||||
CONF_CONTENT_TYPE: still_format
|
||||
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
|
||||
# temporary preview for user to check the image
|
||||
self.preview_image_settings = data
|
||||
@@ -464,10 +469,12 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
user_input = self.user_input
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=build_schema(
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
build_schema(
|
||||
True,
|
||||
self.show_advanced_options,
|
||||
),
|
||||
user_input or self.config_entry.options,
|
||||
True,
|
||||
self.show_advanced_options,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -9,3 +9,4 @@ CONF_STILL_IMAGE_URL = "still_image_url"
|
||||
CONF_STREAM_SOURCE = "stream_source"
|
||||
CONF_FRAMERATE = "framerate"
|
||||
GET_IMAGE_TIMEOUT = 10
|
||||
SECTION_ADVANCED = "advanced"
|
||||
|
||||
@@ -26,17 +26,24 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"authentication": "Authentication",
|
||||
"framerate": "Frame rate (Hz)",
|
||||
"limit_refetch_to_url_change": "Limit refetch to URL change",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"rtsp_transport": "RTSP transport protocol",
|
||||
"still_image_url": "Still image URL (e.g. http://...)",
|
||||
"stream_source": "Stream source URL (e.g. rtsp://...)",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Enter the settings to connect to the camera."
|
||||
"sections": {
|
||||
"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": {
|
||||
"data": {
|
||||
@@ -70,19 +77,27 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"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%]",
|
||||
"rtsp_transport": "[%key:component::generic::config::step::user::data::rtsp_transport%]",
|
||||
"still_image_url": "[%key:component::generic::config::step::user::data::still_image_url%]",
|
||||
"stream_source": "[%key:component::generic::config::step::user::data::stream_source%]",
|
||||
"use_wallclock_as_timestamps": "Use wallclock as timestamps",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
|
||||
"sections": {
|
||||
"advanced": {
|
||||
"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": {
|
||||
|
||||
@@ -8,4 +8,4 @@ HA_MANAGED_API_PORT = 11984
|
||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
|
||||
# in script/hassfest/docker.py.
|
||||
RECOMMENDED_VERSION = "1.9.12"
|
||||
RECOMMENDED_VERSION = "1.9.13"
|
||||
|
||||
@@ -51,12 +51,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
|
||||
|
||||
try:
|
||||
camera = await hass.async_add_executor_job(
|
||||
HikCamera, url, port, username, password
|
||||
HikCamera, url, port, username, password, ssl
|
||||
)
|
||||
except requests.exceptions.RequestException as 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:
|
||||
raise ConfigEntryNotReady(f"Unable to get device ID from {host}")
|
||||
|
||||
|
||||
@@ -49,14 +49,14 @@ class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
camera = await self.hass.async_add_executor_job(
|
||||
HikCamera, url, port, username, password
|
||||
HikCamera, url, port, username, password, ssl
|
||||
)
|
||||
device_id = camera.get_id()
|
||||
device_name = camera.get_name
|
||||
except requests.exceptions.RequestException:
|
||||
_LOGGER.exception("Error connecting to Hikvision device")
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
device_id = camera.get_id
|
||||
device_name = camera.get_name
|
||||
if device_id is None:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
@@ -102,16 +102,16 @@ class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
camera = await self.hass.async_add_executor_job(
|
||||
HikCamera, url, port, username, password
|
||||
HikCamera, url, port, username, password, ssl
|
||||
)
|
||||
device_id = camera.get_id()
|
||||
device_name = camera.get_name
|
||||
except requests.exceptions.RequestException:
|
||||
_LOGGER.exception(
|
||||
"Error connecting to Hikvision device during import, aborting"
|
||||
)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
device_id = camera.get_id
|
||||
device_name = camera.get_name
|
||||
if device_id is None:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-homewizard-energy==9.3.0"],
|
||||
"requirements": ["python-homewizard-energy==10.0.0"],
|
||||
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 homewizard_energy.models import Batteries
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -21,69 +16,59 @@ from .helpers import homewizard_exception_handler
|
||||
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(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeWizardConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up HomeWizard select based on a config entry."""
|
||||
async_add_entities(
|
||||
HomeWizardSelectEntity(
|
||||
coordinator=entry.runtime_data,
|
||||
description=description,
|
||||
if entry.runtime_data.data.device.supports_batteries():
|
||||
async_add_entities(
|
||||
[
|
||||
HomeWizardBatteryModeSelectEntity(
|
||||
coordinator=entry.runtime_data,
|
||||
)
|
||||
]
|
||||
)
|
||||
for description in DESCRIPTIONS
|
||||
if description.create_fn(entry.runtime_data.data)
|
||||
)
|
||||
|
||||
|
||||
class HomeWizardSelectEntity(HomeWizardEntity, SelectEntity):
|
||||
class HomeWizardBatteryModeSelectEntity(HomeWizardEntity, SelectEntity):
|
||||
"""Defines a HomeWizard select entity."""
|
||||
|
||||
entity_description: HomeWizardSelectEntityDescription
|
||||
entity_description: SelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HWEnergyDeviceUpdateCoordinator,
|
||||
description: HomeWizardSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
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._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the selected entity option to represent the entity state."""
|
||||
return self.entity_description.current_fn(self.coordinator.data)
|
||||
return (
|
||||
self.coordinator.data.batteries.mode
|
||||
if self.coordinator.data.batteries and self.coordinator.data.batteries.mode
|
||||
else None
|
||||
)
|
||||
|
||||
@homewizard_exception_handler
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
await self.entity_description.set_fn(self.coordinator.api, option)
|
||||
await self.coordinator.api.batteries(Batteries.Mode(option))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -65,7 +65,9 @@
|
||||
"state": {
|
||||
"standby": "Standby",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ from pyicloud.exceptions import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
@@ -155,8 +155,8 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold,
|
||||
}
|
||||
|
||||
# If this is a password update attempt, update the entry instead of creating one
|
||||
if step_id == "user":
|
||||
# If this is a password update attempt, don't try and creating one
|
||||
if self.source == SOURCE_USER:
|
||||
return self.async_create_entry(title=self._username, data=data)
|
||||
|
||||
entry = await self.async_set_unique_id(self.unique_id)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@dgomes"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/kmtronic",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pykmtronic"],
|
||||
"requirements": ["pykmtronic==0.3.0"]
|
||||
|
||||
@@ -163,6 +163,7 @@ SUPPORTED_PLATFORMS_UI: Final = {
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.DATE,
|
||||
Platform.FAN,
|
||||
Platform.DATETIME,
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
@@ -217,3 +218,9 @@ class ClimateConf:
|
||||
FAN_MAX_STEP: Final = "fan_max_step"
|
||||
FAN_SPEED_MODE: Final = "fan_speed_mode"
|
||||
FAN_ZERO_MODE: Final = "fan_zero_mode"
|
||||
|
||||
|
||||
class FanConf:
|
||||
"""Common config keys for fan."""
|
||||
|
||||
MAX_STEP: Final = "max_step"
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
from propcache.api import cached_property
|
||||
from xknx.devices import Fan as XknxFan
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.percentage import (
|
||||
percentage_to_ranged_value,
|
||||
@@ -19,12 +23,19 @@ from homeassistant.util.percentage import (
|
||||
)
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from .const import KNX_ADDRESS, KNX_MODULE_KEY
|
||||
from .entity import KnxYamlEntity
|
||||
from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, FanConf
|
||||
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .knx_module import KNXModule
|
||||
from .schema import FanSchema
|
||||
|
||||
DEFAULT_PERCENTAGE: Final = 50
|
||||
from .storage.const import (
|
||||
CONF_ENTITY,
|
||||
CONF_GA_OSCILLATION,
|
||||
CONF_GA_SPEED,
|
||||
CONF_GA_STEP,
|
||||
CONF_GA_SWITCH,
|
||||
CONF_SPEED,
|
||||
)
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -34,61 +45,55 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up fan(s) for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
config: list[ConfigType] = knx_module.config_yaml[Platform.FAN]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.FAN,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiFan,
|
||||
),
|
||||
)
|
||||
|
||||
async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config)
|
||||
entities: list[_KnxFan] = []
|
||||
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(KnxYamlEntity, FanEntity):
|
||||
class _KnxFan(FanEntity):
|
||||
"""Representation of a KNX fan."""
|
||||
|
||||
_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)
|
||||
def _get_knx_speed(self, percentage: int) -> int:
|
||||
"""Convert percentage to KNX speed value."""
|
||||
if self._step_range is not None:
|
||||
return math.ceil(percentage_to_ranged_value(self._step_range, percentage))
|
||||
return percentage
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
if self._step_range:
|
||||
step = math.ceil(percentage_to_ranged_value(self._step_range, percentage))
|
||||
await self._device.set_speed(step)
|
||||
else:
|
||||
await self._device.set_speed(percentage)
|
||||
await self._device.set_speed(self._get_knx_speed(percentage))
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def supported_features(self) -> FanEntityFeature:
|
||||
"""Flag supported features."""
|
||||
flags = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
flags = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
|
||||
if self._device.speed.initialized:
|
||||
flags |= FanEntityFeature.SET_SPEED
|
||||
if self._device.supports_oscillation:
|
||||
flags |= FanEntityFeature.OSCILLATE
|
||||
|
||||
return flags
|
||||
|
||||
@property
|
||||
@@ -103,13 +108,18 @@ class KNXFan(KnxYamlEntity, FanEntity):
|
||||
)
|
||||
return self._device.current_speed
|
||||
|
||||
@property
|
||||
@cached_property
|
||||
def speed_count(self) -> int:
|
||||
"""Return the number of speeds the fan supports."""
|
||||
if self._step_range is None:
|
||||
return super().speed_count
|
||||
return int_states_in_range(self._step_range)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the current fan state of the device."""
|
||||
return self._device.is_on
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
@@ -117,14 +127,12 @@ class KNXFan(KnxYamlEntity, FanEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on the fan."""
|
||||
if percentage is None:
|
||||
await self.async_set_percentage(DEFAULT_PERCENTAGE)
|
||||
else:
|
||||
await self.async_set_percentage(percentage)
|
||||
speed = self._get_knx_speed(percentage) if percentage is not None else None
|
||||
await self._device.turn_on(speed)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the fan off."""
|
||||
await self.async_set_percentage(0)
|
||||
await self._device.turn_off()
|
||||
|
||||
async def async_oscillate(self, oscillating: bool) -> None:
|
||||
"""Oscillate the fan."""
|
||||
@@ -134,3 +142,83 @@ class KNXFan(KnxYamlEntity, FanEntity):
|
||||
def oscillating(self) -> bool | None:
|
||||
"""Return whether or not the fan is currently oscillating."""
|
||||
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
|
||||
),
|
||||
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
|
||||
group_address_switch_state=config.get(
|
||||
FanSchema.CONF_SWITCH_STATE_ADDRESS
|
||||
),
|
||||
max_step=max_step,
|
||||
sync_state=config.get(CONF_SYNC_STATE, True),
|
||||
),
|
||||
)
|
||||
# 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
|
||||
),
|
||||
group_address_switch=knx_conf.get_write(CONF_GA_SWITCH),
|
||||
group_address_switch_state=knx_conf.get_state_and_passive(CONF_GA_SWITCH),
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"loggers": ["xknx", "xknxproject"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"xknx==3.12.0",
|
||||
"xknx==3.13.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.10.31.195356"
|
||||
],
|
||||
|
||||
@@ -59,6 +59,7 @@ from .const import (
|
||||
ClimateConf,
|
||||
ColorTempModes,
|
||||
CoverConf,
|
||||
FanConf,
|
||||
FanZeroMode,
|
||||
)
|
||||
from .validation import (
|
||||
@@ -575,20 +576,40 @@ class FanSchema(KNXPlatformSchema):
|
||||
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
|
||||
CONF_OSCILLATION_ADDRESS = "oscillation_address"
|
||||
CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address"
|
||||
CONF_MAX_STEP = "max_step"
|
||||
CONF_SWITCH_ADDRESS = "switch_address"
|
||||
CONF_SWITCH_STATE_ADDRESS = "switch_state_address"
|
||||
|
||||
DEFAULT_NAME = "KNX Fan"
|
||||
|
||||
ENTITY_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Required(KNX_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_MAX_STEP): cv.byte,
|
||||
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
|
||||
}
|
||||
ENTITY_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(KNX_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_SWITCH_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_SWITCH_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
|
||||
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
|
||||
vol.Optional(FanConf.MAX_STEP): cv.byte,
|
||||
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
|
||||
}
|
||||
),
|
||||
vol.Any(
|
||||
vol.Schema(
|
||||
{vol.Required(KNX_ADDRESS): object},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
vol.Schema(
|
||||
{vol.Required(CONF_SWITCH_ADDRESS): object},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
msg=(
|
||||
f"At least one of '{KNX_ADDRESS}' or"
|
||||
f" '{CONF_SWITCH_ADDRESS}' is required."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ CONF_GA_DATE: Final = "ga_date"
|
||||
CONF_GA_DATETIME: Final = "ga_datetime"
|
||||
CONF_GA_TIME: Final = "ga_time"
|
||||
|
||||
CONF_GA_STEP: Final = "ga_step"
|
||||
|
||||
# Climate
|
||||
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
|
||||
CONF_GA_HUMIDITY_CURRENT: Final = "ga_humidity_current"
|
||||
@@ -42,11 +44,15 @@ CONF_GA_FAN_SWING_HORIZONTAL: Final = "ga_fan_swing_horizontal"
|
||||
# Cover
|
||||
CONF_GA_UP_DOWN: Final = "ga_up_down"
|
||||
CONF_GA_STOP: Final = "ga_stop"
|
||||
CONF_GA_STEP: Final = "ga_step"
|
||||
CONF_GA_POSITION_SET: Final = "ga_position_set"
|
||||
CONF_GA_POSITION_STATE: Final = "ga_position_state"
|
||||
CONF_GA_ANGLE: Final = "ga_angle"
|
||||
|
||||
# Fan
|
||||
CONF_SPEED: Final = "speed"
|
||||
CONF_GA_SPEED: Final = "ga_speed"
|
||||
CONF_GA_OSCILLATION: Final = "ga_oscillation"
|
||||
|
||||
# Light
|
||||
CONF_COLOR_TEMP_MIN: Final = "color_temp_min"
|
||||
CONF_COLOR_TEMP_MAX: Final = "color_temp_max"
|
||||
|
||||
@@ -28,6 +28,7 @@ from ..const import (
|
||||
ClimateConf,
|
||||
ColorTempModes,
|
||||
CoverConf,
|
||||
FanConf,
|
||||
FanZeroMode,
|
||||
)
|
||||
from .const import (
|
||||
@@ -62,6 +63,7 @@ from .const import (
|
||||
CONF_GA_OP_MODE_PROTECTION,
|
||||
CONF_GA_OP_MODE_STANDBY,
|
||||
CONF_GA_OPERATION_MODE,
|
||||
CONF_GA_OSCILLATION,
|
||||
CONF_GA_POSITION_SET,
|
||||
CONF_GA_POSITION_STATE,
|
||||
CONF_GA_RED_BRIGHTNESS,
|
||||
@@ -69,6 +71,7 @@ from .const import (
|
||||
CONF_GA_SATURATION,
|
||||
CONF_GA_SENSOR,
|
||||
CONF_GA_SETPOINT_SHIFT,
|
||||
CONF_GA_SPEED,
|
||||
CONF_GA_STEP,
|
||||
CONF_GA_STOP,
|
||||
CONF_GA_SWITCH,
|
||||
@@ -80,6 +83,7 @@ from .const import (
|
||||
CONF_GA_WHITE_BRIGHTNESS,
|
||||
CONF_GA_WHITE_SWITCH,
|
||||
CONF_IGNORE_AUTO_MODE,
|
||||
CONF_SPEED,
|
||||
CONF_TARGET_TEMPERATURE,
|
||||
)
|
||||
from .knx_selector import (
|
||||
@@ -220,6 +224,60 @@ DATETIME_KNX_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
FAN_KNX_SCHEMA = AllSerializeFirst(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_GA_SWITCH): GASelector(
|
||||
write_required=True, valid_dpt="1"
|
||||
),
|
||||
vol.Optional(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(),
|
||||
}
|
||||
),
|
||||
vol.Any(
|
||||
vol.Schema(
|
||||
{vol.Required(CONF_GA_SWITCH): object},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
vol.Schema(
|
||||
{vol.Required(CONF_SPEED): object},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
),
|
||||
msg=("At least one of 'Switch' or 'Fan speed' is required."),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@unique
|
||||
class LightColorMode(StrEnum):
|
||||
@@ -513,6 +571,7 @@ KNX_SCHEMA_FOR_PLATFORM = {
|
||||
Platform.COVER: COVER_KNX_SCHEMA,
|
||||
Platform.DATE: DATE_KNX_SCHEMA,
|
||||
Platform.DATETIME: DATETIME_KNX_SCHEMA,
|
||||
Platform.FAN: FAN_KNX_SCHEMA,
|
||||
Platform.LIGHT: LIGHT_KNX_SCHEMA,
|
||||
Platform.SWITCH: SWITCH_KNX_SCHEMA,
|
||||
Platform.TIME: TIME_KNX_SCHEMA,
|
||||
|
||||
@@ -460,6 +460,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"ga_switch": {
|
||||
"description": "Group address to turn the fan on/off.",
|
||||
"label": "Switch"
|
||||
},
|
||||
"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",
|
||||
"light": {
|
||||
"description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"codeowners": ["@OnFreund"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/kodi",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["jsonrpc_async", "jsonrpc_base", "jsonrpc_websocket", "pykodi"],
|
||||
"requirements": ["pykodi==0.2.7"],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@stegm"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/kostal_plenticore",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["kostal"],
|
||||
"requirements": ["pykoplenti==1.3.0"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@eifinger"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/kraken",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["krakenex", "pykrakenapi"],
|
||||
"requirements": ["krakenex==2.2.2", "pykrakenapi==0.1.8"]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/kulersky",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bleak", "pykulersky"],
|
||||
"requirements": ["pykulersky==0.5.8"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@IceBotYT"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lacrosse_view",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["lacrosse_view"],
|
||||
"requirements": ["lacrosse-view==1.1.1"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ultraheat-api==0.5.7"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@joostlek"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lastfm",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pylast"],
|
||||
"requirements": ["pylast==5.1.0"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@xLarry"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/laundrify",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["laundrify-aio==1.2.2"]
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ from .const import CONF_DOMAIN_DATA
|
||||
from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
|
||||
@@ -36,7 +36,7 @@ from .const import (
|
||||
from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from .const import (
|
||||
from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
BRIGHTNESS_SCALE = (1, 100)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/lcn",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pypck==0.9.7", "lcn-frontend==0.2.7"]
|
||||
"requirements": ["pypck==0.9.8", "lcn-frontend==0.2.7"]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ from .const import (
|
||||
from .entity import LcnEntity
|
||||
from .helpers import LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 2
|
||||
|
||||
|
||||
def add_lcn_entities(
|
||||
|
||||
@@ -40,7 +40,7 @@ from .const import (
|
||||
from .entity import LcnEntity
|
||||
from .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 2
|
||||
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 .helpers import InputType, LcnConfigEntry
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
PARALLEL_UPDATES = 2
|
||||
SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
def add_lcn_switch_entities(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/leaone",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["leaone-ble==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/led_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.7"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lg_soundbar",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["temescal"],
|
||||
"requirements": ["temescal==0.5"]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -241,6 +242,7 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
# If device is off, turn on first.
|
||||
if not self.data.is_on:
|
||||
await self.async_turn_on()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_hvac_mode: %s",
|
||||
@@ -324,10 +326,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
# If device is off, turn on first.
|
||||
if not self.data.is_on:
|
||||
await self.async_turn_on()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if hvac_mode and hvac_mode != self.hvac_mode:
|
||||
await self.async_set_hvac_mode(HVACMode(hvac_mode))
|
||||
|
||||
await asyncio.sleep(2)
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] async_set_temperature: %s",
|
||||
self.coordinator.device_name,
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
"name": "LG ThinQ",
|
||||
"codeowners": ["@LG-ThinQ-Integration"],
|
||||
"config_flow": true,
|
||||
"dhcp": [{ "macaddress": "34E6E6*" }],
|
||||
"dhcp": [
|
||||
{
|
||||
"macaddress": "34E6E6*"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/lg_thinq",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["thinqconnect"],
|
||||
"requirements": ["thinqconnect==1.0.9"]
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"LIFX Z"
|
||||
]
|
||||
},
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiolifx", "aiolifx_effects", "bitstring"],
|
||||
"requirements": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@StefanIacobLivisi", "@planbnet"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/livisi",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["livisi==0.0.25"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/loqed",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["loqedAPI==2.1.10"],
|
||||
"zeroconf": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@majuss", "@suaveolent"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lupusec",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["lupupy"],
|
||||
"requirements": ["lupupy==0.3.2"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@cdheiser", "@wilburCForce"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/lutron",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pylutron"],
|
||||
"requirements": ["pylutron==0.2.18"],
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/lyric",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiolyric"],
|
||||
"requirements": ["aiolyric==2.0.2"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/mailgun",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pymailgunner"],
|
||||
"requirements": ["pymailgunner==1.4"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@Sotolotl", "@emontnemery"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/meater",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["meater-python==0.0.8"]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/medcom_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["medcom-ble==0.1.1"]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/melnor",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["melnor-bluetooth==0.0.25"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@DylanGore"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/met_eireann",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["meteireann"],
|
||||
"requirements": ["PyMetEireann==2024.11.0"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "meteo_france",
|
||||
"name": "Météo-France",
|
||||
"name": "M\u00e9t\u00e9o-France",
|
||||
"codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/meteo_france",
|
||||
|
||||
@@ -27,7 +27,11 @@ from music_assistant_models.player import Player
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
@@ -101,6 +105,15 @@ async def async_setup_entry( # noqa: C901
|
||||
)
|
||||
raise ConfigEntryNotReady(f"Invalid server version: {err}") from err
|
||||
except (AuthenticationRequired, AuthenticationFailed, InvalidToken) as err:
|
||||
assert mass.server_info is not None
|
||||
# Users cannot reauthenticate when running as Home Assistant addon,
|
||||
# so raising ConfigEntryAuthFailed in that case would be incorrect.
|
||||
# Instead we should wait until the addon discovery is completed,
|
||||
# as that will set up authentication and reload the entry automatically.
|
||||
if mass.server_info.homeassistant_addon:
|
||||
raise ConfigEntryError(
|
||||
"Authentication failed, addon discovery not completed yet"
|
||||
) from err
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for {mass_url}: {err}"
|
||||
) from err
|
||||
|
||||
@@ -179,6 +179,7 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
ConfigEntryState.LOADED,
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
ConfigEntryState.SETUP_IN_PROGRESS,
|
||||
):
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ UNSUPPORTED_EXTENDED_CACHE_RETENTION_MODELS: list[str] = [
|
||||
"o4",
|
||||
"gpt-3.5",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4o",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-nano",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "overseerr",
|
||||
"name": "Overseerr",
|
||||
"after_dependencies": ["cloud"],
|
||||
"codeowners": ["@joostlek"],
|
||||
"codeowners": ["@joostlek", "@AmGarera"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/overseerr",
|
||||
|
||||
@@ -7,6 +7,7 @@ from plugwise import GwEntityData, Smile
|
||||
from plugwise.exceptions import (
|
||||
ConnectionFailedError,
|
||||
InvalidAuthentication,
|
||||
InvalidSetupError,
|
||||
InvalidXMLError,
|
||||
PlugwiseError,
|
||||
ResponseError,
|
||||
@@ -31,6 +32,9 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
"""Class to manage fetching Plugwise data from single endpoint."""
|
||||
|
||||
_connected: bool = False
|
||||
_current_devices: set[str]
|
||||
_stored_devices: set[str]
|
||||
new_devices: set[str]
|
||||
|
||||
config_entry: PlugwiseConfigEntry
|
||||
|
||||
@@ -59,14 +63,31 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
port=self.config_entry.data.get(CONF_PORT, DEFAULT_PORT),
|
||||
websession=async_get_clientsession(hass, verify_ssl=False),
|
||||
)
|
||||
self._current_devices: set[str] = set()
|
||||
self.new_devices: set[str] = set()
|
||||
self._current_devices = set()
|
||||
self._stored_devices = set()
|
||||
self.new_devices = set()
|
||||
|
||||
async def _connect(self) -> None:
|
||||
"""Connect to the Plugwise Smile."""
|
||||
"""Connect to the Plugwise Smile.
|
||||
|
||||
A Version object is received when the connection succeeds.
|
||||
"""
|
||||
version = await self.api.connect()
|
||||
self._connected = isinstance(version, Version)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Initialize the update_data process."""
|
||||
device_reg = dr.async_get(self.hass)
|
||||
device_entries = dr.async_entries_for_config_entry(
|
||||
device_reg, self.config_entry.entry_id
|
||||
)
|
||||
self._stored_devices = {
|
||||
identifier[1]
|
||||
for device_entry in device_entries
|
||||
for identifier in device_entry.identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
}
|
||||
|
||||
async def _async_update_data(self) -> dict[str, GwEntityData]:
|
||||
"""Fetch data from Plugwise."""
|
||||
try:
|
||||
@@ -83,10 +104,15 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from err
|
||||
except InvalidSetupError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_setup",
|
||||
) from err
|
||||
except (InvalidXMLError, ResponseError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_xml_data",
|
||||
translation_key="response_error",
|
||||
) from err
|
||||
except PlugwiseError as err:
|
||||
raise UpdateFailed(
|
||||
@@ -104,12 +130,16 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
|
||||
def _async_add_remove_devices(self, data: dict[str, GwEntityData]) -> None:
|
||||
"""Add new Plugwise devices, remove non-existing devices."""
|
||||
# Check for new or removed devices
|
||||
self.new_devices = set(data) - self._current_devices
|
||||
removed_devices = self._current_devices - set(data)
|
||||
self._current_devices = set(data)
|
||||
|
||||
if removed_devices:
|
||||
set_of_data = set(data)
|
||||
# Check for new or removed devices,
|
||||
# 'new_devices' contains all devices present in 'data' at init ('self._current_devices' is empty)
|
||||
# this is required for the proper initialization of all the present platform entities.
|
||||
self.new_devices = set_of_data - self._current_devices
|
||||
current_devices = (
|
||||
self._stored_devices if not self._current_devices else self._current_devices
|
||||
)
|
||||
self._current_devices = set_of_data
|
||||
if current_devices - set_of_data: # device(s) to remove
|
||||
self._async_remove_devices(data)
|
||||
|
||||
def _async_remove_devices(self, data: dict[str, GwEntityData]) -> None:
|
||||
@@ -118,26 +148,26 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[dict[str, GwEntityData
|
||||
device_list = dr.async_entries_for_config_entry(
|
||||
device_reg, self.config_entry.entry_id
|
||||
)
|
||||
|
||||
# First find the Plugwise via_device
|
||||
gateway_device = device_reg.async_get_device({(DOMAIN, self.api.gateway_id)})
|
||||
assert gateway_device is not None
|
||||
via_device_id = gateway_device.id
|
||||
|
||||
# Then remove the connected orphaned device(s)
|
||||
for device_entry in device_list:
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier[0] == DOMAIN:
|
||||
if (
|
||||
device_entry.via_device_id == via_device_id
|
||||
and identifier[1] not in data
|
||||
):
|
||||
device_reg.async_update_device(
|
||||
device_entry.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Removed %s device %s %s from device_registry",
|
||||
DOMAIN,
|
||||
device_entry.model,
|
||||
identifier[1],
|
||||
)
|
||||
if (
|
||||
identifier[0] == DOMAIN
|
||||
and device_entry.via_device_id == via_device_id
|
||||
and identifier[1] not in data
|
||||
):
|
||||
device_reg.async_update_device(
|
||||
device_entry.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Removed %s device/zone %s %s from device_registry",
|
||||
DOMAIN,
|
||||
device_entry.model,
|
||||
identifier[1],
|
||||
)
|
||||
|
||||
@@ -319,7 +319,10 @@
|
||||
"failed_to_connect": {
|
||||
"message": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"invalid_xml_data": {
|
||||
"invalid_setup": {
|
||||
"message": "Add your Adam instead of your Anna, see the documentation"
|
||||
},
|
||||
"response_error": {
|
||||
"message": "[%key:component::plugwise::config::error::response_error%]"
|
||||
},
|
||||
"set_schedule_first": {
|
||||
|
||||
@@ -37,10 +37,12 @@ from .const import (
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import (
|
||||
RoborockB01Q7UpdateCoordinator,
|
||||
RoborockConfigEntry,
|
||||
RoborockCoordinators,
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
RoborockDataUpdateCoordinatorB01,
|
||||
RoborockWashingMachineUpdateCoordinator,
|
||||
RoborockWetDryVacUpdateCoordinator,
|
||||
)
|
||||
@@ -131,13 +133,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
for coord in coordinators
|
||||
if isinstance(coord, RoborockDataUpdateCoordinatorA01)
|
||||
]
|
||||
if len(v1_coords) + len(a01_coords) == 0:
|
||||
b01_coords = [
|
||||
coord
|
||||
for coord in coordinators
|
||||
if isinstance(coord, RoborockDataUpdateCoordinatorB01)
|
||||
]
|
||||
if len(v1_coords) + len(a01_coords) + len(b01_coords) == 0:
|
||||
raise ConfigEntryNotReady(
|
||||
"No devices were able to successfully setup",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_coordinators",
|
||||
)
|
||||
entry.runtime_data = RoborockCoordinators(v1_coords, a01_coords)
|
||||
entry.runtime_data = RoborockCoordinators(v1_coords, a01_coords, b01_coords)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -208,12 +215,17 @@ def build_setup_functions(
|
||||
Coroutine[
|
||||
Any,
|
||||
Any,
|
||||
RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None,
|
||||
RoborockDataUpdateCoordinator
|
||||
| RoborockDataUpdateCoordinatorA01
|
||||
| RoborockDataUpdateCoordinatorB01
|
||||
| None,
|
||||
]
|
||||
]:
|
||||
"""Create a list of setup functions that can later be called asynchronously."""
|
||||
coordinators: list[
|
||||
RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01
|
||||
RoborockDataUpdateCoordinator
|
||||
| RoborockDataUpdateCoordinatorA01
|
||||
| RoborockDataUpdateCoordinatorB01
|
||||
] = []
|
||||
for device in devices:
|
||||
_LOGGER.debug("Creating device %s: %s", device.name, device)
|
||||
@@ -229,6 +241,12 @@ def build_setup_functions(
|
||||
coordinators.append(
|
||||
RoborockWashingMachineUpdateCoordinator(hass, entry, device, device.zeo)
|
||||
)
|
||||
elif device.b01_q7_properties is not None:
|
||||
coordinators.append(
|
||||
RoborockB01Q7UpdateCoordinator(
|
||||
hass, entry, device, device.b01_q7_properties
|
||||
)
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Not adding device %s because its protocol version %s or category %s is not supported",
|
||||
@@ -241,8 +259,15 @@ def build_setup_functions(
|
||||
|
||||
|
||||
async def setup_coordinator(
|
||||
coordinator: RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01,
|
||||
) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None:
|
||||
coordinator: RoborockDataUpdateCoordinator
|
||||
| RoborockDataUpdateCoordinatorA01
|
||||
| RoborockDataUpdateCoordinatorB01,
|
||||
) -> (
|
||||
RoborockDataUpdateCoordinator
|
||||
| RoborockDataUpdateCoordinatorA01
|
||||
| RoborockDataUpdateCoordinatorB01
|
||||
| None
|
||||
):
|
||||
"""Set up a single coordinator."""
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -8,12 +8,18 @@ import logging
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from propcache.api import cached_property
|
||||
from roborock import B01Props
|
||||
from roborock.data import HomeDataScene
|
||||
from roborock.devices.device import RoborockDevice
|
||||
from roborock.devices.traits.a01 import DyadApi, ZeoApi
|
||||
from roborock.devices.traits.b01 import Q7PropertiesApi
|
||||
from roborock.devices.traits.v1 import PropertiesApi
|
||||
from roborock.exceptions import RoborockDeviceBusy, RoborockException
|
||||
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
|
||||
from roborock.roborock_message import (
|
||||
RoborockB01Props,
|
||||
RoborockDyadDataProtocol,
|
||||
RoborockZeoProtocol,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_CONNECTIONS
|
||||
@@ -58,12 +64,17 @@ class RoborockCoordinators:
|
||||
|
||||
v1: list[RoborockDataUpdateCoordinator]
|
||||
a01: list[RoborockDataUpdateCoordinatorA01]
|
||||
b01: list[RoborockDataUpdateCoordinatorB01]
|
||||
|
||||
def values(
|
||||
self,
|
||||
) -> list[RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01]:
|
||||
) -> list[
|
||||
RoborockDataUpdateCoordinator
|
||||
| RoborockDataUpdateCoordinatorA01
|
||||
| RoborockDataUpdateCoordinatorB01
|
||||
]:
|
||||
"""Return all coordinators."""
|
||||
return self.v1 + self.a01
|
||||
return self.v1 + self.a01 + self.b01
|
||||
|
||||
|
||||
type RoborockConfigEntry = ConfigEntry[RoborockCoordinators]
|
||||
@@ -469,3 +480,91 @@ class RoborockWetDryVacUpdateCoordinator(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_data_fail",
|
||||
) from ex
|
||||
|
||||
|
||||
class RoborockDataUpdateCoordinatorB01(DataUpdateCoordinator[B01Props]):
|
||||
"""Class to manage fetching data from the API for B01 devices."""
|
||||
|
||||
config_entry: RoborockConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: RoborockConfigEntry,
|
||||
device: RoborockDevice,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=A01_UPDATE_INTERVAL,
|
||||
)
|
||||
self._device = device
|
||||
self.device_info = DeviceInfo(
|
||||
name=device.name,
|
||||
identifiers={(DOMAIN, device.duid)},
|
||||
manufacturer="Roborock",
|
||||
model=device.product.model,
|
||||
sw_version=device.device_info.fv,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def duid(self) -> str:
|
||||
"""Get the unique id of the device as specified by Roborock."""
|
||||
return self._device.duid
|
||||
|
||||
@cached_property
|
||||
def duid_slug(self) -> str:
|
||||
"""Get the slug of the duid."""
|
||||
return slugify(self.duid)
|
||||
|
||||
@property
|
||||
def device(self) -> RoborockDevice:
|
||||
"""Get the RoborockDevice."""
|
||||
return self._device
|
||||
|
||||
|
||||
class RoborockB01Q7UpdateCoordinator(RoborockDataUpdateCoordinatorB01):
|
||||
"""Coordinator for B01 Q7 devices."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: RoborockConfigEntry,
|
||||
device: RoborockDevice,
|
||||
api: Q7PropertiesApi,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(hass, config_entry, device)
|
||||
self.api = api
|
||||
self.request_protocols: list[RoborockB01Props] = [
|
||||
RoborockB01Props.STATUS,
|
||||
RoborockB01Props.MAIN_BRUSH,
|
||||
RoborockB01Props.SIDE_BRUSH,
|
||||
RoborockB01Props.DUST_BAG_USED,
|
||||
RoborockB01Props.MOP_LIFE,
|
||||
RoborockB01Props.MAIN_SENSOR,
|
||||
RoborockB01Props.CLEANING_TIME,
|
||||
RoborockB01Props.REAL_CLEAN_TIME,
|
||||
RoborockB01Props.HYPA,
|
||||
]
|
||||
|
||||
async def _async_update_data(
|
||||
self,
|
||||
) -> B01Props:
|
||||
try:
|
||||
data = await self.api.query_values(self.request_protocols)
|
||||
except RoborockException as ex:
|
||||
_LOGGER.debug("Failed to update Q7 data: %s", ex)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_data_fail",
|
||||
) from ex
|
||||
if data is None:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_data_fail",
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -13,7 +13,11 @@ from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01
|
||||
from .coordinator import (
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
RoborockDataUpdateCoordinatorB01,
|
||||
)
|
||||
|
||||
|
||||
class RoborockEntity(Entity):
|
||||
@@ -124,3 +128,23 @@ class RoborockCoordinatedEntityA01(
|
||||
)
|
||||
CoordinatorEntity.__init__(self, coordinator=coordinator)
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
|
||||
class RoborockCoordinatedEntityB01(
|
||||
RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinatorB01]
|
||||
):
|
||||
"""Representation of coordinated Roborock Entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
coordinator: RoborockDataUpdateCoordinatorB01,
|
||||
) -> None:
|
||||
"""Initialize the coordinated Roborock Device."""
|
||||
RoborockEntity.__init__(
|
||||
self,
|
||||
unique_id=unique_id,
|
||||
device_info=coordinator.device_info,
|
||||
)
|
||||
CoordinatorEntity.__init__(self, coordinator=coordinator)
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
@@ -8,12 +8,14 @@ import datetime
|
||||
import logging
|
||||
|
||||
from roborock.data import (
|
||||
B01Props,
|
||||
DyadError,
|
||||
RoborockDockErrorCode,
|
||||
RoborockDockTypeCode,
|
||||
RoborockDyadStateCode,
|
||||
RoborockErrorCode,
|
||||
RoborockStateCode,
|
||||
WorkStatusMapping,
|
||||
ZeoError,
|
||||
ZeoState,
|
||||
)
|
||||
@@ -34,9 +36,11 @@ from .coordinator import (
|
||||
RoborockConfigEntry,
|
||||
RoborockDataUpdateCoordinator,
|
||||
RoborockDataUpdateCoordinatorA01,
|
||||
RoborockDataUpdateCoordinatorB01,
|
||||
)
|
||||
from .entity import (
|
||||
RoborockCoordinatedEntityA01,
|
||||
RoborockCoordinatedEntityB01,
|
||||
RoborockCoordinatedEntityV1,
|
||||
RoborockEntity,
|
||||
)
|
||||
@@ -64,6 +68,13 @@ class RoborockSensorDescriptionA01(SensorEntityDescription):
|
||||
data_protocol: RoborockDyadDataProtocol | RoborockZeoProtocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RoborockSensorDescriptionB01(SensorEntityDescription):
|
||||
"""A class that describes Roborock B01 sensors."""
|
||||
|
||||
value_fn: Callable[[B01Props], StateType]
|
||||
|
||||
|
||||
def _dock_error_value_fn(state: DeviceState) -> str | None:
|
||||
if (
|
||||
status := state.status.dock_error_status
|
||||
@@ -326,6 +337,71 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
|
||||
),
|
||||
]
|
||||
|
||||
Q7_B01_SENSOR_DESCRIPTIONS = [
|
||||
RoborockSensorDescriptionB01(
|
||||
key="q7_status",
|
||||
value_fn=lambda data: data.status_name,
|
||||
translation_key="q7_status",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=WorkStatusMapping.keys(),
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="main_brush_time_left",
|
||||
value_fn=lambda data: data.main_brush_time_left,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="main_brush_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="side_brush_time_left",
|
||||
value_fn=lambda data: data.side_brush_time_left,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="side_brush_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="filter_time_left",
|
||||
value_fn=lambda data: data.filter_time_left,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="filter_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="sensor_time_left",
|
||||
value_fn=lambda data: data.sensor_dirty_time_left,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="sensor_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="mop_life_time_left",
|
||||
value_fn=lambda data: data.mop_life_time_left,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="mop_life_time_left",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
RoborockSensorDescriptionB01(
|
||||
key="total_cleaning_time",
|
||||
value_fn=lambda data: data.real_clean_time,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
translation_key="total_cleaning_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -354,6 +430,12 @@ async def async_setup_entry(
|
||||
for description in A01_SENSOR_DESCRIPTIONS
|
||||
if description.data_protocol in coordinator.request_protocols
|
||||
)
|
||||
entities.extend(
|
||||
RoborockSensorEntityB01(coordinator, description)
|
||||
for coordinator in coordinators.b01
|
||||
for description in Q7_B01_SENSOR_DESCRIPTIONS
|
||||
if description.value_fn(coordinator.data) is not None
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -440,3 +522,23 @@ class RoborockSensorEntityA01(RoborockCoordinatedEntityA01, SensorEntity):
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
return self.coordinator.data[self.entity_description.data_protocol]
|
||||
|
||||
|
||||
class RoborockSensorEntityB01(RoborockCoordinatedEntityB01, SensorEntity):
|
||||
"""Representation of a B01 Roborock sensor."""
|
||||
|
||||
entity_description: RoborockSensorDescriptionB01
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RoborockDataUpdateCoordinatorB01,
|
||||
description: RoborockSensorDescriptionB01,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entity_description = description
|
||||
super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
@@ -213,6 +213,25 @@
|
||||
"mop_drying_remaining_time": {
|
||||
"name": "Mop drying remaining time"
|
||||
},
|
||||
"mop_life_time_left": {
|
||||
"name": "Mop life time left"
|
||||
},
|
||||
"q7_status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"docking": "[%key:component::roborock::entity::sensor::status::state::docking%]",
|
||||
"mop_airdrying": "Mop air drying",
|
||||
"mop_cleaning": "Mop cleaning",
|
||||
"moping": "Mopping",
|
||||
"paused": "[%key:common::state::paused%]",
|
||||
"sleeping": "Sleeping",
|
||||
"sweep_moping": "Sweep mopping",
|
||||
"sweep_moping_2": "Sweep mopping",
|
||||
"updating": "[%key:component::roborock::entity::sensor::status::state::updating%]",
|
||||
"waiting_for_orders": "Waiting for orders"
|
||||
}
|
||||
},
|
||||
"sensor_time_left": {
|
||||
"name": "Sensor time left"
|
||||
},
|
||||
|
||||
@@ -537,6 +537,12 @@
|
||||
"voltmeter_value": {
|
||||
"name": "Voltmeter value"
|
||||
},
|
||||
"voltmeter_value_with_channel_name": {
|
||||
"name": "Voltmeter value {channel_name}"
|
||||
},
|
||||
"voltmeter_with_channel_name": {
|
||||
"name": "Voltmeter {channel_name}"
|
||||
},
|
||||
"water_consumption": {
|
||||
"name": "Water consumption"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from PySrDaliGateway import DaliGateway
|
||||
@@ -23,7 +24,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER
|
||||
from .types import DaliCenterConfigEntry, DaliCenterData
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
_PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SCENE]
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -48,7 +49,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -
|
||||
) from exc
|
||||
|
||||
try:
|
||||
devices = await gateway.discover_devices()
|
||||
devices, scenes = await asyncio.gather(
|
||||
gateway.discover_devices(),
|
||||
gateway.discover_scenes(),
|
||||
)
|
||||
except DaliGatewayError as exc:
|
||||
raise ConfigEntryNotReady(
|
||||
"Unable to discover devices from the gateway"
|
||||
@@ -70,6 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -
|
||||
entry.runtime_data = DaliCenterData(
|
||||
gateway=gateway,
|
||||
devices=devices,
|
||||
scenes=scenes,
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
|
||||
57
homeassistant/components/sunricher_dali/entity.py
Normal file
57
homeassistant/components/sunricher_dali/entity.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Base entity for Sunricher DALI integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from PySrDaliGateway import CallbackEventType, DaliObjectBase, Device
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DaliCenterEntity(Entity):
|
||||
"""Base entity for DALI Center objects (devices, scenes, etc.)."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, dali_object: DaliObjectBase) -> None:
|
||||
"""Initialize base entity."""
|
||||
self._dali_object = dali_object
|
||||
self._attr_unique_id = dali_object.unique_id
|
||||
self._unavailable_logged = False
|
||||
self._attr_available = True
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register availability listener."""
|
||||
self.async_on_remove(
|
||||
self._dali_object.register_listener(
|
||||
CallbackEventType.ONLINE_STATUS,
|
||||
self._handle_availability,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_availability(self, available: bool) -> None:
|
||||
"""Handle availability changes."""
|
||||
if not available and not self._unavailable_logged:
|
||||
_LOGGER.info("Entity %s became unavailable", self.entity_id)
|
||||
self._unavailable_logged = True
|
||||
elif available and self._unavailable_logged:
|
||||
_LOGGER.info("Entity %s is back online", self.entity_id)
|
||||
self._unavailable_logged = False
|
||||
|
||||
self._attr_available = available
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
|
||||
class DaliDeviceEntity(DaliCenterEntity):
|
||||
"""Base entity for DALI Device objects."""
|
||||
|
||||
def __init__(self, device: Device) -> None:
|
||||
"""Initialize device entity."""
|
||||
super().__init__(device)
|
||||
self._attr_available = device.status == "online"
|
||||
@@ -22,6 +22,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .entity import DaliDeviceEntity
|
||||
from .types import DaliCenterConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -45,10 +46,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class DaliCenterLight(LightEntity):
|
||||
class DaliCenterLight(DaliDeviceEntity, LightEntity):
|
||||
"""Representation of a Sunricher DALI Light."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_is_on: bool | None = None
|
||||
_attr_brightness: int | None = None
|
||||
@@ -60,11 +60,8 @@ class DaliCenterLight(LightEntity):
|
||||
|
||||
def __init__(self, light: Device) -> None:
|
||||
"""Initialize the light entity."""
|
||||
|
||||
super().__init__(light)
|
||||
self._light = light
|
||||
self._unavailable_logged = False
|
||||
self._attr_unique_id = light.unique_id
|
||||
self._attr_available = light.status == "online"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, light.dev_id)},
|
||||
name=light.name,
|
||||
@@ -111,6 +108,7 @@ class DaliCenterLight(LightEntity):
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity addition to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self.async_on_remove(
|
||||
self._light.register_listener(
|
||||
@@ -118,27 +116,10 @@ class DaliCenterLight(LightEntity):
|
||||
)
|
||||
)
|
||||
|
||||
self.async_on_remove(
|
||||
self._light.register_listener(
|
||||
CallbackEventType.ONLINE_STATUS, self._handle_availability
|
||||
)
|
||||
)
|
||||
|
||||
# read_status() only queues a request on the gateway and relies on the
|
||||
# current event loop via call_later, so it must run in the loop thread.
|
||||
self._light.read_status()
|
||||
|
||||
@callback
|
||||
def _handle_availability(self, available: bool) -> None:
|
||||
self._attr_available = available
|
||||
if not available and not self._unavailable_logged:
|
||||
_LOGGER.info("Light %s became unavailable", self._attr_unique_id)
|
||||
self._unavailable_logged = True
|
||||
elif available and self._unavailable_logged:
|
||||
_LOGGER.info("Light %s is back online", self._attr_unique_id)
|
||||
self._unavailable_logged = False
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@callback
|
||||
def _handle_device_update(self, status: LightStatus) -> None:
|
||||
if status.get("is_on") is not None:
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["PySrDaliGateway==0.16.2"]
|
||||
"requirements": ["PySrDaliGateway==0.18.0"]
|
||||
}
|
||||
|
||||
45
homeassistant/components/sunricher_dali/scene.py
Normal file
45
homeassistant/components/sunricher_dali/scene.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Support for DALI Center Scene entities."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from PySrDaliGateway import Scene
|
||||
|
||||
from homeassistant.components.scene import Scene as SceneEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import DaliCenterEntity
|
||||
from .types import DaliCenterConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: DaliCenterConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up DALI Center scene entities from config entry."""
|
||||
async_add_entities(DaliCenterScene(scene) for scene in entry.runtime_data.scenes)
|
||||
|
||||
|
||||
class DaliCenterScene(DaliCenterEntity, SceneEntity):
|
||||
"""Representation of a DALI Center Scene."""
|
||||
|
||||
def __init__(self, scene: Scene) -> None:
|
||||
"""Initialize the DALI scene."""
|
||||
super().__init__(scene)
|
||||
self._scene = scene
|
||||
self._attr_name = scene.name
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, scene.gw_sn)},
|
||||
)
|
||||
|
||||
async def async_activate(self, **kwargs: Any) -> None:
|
||||
"""Activate the DALI scene."""
|
||||
await self.hass.async_add_executor_job(self._scene.activate)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PySrDaliGateway import DaliGateway, Device
|
||||
from PySrDaliGateway import DaliGateway, Device, Scene
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
@@ -13,6 +13,7 @@ class DaliCenterData:
|
||||
|
||||
gateway: DaliGateway
|
||||
devices: list[Device]
|
||||
scenes: list[Scene]
|
||||
|
||||
|
||||
type DaliCenterConfigEntry = ConfigEntry[DaliCenterData]
|
||||
|
||||
@@ -68,7 +68,7 @@ class _SwingModeWrapper(DeviceWrapper):
|
||||
on_off: DPCodeBooleanWrapper | None = None
|
||||
horizontal: DPCodeBooleanWrapper | None = None
|
||||
vertical: DPCodeBooleanWrapper | None = None
|
||||
modes: list[str]
|
||||
options: list[str]
|
||||
|
||||
@classmethod
|
||||
def find_dpcode(cls, device: CustomerDevice) -> Self | None:
|
||||
@@ -83,18 +83,18 @@ class _SwingModeWrapper(DeviceWrapper):
|
||||
device, DPCode.SWITCH_VERTICAL, prefer_function=True
|
||||
)
|
||||
if on_off or horizontal or vertical:
|
||||
modes = [SWING_OFF]
|
||||
options = [SWING_OFF]
|
||||
if on_off:
|
||||
modes.append(SWING_ON)
|
||||
options.append(SWING_ON)
|
||||
if horizontal:
|
||||
modes.append(SWING_HORIZONTAL)
|
||||
options.append(SWING_HORIZONTAL)
|
||||
if vertical:
|
||||
modes.append(SWING_VERTICAL)
|
||||
options.append(SWING_VERTICAL)
|
||||
return cls(
|
||||
on_off=on_off,
|
||||
horizontal=horizontal,
|
||||
vertical=vertical,
|
||||
modes=modes,
|
||||
options=options,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -403,7 +403,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
# Determine swing modes
|
||||
if swing_wrapper:
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
|
||||
self._attr_swing_modes = swing_wrapper.modes
|
||||
self._attr_swing_modes = swing_wrapper.options
|
||||
|
||||
if switch_wrapper:
|
||||
self._attr_supported_features |= (
|
||||
|
||||
@@ -31,10 +31,12 @@ from .models import (
|
||||
class _DPCodeEventWrapper(DPCodeTypeInformationWrapper):
|
||||
"""Base class for Tuya event wrappers."""
|
||||
|
||||
@property
|
||||
def event_types(self) -> list[str]:
|
||||
"""Return the event types for the DP code."""
|
||||
return ["triggered"]
|
||||
options: list[str]
|
||||
|
||||
def __init__(self, dpcode: str, type_information: Any) -> None:
|
||||
"""Init _DPCodeEventWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.options = ["triggered"]
|
||||
|
||||
def get_event_type(
|
||||
self, device: CustomerDevice, updated_status_properties: list[str] | None
|
||||
@@ -55,11 +57,6 @@ class _DPCodeEventWrapper(DPCodeTypeInformationWrapper):
|
||||
class _EventEnumWrapper(DPCodeEnumWrapper, _DPCodeEventWrapper):
|
||||
"""Wrapper for event enum DP codes."""
|
||||
|
||||
@property
|
||||
def event_types(self) -> list[str]:
|
||||
"""Return the event types for the enum."""
|
||||
return self.options
|
||||
|
||||
def get_event_type(
|
||||
self, device: CustomerDevice, updated_status_properties: list[str] | None
|
||||
) -> str | None:
|
||||
@@ -232,7 +229,7 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
self._attr_event_types = dpcode_wrapper.event_types
|
||||
self._attr_event_types = dpcode_wrapper.options
|
||||
|
||||
async def _handle_state_update(
|
||||
self,
|
||||
|
||||
@@ -128,9 +128,9 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
self._switch_wrapper = switch_wrapper
|
||||
|
||||
self._attr_fan_speed_list = []
|
||||
self._attr_supported_features = (
|
||||
VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE
|
||||
)
|
||||
self._attr_supported_features = VacuumEntityFeature.SEND_COMMAND
|
||||
if status_wrapper or pause_wrapper:
|
||||
self._attr_supported_features |= VacuumEntityFeature.STATE
|
||||
if pause_wrapper:
|
||||
self._attr_supported_features |= VacuumEntityFeature.PAUSE
|
||||
|
||||
|
||||
@@ -453,6 +453,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = (
|
||||
ProtectBinaryEventEntityDescription(
|
||||
key="smart_audio_cmonx",
|
||||
translation_key="co_alarm_detected",
|
||||
device_class=BinarySensorDeviceClass.CO,
|
||||
ufp_required_field="can_detect_co",
|
||||
ufp_enabled="is_co_detection_on",
|
||||
ufp_event_obj="last_cmonx_detect_event",
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from uiprotect.data import ModelType, ProtectAdoptableDeviceModel
|
||||
|
||||
@@ -45,9 +45,6 @@ class ProtectButtonEntityDescription(
|
||||
ufp_press: str | None = None
|
||||
|
||||
|
||||
DEVICE_CLASS_CHIME_BUTTON: Final = "unifiprotect__chime_button"
|
||||
|
||||
|
||||
ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = (
|
||||
ProtectButtonEntityDescription(
|
||||
key="reboot",
|
||||
@@ -84,7 +81,6 @@ CHIME_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = (
|
||||
ProtectButtonEntityDescription(
|
||||
key="play",
|
||||
translation_key="play_chime",
|
||||
device_class=DEVICE_CLASS_CHIME_BUTTON,
|
||||
ufp_press="play",
|
||||
),
|
||||
ProtectButtonEntityDescription(
|
||||
|
||||
@@ -83,7 +83,7 @@ def _async_device_entities(
|
||||
_LOGGER.debug(
|
||||
"Adding %s entity %s for %s",
|
||||
klass.__name__,
|
||||
description.name,
|
||||
description.key,
|
||||
device.display_name,
|
||||
)
|
||||
continue
|
||||
@@ -111,7 +111,7 @@ def _async_device_entities(
|
||||
_LOGGER.debug(
|
||||
"Adding %s entity %s for %s",
|
||||
klass.__name__,
|
||||
description.name,
|
||||
description.key,
|
||||
device.display_name,
|
||||
)
|
||||
|
||||
@@ -252,16 +252,11 @@ class BaseProtectEntity(Entity):
|
||||
|
||||
if changed:
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
device_name = device.name or ""
|
||||
if hasattr(self, "entity_description") and self.entity_description.name:
|
||||
device_name += f" {self.entity_description.name}"
|
||||
|
||||
_LOGGER.debug(
|
||||
"Updating state [%s (%s)] %s -> %s",
|
||||
device_name,
|
||||
device.mac,
|
||||
"Updating state [%s] %s -> %s",
|
||||
self.entity_id,
|
||||
previous_attrs,
|
||||
tuple((getattr(self, attr)) for attr in self._state_attrs),
|
||||
tuple(getter() for getter in self._state_getters),
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
from typing import Any
|
||||
|
||||
from uiprotect.api import ProtectApiClient
|
||||
from uiprotect.data import (
|
||||
@@ -102,8 +102,6 @@ DEVICE_RECORDING_MODES = [
|
||||
{"id": mode.value, "name": mode.value.title()} for mode in list(RecordingMode)
|
||||
]
|
||||
|
||||
DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ProtectSelectEntityDescription(
|
||||
@@ -217,7 +215,6 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = (
|
||||
key="doorbell_text",
|
||||
translation_key="doorbell_text",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=DEVICE_CLASS_LCD_MESSAGE,
|
||||
ufp_required_field="feature_flags.has_lcd_screen",
|
||||
ufp_value_fn=_get_doorbell_current,
|
||||
ufp_options_fn=_get_doorbell_options,
|
||||
@@ -377,9 +374,7 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity):
|
||||
entity_description.entity_category is not None
|
||||
and entity_description.ufp_options_fn is not None
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Updating dynamic select options for %s", entity_description.name
|
||||
)
|
||||
_LOGGER.debug("Updating dynamic select options for %s", self.entity_id)
|
||||
self._async_set_options(self.data, entity_description)
|
||||
if (unifi_value := entity_description.get_ufp_value(device)) is None:
|
||||
unifi_value = TYPE_EMPTY_VALUE
|
||||
|
||||
@@ -29,8 +29,6 @@ set_chime_paired_doorbells:
|
||||
selector:
|
||||
device:
|
||||
integration: unifiprotect
|
||||
entity:
|
||||
device_class: unifiprotect__chime_button
|
||||
doorbells:
|
||||
example: "binary_sensor.front_doorbell_doorbell"
|
||||
required: false
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, LOGGER, PLATFORMS
|
||||
@@ -25,14 +26,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
pyvlx = PyVLX(host=host, password=password)
|
||||
|
||||
LOGGER.debug("Velux interface started")
|
||||
LOGGER.debug("Setting up Velux gateway %s", host)
|
||||
try:
|
||||
LOGGER.debug("Retrieving scenes from %s", host)
|
||||
await pyvlx.load_scenes()
|
||||
LOGGER.debug("Retrieving nodes from %s", host)
|
||||
await pyvlx.load_nodes()
|
||||
except PyVLXException as ex:
|
||||
LOGGER.exception("Can't connect to velux interface: %s", ex)
|
||||
return False
|
||||
except (OSError, PyVLXException) as ex:
|
||||
# Defer setup and retry later as the bridge is not ready/available
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to Velux gateway at {host}. "
|
||||
"If connection continues to fail, try power-cycling the gateway device."
|
||||
) from ex
|
||||
|
||||
LOGGER.debug("Velux connection to %s successful", host)
|
||||
entry.runtime_data = pyvlx
|
||||
|
||||
connections = None
|
||||
|
||||
@@ -20,9 +20,7 @@ rules:
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: todo
|
||||
comment: needs rework, failure to setup currently only returns false
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.components.webhook import (
|
||||
)
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import AUTO_SHUT_OFF_EVENT_NAME, DOMAIN
|
||||
@@ -50,7 +51,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) ->
|
||||
)
|
||||
|
||||
watergate_client = WatergateLocalApiClient(
|
||||
sonic_address if sonic_address.startswith("http") else f"http://{sonic_address}"
|
||||
base_url=(
|
||||
sonic_address
|
||||
if sonic_address.startswith("http")
|
||||
else f"http://{sonic_address}"
|
||||
),
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
coordinator = WatergateDataCoordinator(hass, entry, watergate_client)
|
||||
|
||||
@@ -11,6 +11,7 @@ from watergate_local_api.watergate_api import (
|
||||
from homeassistant.components.webhook import async_generate_id as webhook_generate_id
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -34,7 +35,8 @@ class WatergateConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
watergate_client = WatergateLocalApiClient(
|
||||
self.prepare_ip_address(user_input[CONF_IP_ADDRESS])
|
||||
base_url=self.prepare_ip_address(user_input[CONF_IP_ADDRESS]),
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
try:
|
||||
state = await watergate_client.async_get_device_state()
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"dependencies": ["http", "webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/watergate",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["watergate-local-api==2025.1.0"]
|
||||
}
|
||||
|
||||
@@ -27,9 +27,12 @@ rules:
|
||||
|
||||
# Silver
|
||||
config-entry-unloading: done
|
||||
log-when-unavailable: todo
|
||||
log-when-unavailable: done
|
||||
entity-unavailable: done
|
||||
action-exceptions: done
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
@@ -37,5 +40,36 @@ rules:
|
||||
parallel-updates: done
|
||||
test-coverage: done
|
||||
integration-owner: done
|
||||
docs-installation-parameters: todo
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any configuration parameters.
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user