mirror of
https://github.com/home-assistant/core.git
synced 2026-01-11 17:48:37 +00:00
Compare commits
1 Commits
tibber_bin
...
scop-tasmo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
827c13f388 |
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -100,7 +100,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.2"
|
||||
HA_SHORT_VERSION: "2026.1"
|
||||
DEFAULT_PYTHON: "3.13.11"
|
||||
ALL_PYTHON_VERSIONS: "['3.13.11', '3.14.2']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -516,8 +516,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/fireservicerota/ @cyberjunky
|
||||
/homeassistant/components/firmata/ @DaAwesomeP
|
||||
/tests/components/firmata/ @DaAwesomeP
|
||||
/homeassistant/components/fish_audio/ @noambav
|
||||
/tests/components/fish_audio/ @noambav
|
||||
/homeassistant/components/fitbit/ @allenporter
|
||||
/tests/components/fitbit/ @allenporter
|
||||
/homeassistant/components/fivem/ @Sander0542
|
||||
@@ -1170,8 +1168,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/open_router/ @joostlek
|
||||
/homeassistant/components/openerz/ @misialq
|
||||
/tests/components/openerz/ @misialq
|
||||
/homeassistant/components/openevse/ @c00w
|
||||
/tests/components/openevse/ @c00w
|
||||
/homeassistant/components/openexchangerates/ @MartinHjelmare
|
||||
/tests/components/openexchangerates/ @MartinHjelmare
|
||||
/homeassistant/components/opengarage/ @danielhiversen
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"requirements": ["accuweather==5.0.0"]
|
||||
"requirements": ["accuweather==4.2.2"]
|
||||
}
|
||||
|
||||
@@ -15,10 +15,12 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
|
||||
from .entity import ActronAirAcEntity, ActronAirZoneEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -54,7 +56,8 @@ async def async_setup_entry(
|
||||
|
||||
for coordinator in system_coordinators.values():
|
||||
status = coordinator.data
|
||||
entities.append(ActronSystemClimate(coordinator))
|
||||
name = status.ac_system.system_name
|
||||
entities.append(ActronSystemClimate(coordinator, name))
|
||||
|
||||
entities.extend(
|
||||
ActronZoneClimate(coordinator, zone)
|
||||
@@ -65,9 +68,10 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ActronAirClimateEntity(ClimateEntity):
|
||||
class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEntity):
|
||||
"""Base class for Actron Air climate entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
@@ -79,17 +83,43 @@ class ActronAirClimateEntity(ClimateEntity):
|
||||
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
|
||||
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ActronAirSystemCoordinator,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize an Actron Air unit."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_number = coordinator.serial_number
|
||||
|
||||
class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
|
||||
|
||||
class ActronSystemClimate(BaseClimateEntity):
|
||||
"""Representation of the Actron Air system."""
|
||||
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ActronAirSystemCoordinator,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize an Actron Air unit."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = self._serial_number
|
||||
super().__init__(coordinator, name)
|
||||
serial_number = coordinator.serial_number
|
||||
self._attr_unique_id = serial_number
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_number)},
|
||||
name=self._status.ac_system.system_name,
|
||||
manufacturer="Actron Air",
|
||||
model_id=self._status.ac_system.master_wc_model,
|
||||
sw_version=self._status.ac_system.master_wc_firmware_version,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
@@ -138,7 +168,7 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set a new fan mode."""
|
||||
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode)
|
||||
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode.lower())
|
||||
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
@@ -152,7 +182,7 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
|
||||
await self._status.user_aircon_settings.set_temperature(temperature=temp)
|
||||
|
||||
|
||||
class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
|
||||
class ActronZoneClimate(BaseClimateEntity):
|
||||
"""Representation of a zone within the Actron Air system."""
|
||||
|
||||
_attr_supported_features = (
|
||||
@@ -167,8 +197,18 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
|
||||
zone: ActronAirZone,
|
||||
) -> None:
|
||||
"""Initialize an Actron Air unit."""
|
||||
super().__init__(coordinator, zone)
|
||||
self._attr_unique_id: str = self._zone_identifier
|
||||
super().__init__(coordinator, zone.title)
|
||||
serial_number = coordinator.serial_number
|
||||
self._zone_id: int = zone.zone_id
|
||||
self._attr_unique_id: str = f"{serial_number}_zone_{zone.zone_id}"
|
||||
self._attr_device_info: DeviceInfo = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
name=zone.title,
|
||||
manufacturer="Actron Air",
|
||||
model="Zone",
|
||||
suggested_area=zone.title,
|
||||
via_device=(DOMAIN, serial_number),
|
||||
)
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
@@ -216,4 +256,4 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the temperature."""
|
||||
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE))
|
||||
await self._zone.set_temperature(temperature=kwargs["temperature"])
|
||||
|
||||
@@ -8,7 +8,6 @@ from datetime import timedelta
|
||||
from actron_neo_api import (
|
||||
ActronAirACSystem,
|
||||
ActronAirAPI,
|
||||
ActronAirAPIError,
|
||||
ActronAirAuthError,
|
||||
ActronAirStatus,
|
||||
)
|
||||
@@ -16,7 +15,7 @@ from actron_neo_api import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
@@ -71,12 +70,6 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
) from err
|
||||
except ActronAirAPIError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
self.status = self.api.state_manager.get_status(self.serial_number)
|
||||
self.last_seen = dt_util.utcnow()
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Base entity classes for Actron Air integration."""
|
||||
|
||||
from actron_neo_api import ActronAirZone
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ActronAirSystemCoordinator
|
||||
|
||||
|
||||
class ActronAirEntity(CoordinatorEntity[ActronAirSystemCoordinator]):
|
||||
"""Base class for Actron Air entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_number = coordinator.serial_number
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return not self.coordinator.is_device_stale()
|
||||
|
||||
|
||||
class ActronAirAcEntity(ActronAirEntity):
|
||||
"""Base class for Actron Air entities."""
|
||||
|
||||
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._serial_number)},
|
||||
name=coordinator.data.ac_system.system_name,
|
||||
manufacturer="Actron Air",
|
||||
model_id=coordinator.data.ac_system.master_wc_model,
|
||||
sw_version=coordinator.data.ac_system.master_wc_firmware_version,
|
||||
serial_number=self._serial_number,
|
||||
)
|
||||
|
||||
|
||||
class ActronAirZoneEntity(ActronAirEntity):
|
||||
"""Base class for Actron Air zone entities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ActronAirSystemCoordinator,
|
||||
zone: ActronAirZone,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._zone_id: int = zone.zone_id
|
||||
self._zone_identifier = f"{self._serial_number}_zone_{zone.zone_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._zone_identifier)},
|
||||
name=zone.title,
|
||||
manufacturer="Actron Air",
|
||||
model="Zone",
|
||||
suggested_area=zone.title,
|
||||
via_device=(DOMAIN, self._serial_number),
|
||||
)
|
||||
@@ -51,9 +51,6 @@
|
||||
"exceptions": {
|
||||
"auth_error": {
|
||||
"message": "Authentication failed, please reauthenticate"
|
||||
},
|
||||
"update_error": {
|
||||
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ from typing import Any
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
|
||||
from .entity import ActronAirAcEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -72,9 +74,10 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
|
||||
class ActronAirSwitch(CoordinatorEntity[ActronAirSystemCoordinator], SwitchEntity):
|
||||
"""Actron Air switch."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
entity_description: ActronAirSwitchEntityDescription
|
||||
|
||||
@@ -87,6 +90,11 @@ class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.serial_number)},
|
||||
manufacturer="Actron Air",
|
||||
name=coordinator.data.ac_system.system_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/adax",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adax", "adax_local"],
|
||||
"requirements": ["adax==0.4.0", "Adax-local==0.3.0"]
|
||||
"requirements": ["adax==0.4.0", "Adax-local==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -7,12 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Button platform for Airobot integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pyairobotrest.exceptions import (
|
||||
AirobotConnectionError,
|
||||
AirobotError,
|
||||
AirobotTimeoutError,
|
||||
)
|
||||
|
||||
from homeassistant.components.button import (
|
||||
ButtonDeviceClass,
|
||||
ButtonEntity,
|
||||
ButtonEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
|
||||
from .entity import AirobotEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirobotButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Airobot button entity."""
|
||||
|
||||
press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
|
||||
AirobotButtonEntityDescription(
|
||||
key="restart",
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AirobotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Airobot button entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AirobotButton(coordinator, description) for description in BUTTON_TYPES
|
||||
)
|
||||
|
||||
|
||||
class AirobotButton(AirobotEntity, ButtonEntity):
|
||||
"""Representation of an Airobot button."""
|
||||
|
||||
entity_description: AirobotButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: AirobotDataUpdateCoordinator,
|
||||
description: AirobotButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the button."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator)
|
||||
except (AirobotConnectionError, AirobotTimeoutError):
|
||||
# Connection errors during reboot are expected as device restarts
|
||||
pass
|
||||
except AirobotError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="button_press_failed",
|
||||
translation_placeholders={"button": self.entity_description.key},
|
||||
) from err
|
||||
@@ -175,42 +175,6 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
# Verify the device ID matches the existing config entry
|
||||
await self.async_set_unique_id(info.device_id)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_device")
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates=user_input,
|
||||
title=info.title,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, reconfigure_entry.data
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyairobotrest==0.2.0"]
|
||||
"requirements": ["pyairobotrest==0.1.0"]
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: This integration doesn't have any cases where raising an issue is needed.
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"wrong_device": "Device ID does not match the existing configuration. Please use the correct device credentials."
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -30,19 +28,6 @@
|
||||
},
|
||||
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "Device ID"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::airobot::config::step::user::data_description::host%]",
|
||||
"password": "[%key:component::airobot::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::airobot::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "Update your Airobot thermostat connection details. Note: The Device ID must remain the same as the original configuration."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
@@ -86,9 +71,6 @@
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed, please reauthenticate."
|
||||
},
|
||||
"button_press_failed": {
|
||||
"message": "Failed to press {button} button."
|
||||
},
|
||||
"connection_failed": {
|
||||
"message": "Failed to communicate with device."
|
||||
},
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["airos==0.6.1"]
|
||||
"requirements": ["airos==0.6.0"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==11.0.2"]
|
||||
"requirements": ["aioamazondevices==10.0.0"]
|
||||
}
|
||||
|
||||
@@ -136,7 +136,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"person",
|
||||
"scene",
|
||||
"siren",
|
||||
"switch",
|
||||
|
||||
@@ -80,7 +80,7 @@ class AzureDataExplorerClient:
|
||||
def test_connection(self) -> None:
|
||||
"""Test connection, will throw Exception if it cannot connect."""
|
||||
|
||||
query = f"['{self._table}'] | take 1"
|
||||
query = f"{self._table} | take 1"
|
||||
|
||||
self.query_client.execute_query(self._database, query)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def validate_input(self, data: dict[str, Any]) -> dict[str, str]:
|
||||
async def validate_input(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
@@ -54,40 +54,36 @@ class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(client.test_connection)
|
||||
except KustoAuthenticationError as err:
|
||||
_LOGGER.error("Authentication failed: %s", err)
|
||||
|
||||
except KustoAuthenticationError as exp:
|
||||
_LOGGER.error(exp)
|
||||
return {"base": "invalid_auth"}
|
||||
except KustoServiceError as err:
|
||||
_LOGGER.error("Could not connect: %s", err)
|
||||
|
||||
except KustoServiceError as exp:
|
||||
_LOGGER.error(exp)
|
||||
return {"base": "cannot_connect"}
|
||||
|
||||
return {}
|
||||
return None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
data_schema = STEP_USER_DATA_SCHEMA
|
||||
|
||||
if user_input is not None:
|
||||
errors = await self.validate_input(user_input)
|
||||
errors: dict = {}
|
||||
if user_input:
|
||||
errors = await self.validate_input(user_input) # type: ignore[assignment]
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
data=user_input,
|
||||
title=f"{user_input[CONF_ADX_CLUSTER_INGEST_URI].replace('https://', '')} / {user_input[CONF_ADX_DATABASE_NAME]} ({user_input[CONF_ADX_TABLE_NAME]})",
|
||||
title=user_input[CONF_ADX_CLUSTER_INGEST_URI].replace(
|
||||
"https://", ""
|
||||
),
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
|
||||
# Keep previously entered values when we re-show the form after an error.
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"use_queued_ingestion": "Use queued ingestion"
|
||||
},
|
||||
"data_description": {
|
||||
"authority_id": "In Azure portal this is also known as Directory (tenant) ID",
|
||||
"cluster_ingest_uri": "Ingestion URI of the cluster",
|
||||
"use_queued_ingestion": "Must be enabled when using ADX free cluster"
|
||||
},
|
||||
|
||||
@@ -6,15 +6,13 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from b2sdk.v2 import Bucket, exception
|
||||
from b2sdk.v2 import B2Api, Bucket, InMemoryAccountInfo, exception
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
# Import from b2_client to ensure timeout configuration is applied
|
||||
from .b2_client import B2Api, InMemoryAccountInfo
|
||||
from .const import (
|
||||
BACKBLAZE_REALM,
|
||||
CONF_APPLICATION_KEY,
|
||||
@@ -74,11 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except (
|
||||
exception.B2ConnectionError,
|
||||
exception.B2RequestTimeout,
|
||||
exception.ConnectionReset,
|
||||
) as err:
|
||||
except exception.ConnectionReset as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Backblaze B2 client with extended timeouts.
|
||||
|
||||
The b2sdk library uses class-level timeout attributes. To avoid modifying
|
||||
global library state, we subclass the relevant classes to provide extended
|
||||
timeouts suitable for backup operations involving large files.
|
||||
"""
|
||||
|
||||
from b2sdk.v2 import B2Api as BaseB2Api, InMemoryAccountInfo
|
||||
from b2sdk.v2.b2http import B2Http as BaseB2Http
|
||||
from b2sdk.v2.session import B2Session as BaseB2Session
|
||||
|
||||
# Extended timeouts for Home Assistant backup operations
|
||||
# Default CONNECTION_TIMEOUT is 46 seconds, which can be too short for slow connections
|
||||
CONNECTION_TIMEOUT = 120 # 2 minutes
|
||||
|
||||
# Default TIMEOUT_FOR_UPLOAD is 128 seconds, which is too short for large backups
|
||||
TIMEOUT_FOR_UPLOAD = 43200 # 12 hours
|
||||
|
||||
|
||||
class B2Http(BaseB2Http): # type: ignore[misc]
|
||||
"""B2Http with extended timeouts for backup operations."""
|
||||
|
||||
CONNECTION_TIMEOUT = CONNECTION_TIMEOUT
|
||||
TIMEOUT_FOR_UPLOAD = TIMEOUT_FOR_UPLOAD
|
||||
|
||||
|
||||
class B2Session(BaseB2Session): # type: ignore[misc]
|
||||
"""B2Session using custom B2Http with extended timeouts."""
|
||||
|
||||
B2HTTP_CLASS = B2Http
|
||||
|
||||
|
||||
class B2Api(BaseB2Api): # type: ignore[misc]
|
||||
"""B2Api using custom session with extended timeouts."""
|
||||
|
||||
SESSION_CLASS = B2Session
|
||||
|
||||
|
||||
__all__ = ["B2Api", "InMemoryAccountInfo"]
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from b2sdk.v2 import exception
|
||||
from b2sdk.v2 import B2Api, InMemoryAccountInfo, exception
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||
@@ -17,8 +17,6 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
# Import from b2_client to ensure timeout configuration is applied
|
||||
from .b2_client import B2Api, InMemoryAccountInfo
|
||||
from .const import (
|
||||
BACKBLAZE_REALM,
|
||||
CONF_APPLICATION_KEY,
|
||||
@@ -174,12 +172,8 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET]
|
||||
)
|
||||
errors[CONF_BUCKET] = "invalid_bucket_name"
|
||||
except (
|
||||
exception.B2ConnectionError,
|
||||
exception.B2RequestTimeout,
|
||||
exception.ConnectionReset,
|
||||
) as err:
|
||||
_LOGGER.error("Failed to connect to Backblaze B2: %s", err)
|
||||
except exception.ConnectionReset:
|
||||
_LOGGER.error("Failed to connect to Backblaze B2. Connection reset")
|
||||
errors["base"] = "cannot_connect"
|
||||
except exception.MissingAccountData:
|
||||
# This generally indicates an issue with how InMemoryAccountInfo is used
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["b2sdk"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["b2sdk==2.10.1"]
|
||||
"requirements": ["b2sdk==2.8.1"]
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -2,25 +2,16 @@
|
||||
|
||||
from pyblu import Player
|
||||
from pyblu.errors import PlayerUnreachableError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ATTR_MASTER,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
SERVICE_JOIN,
|
||||
SERVICE_SET_TIMER,
|
||||
SERVICE_UNJOIN,
|
||||
)
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
BluesoundConfigEntry,
|
||||
BluesoundCoordinator,
|
||||
@@ -37,38 +28,6 @@ PLATFORMS = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Bluesound."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_TIMER,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_increase_timer",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_clear_timer",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_JOIN,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={vol.Required(ATTR_MASTER): cv.entity_id},
|
||||
func="async_bluesound_join",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_UNJOIN,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_bluesound_unjoin",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,3 @@ DOMAIN = "bluesound"
|
||||
INTEGRATION_TITLE = "Bluesound"
|
||||
ATTR_BLUESOUND_GROUP = "bluesound_group"
|
||||
ATTR_MASTER = "master"
|
||||
|
||||
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
|
||||
SERVICE_JOIN = "join"
|
||||
SERVICE_SET_TIMER = "set_sleep_timer"
|
||||
SERVICE_UNJOIN = "unjoin"
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyblu import Input, Player, Preset, Status, SyncStatus
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -21,7 +22,12 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_platform,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
@@ -35,15 +41,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
|
||||
from .const import (
|
||||
ATTR_BLUESOUND_GROUP,
|
||||
ATTR_MASTER,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAR_TIMER,
|
||||
SERVICE_JOIN,
|
||||
SERVICE_SET_TIMER,
|
||||
SERVICE_UNJOIN,
|
||||
)
|
||||
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
|
||||
from .coordinator import BluesoundCoordinator
|
||||
from .utils import (
|
||||
dispatcher_join_signal,
|
||||
@@ -62,6 +60,11 @@ SCAN_INTERVAL = timedelta(minutes=15)
|
||||
DATA_BLUESOUND = DOMAIN
|
||||
DEFAULT_PORT = 11000
|
||||
|
||||
SERVICE_CLEAR_TIMER = "clear_sleep_timer"
|
||||
SERVICE_JOIN = "join"
|
||||
SERVICE_SET_TIMER = "set_sleep_timer"
|
||||
SERVICE_UNJOIN = "unjoin"
|
||||
|
||||
POLL_TIMEOUT = 120
|
||||
|
||||
|
||||
@@ -78,6 +81,20 @@ async def async_setup_entry(
|
||||
config_entry.runtime_data.player,
|
||||
)
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_TIMER, None, "async_increase_timer"
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CLEAR_TIMER, None, "async_clear_timer"
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_JOIN, {vol.Required(ATTR_MASTER): cv.entity_id}, "async_bluesound_join"
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_UNJOIN, None, "async_bluesound_unjoin"
|
||||
)
|
||||
|
||||
async_add_entities([bluesound_player], update_before_add=True)
|
||||
|
||||
|
||||
|
||||
@@ -27,18 +27,13 @@ from homeassistant.exceptions import (
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_PASSKEY, DOMAIN
|
||||
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type BSBLanConfigEntry = ConfigEntry[BSBLanData]
|
||||
|
||||
|
||||
@@ -54,12 +49,6 @@ class BSBLanData:
|
||||
static: StaticState
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the BSB-Lan integration."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
|
||||
"""Set up BSB-Lan from a config entry."""
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"services": {
|
||||
"set_hot_water_schedule": {
|
||||
"service": "mdi:calendar-clock"
|
||||
},
|
||||
"sync_time": {
|
||||
"service": "mdi:timer-sync-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"requirements": ["python-bsblan==3.1.6"],
|
||||
"requirements": ["python-bsblan==3.1.4"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
"""Support for BSB-Lan services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import BSBLanConfigEntry
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
ATTR_MONDAY_SLOTS = "monday_slots"
|
||||
ATTR_TUESDAY_SLOTS = "tuesday_slots"
|
||||
ATTR_WEDNESDAY_SLOTS = "wednesday_slots"
|
||||
ATTR_THURSDAY_SLOTS = "thursday_slots"
|
||||
ATTR_FRIDAY_SLOTS = "friday_slots"
|
||||
ATTR_SATURDAY_SLOTS = "saturday_slots"
|
||||
ATTR_SUNDAY_SLOTS = "sunday_slots"
|
||||
|
||||
# Service names
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule"
|
||||
SERVICE_SYNC_TIME = "sync_time"
|
||||
|
||||
|
||||
# Schema for a single time slot
|
||||
_SLOT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("start_time"): cv.time,
|
||||
vol.Required("end_time"): cv.time,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): cv.string,
|
||||
vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _convert_time_slots_to_day_schedule(
|
||||
slots: list[dict[str, time]] | None,
|
||||
) -> DaySchedule | None:
|
||||
"""Convert list of time slot dicts to a DaySchedule object.
|
||||
|
||||
Example: [{"start_time": time(6, 0), "end_time": time(8, 0)},
|
||||
{"start_time": time(17, 0), "end_time": time(21, 0)}]
|
||||
becomes: DaySchedule with two TimeSlot objects
|
||||
|
||||
None returns None (don't modify this day).
|
||||
Empty list returns DaySchedule with empty slots (clear this day).
|
||||
"""
|
||||
if slots is None:
|
||||
return None
|
||||
|
||||
if not slots:
|
||||
return DaySchedule(slots=[])
|
||||
|
||||
time_slots = []
|
||||
for slot in slots:
|
||||
start_time = slot["start_time"]
|
||||
end_time = slot["end_time"]
|
||||
|
||||
# Validate that end time is after start time
|
||||
if end_time <= start_time:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_time_before_start_time",
|
||||
translation_placeholders={
|
||||
"start_time": start_time.strftime("%H:%M"),
|
||||
"end_time": end_time.strftime("%H:%M"),
|
||||
},
|
||||
)
|
||||
|
||||
time_slots.append(TimeSlot(start=start_time, end=end_time))
|
||||
LOGGER.debug(
|
||||
"Created time slot: %s-%s",
|
||||
start_time.strftime("%H:%M"),
|
||||
end_time.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
LOGGER.debug("Created DaySchedule with %d slots", len(time_slots))
|
||||
return DaySchedule(slots=time_slots)
|
||||
|
||||
|
||||
async def set_hot_water_schedule(service_call: ServiceCall) -> None:
|
||||
"""Set hot water heating schedule."""
|
||||
device_id = service_call.data[ATTR_DEVICE_ID]
|
||||
|
||||
# Get the device and config entry
|
||||
device_registry = dr.async_get(service_call.hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_device_id",
|
||||
translation_placeholders={"device_id": device_id},
|
||||
)
|
||||
|
||||
# Find the config entry for this device
|
||||
matching_entries: list[BSBLanConfigEntry] = [
|
||||
entry
|
||||
for entry in service_call.hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.entry_id in device_entry.config_entries
|
||||
]
|
||||
|
||||
if not matching_entries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry_for_device",
|
||||
translation_placeholders={"device_id": device_entry.name or device_id},
|
||||
)
|
||||
|
||||
entry = matching_entries[0]
|
||||
|
||||
# Verify the config entry is loaded
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
translation_placeholders={"device_name": device_entry.name or device_id},
|
||||
)
|
||||
|
||||
client = entry.runtime_data.client
|
||||
|
||||
# Convert time slots to DaySchedule objects
|
||||
monday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_MONDAY_SLOTS)
|
||||
)
|
||||
tuesday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_TUESDAY_SLOTS)
|
||||
)
|
||||
wednesday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_WEDNESDAY_SLOTS)
|
||||
)
|
||||
thursday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_THURSDAY_SLOTS)
|
||||
)
|
||||
friday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_FRIDAY_SLOTS)
|
||||
)
|
||||
saturday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_SATURDAY_SLOTS)
|
||||
)
|
||||
sunday = _convert_time_slots_to_day_schedule(
|
||||
service_call.data.get(ATTR_SUNDAY_SLOTS)
|
||||
)
|
||||
|
||||
# Create the DHWSchedule object
|
||||
dhw_schedule = DHWSchedule(
|
||||
monday=monday,
|
||||
tuesday=tuesday,
|
||||
wednesday=wednesday,
|
||||
thursday=thursday,
|
||||
friday=friday,
|
||||
saturday=saturday,
|
||||
sunday=sunday,
|
||||
)
|
||||
|
||||
LOGGER.debug(
|
||||
"Setting hot water schedule - Monday: %s, Tuesday: %s, Wednesday: %s, "
|
||||
"Thursday: %s, Friday: %s, Saturday: %s, Sunday: %s",
|
||||
monday,
|
||||
tuesday,
|
||||
wednesday,
|
||||
thursday,
|
||||
friday,
|
||||
saturday,
|
||||
sunday,
|
||||
)
|
||||
|
||||
try:
|
||||
# Call the BSB-Lan API to set the schedule
|
||||
await client.set_hot_water_schedule(dhw_schedule)
|
||||
except BSBLANError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_schedule_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
# Refresh the slow coordinator to get the updated schedule
|
||||
await entry.runtime_data.slow_coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def async_sync_time(service_call: ServiceCall) -> None:
|
||||
"""Synchronize BSB-LAN device time with Home Assistant."""
|
||||
device_id: str = service_call.data[ATTR_DEVICE_ID]
|
||||
|
||||
# Get the device and config entry
|
||||
device_registry = dr.async_get(service_call.hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_device_id",
|
||||
translation_placeholders={"device_id": device_id},
|
||||
)
|
||||
|
||||
# Find the config entry for this device
|
||||
matching_entries: list[BSBLanConfigEntry] = [
|
||||
entry
|
||||
for entry in service_call.hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.entry_id in device_entry.config_entries
|
||||
]
|
||||
|
||||
if not matching_entries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry_for_device",
|
||||
translation_placeholders={"device_id": device_entry.name or device_id},
|
||||
)
|
||||
|
||||
entry = matching_entries[0]
|
||||
|
||||
# Verify the config entry is loaded
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_loaded",
|
||||
translation_placeholders={"device_name": device_entry.name or device_id},
|
||||
)
|
||||
|
||||
client = entry.runtime_data.client
|
||||
|
||||
try:
|
||||
# Get current device time
|
||||
device_time = await client.time()
|
||||
current_time = dt_util.now()
|
||||
current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
# Only sync if device time differs from HA time
|
||||
if device_time.time.value != current_time_str:
|
||||
await client.set_time(current_time_str)
|
||||
except BSBLANError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="sync_time_failed",
|
||||
translation_placeholders={
|
||||
"device_name": device_entry.name or device_id,
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
SYNC_TIME_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the BSB-Lan services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
set_hot_water_schedule,
|
||||
schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SYNC_TIME,
|
||||
async_sync_time,
|
||||
schema=SYNC_TIME_SCHEMA,
|
||||
)
|
||||
@@ -1,122 +0,0 @@
|
||||
sync_time:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
example: "abc123device456"
|
||||
selector:
|
||||
device:
|
||||
integration: bsblan
|
||||
|
||||
set_hot_water_schedule:
|
||||
fields:
|
||||
device_id:
|
||||
required: true
|
||||
example: "abc123device456"
|
||||
selector:
|
||||
device:
|
||||
integration: bsblan
|
||||
monday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
tuesday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
wednesday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
thursday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
friday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
saturday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
sunday_slots:
|
||||
selector:
|
||||
object:
|
||||
multiple: true
|
||||
label_field: start_time
|
||||
description_field: end_time
|
||||
fields:
|
||||
start_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
end_time:
|
||||
required: true
|
||||
selector:
|
||||
time:
|
||||
@@ -70,18 +70,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"config_entry_not_loaded": {
|
||||
"message": "The device `{device_name}` is not currently loaded or available"
|
||||
},
|
||||
"end_time_before_start_time": {
|
||||
"message": "End time ({end_time}) must be after start time ({start_time})"
|
||||
},
|
||||
"invalid_device_id": {
|
||||
"message": "Invalid device ID: {device_id}"
|
||||
},
|
||||
"no_config_entry_for_device": {
|
||||
"message": "No configuration entry found for device: {device_id}"
|
||||
},
|
||||
"set_data_error": {
|
||||
"message": "An error occurred while sending the data to the BSB-Lan device"
|
||||
},
|
||||
@@ -91,9 +79,6 @@
|
||||
"set_preset_mode_error": {
|
||||
"message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto"
|
||||
},
|
||||
"set_schedule_failed": {
|
||||
"message": "Failed to set hot water schedule: {error}"
|
||||
},
|
||||
"set_temperature_error": {
|
||||
"message": "An error occurred while setting the temperature"
|
||||
},
|
||||
@@ -105,59 +90,6 @@
|
||||
},
|
||||
"setup_general_error": {
|
||||
"message": "An unknown error occurred while retrieving static device data"
|
||||
},
|
||||
"sync_time_failed": {
|
||||
"message": "Failed to sync time for {device_name}: {error}"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_hot_water_schedule": {
|
||||
"description": "Set the hot water heating schedule for a BSB-LAN device.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The BSB-LAN device to configure.",
|
||||
"name": "Device"
|
||||
},
|
||||
"friday_slots": {
|
||||
"description": "Time periods for Friday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Friday time slots"
|
||||
},
|
||||
"monday_slots": {
|
||||
"description": "Time periods for Monday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Monday time slots"
|
||||
},
|
||||
"saturday_slots": {
|
||||
"description": "Time periods for Saturday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Saturday time slots"
|
||||
},
|
||||
"sunday_slots": {
|
||||
"description": "Time periods for Sunday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Sunday time slots"
|
||||
},
|
||||
"thursday_slots": {
|
||||
"description": "Time periods for Thursday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Thursday time slots"
|
||||
},
|
||||
"tuesday_slots": {
|
||||
"description": "Time periods for Tuesday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Tuesday time slots"
|
||||
},
|
||||
"wednesday_slots": {
|
||||
"description": "Time periods for Wednesday. Add multiple slots for different heating periods throughout the day.",
|
||||
"name": "Wednesday time slots"
|
||||
}
|
||||
},
|
||||
"name": "Set hot water schedule"
|
||||
},
|
||||
"sync_time": {
|
||||
"description": "Synchronize Home Assistant time to the BSB-Lan device. Only updates if device time differs from Home Assistant time.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The BSB-LAN device to sync time for.",
|
||||
"name": "Device"
|
||||
}
|
||||
},
|
||||
"name": "Sync time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"codeowners": ["@emontnemery"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["casttube", "pychromecast"],
|
||||
"requirements": ["PyChromecast==14.0.9"],
|
||||
|
||||
@@ -5,7 +5,7 @@ from aiocomelit.const import BRIDGE
|
||||
from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_VEDO_PIN, DEFAULT_PORT
|
||||
from .const import DEFAULT_PORT
|
||||
from .coordinator import (
|
||||
ComelitBaseCoordinator,
|
||||
ComelitConfigEntry,
|
||||
@@ -22,16 +22,6 @@ BRIDGE_PLATFORMS = [
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
BRIDGE_AND_VEDO_PLATFORMS = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.HUMIDIFIER,
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
VEDO_PLATFORMS = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -47,20 +37,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ComelitConfigEntry) -> b
|
||||
session = await async_client_session(hass)
|
||||
|
||||
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
vedo_pin = entry.data.get(CONF_VEDO_PIN)
|
||||
coordinator = ComelitSerialBridge(
|
||||
hass,
|
||||
entry,
|
||||
entry.data[CONF_HOST],
|
||||
entry.data.get(CONF_PORT, DEFAULT_PORT),
|
||||
entry.data[CONF_PIN],
|
||||
vedo_pin,
|
||||
session,
|
||||
)
|
||||
platforms = BRIDGE_PLATFORMS
|
||||
# Add VEDO platforms if vedo_pin is configured
|
||||
if vedo_pin:
|
||||
platforms = BRIDGE_AND_VEDO_PLATFORMS
|
||||
else:
|
||||
coordinator = ComelitVedoSystem(
|
||||
hass,
|
||||
@@ -86,9 +71,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ComelitConfigEntry) ->
|
||||
|
||||
if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
platforms = BRIDGE_PLATFORMS
|
||||
# Add VEDO platforms if vedo_pin was configured
|
||||
if entry.data.get(CONF_VEDO_PIN):
|
||||
platforms = BRIDGE_AND_VEDO_PLATFORMS
|
||||
else:
|
||||
platforms = VEDO_PLATFORMS
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import cast
|
||||
|
||||
from aiocomelit.api import ComelitVedoAreaObject
|
||||
from aiocomelit.const import ALARM_AREA, AlarmAreaState
|
||||
from aiocomelit.const import AlarmAreaState
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
|
||||
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -56,25 +56,15 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Comelit VEDO system alarm control panel devices."""
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
is_bridge = isinstance(coordinator, ComelitSerialBridge)
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if is_bridge:
|
||||
assert isinstance(coordinator, ComelitSerialBridge)
|
||||
else:
|
||||
assert isinstance(coordinator, ComelitVedoSystem)
|
||||
|
||||
if data := coordinator.data[ALARM_AREA]:
|
||||
async_add_entities(
|
||||
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in data.values()
|
||||
)
|
||||
async_add_entities(
|
||||
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_areas"].values()
|
||||
)
|
||||
|
||||
|
||||
class ComelitAlarmEntity(
|
||||
CoordinatorEntity[ComelitVedoSystem | ComelitSerialBridge], AlarmControlPanelEntity
|
||||
):
|
||||
class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanelEntity):
|
||||
"""Representation of a Ness alarm panel."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -88,7 +78,7 @@ class ComelitAlarmEntity(
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ComelitVedoSystem | ComelitSerialBridge,
|
||||
coordinator: ComelitVedoSystem,
|
||||
area: ComelitVedoAreaObject,
|
||||
config_entry_entry_id: str,
|
||||
) -> None:
|
||||
@@ -105,9 +95,7 @@ class ComelitAlarmEntity(
|
||||
@property
|
||||
def _area(self) -> ComelitVedoAreaObject:
|
||||
"""Return area object."""
|
||||
return cast(
|
||||
ComelitVedoAreaObject, self.coordinator.data[ALARM_AREA][self._area_index]
|
||||
)
|
||||
return self.coordinator.data["alarm_areas"][self._area_index]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import cast
|
||||
|
||||
from aiocomelit.api import ComelitVedoZoneObject
|
||||
from aiocomelit.const import ALARM_ZONE, AlarmZoneState
|
||||
from aiocomelit import ComelitVedoZoneObject
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -16,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import ObjectClassType
|
||||
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
|
||||
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
|
||||
from .utils import new_device_listener
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
@@ -30,32 +29,25 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Comelit VEDO presence sensors."""
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
is_bridge = isinstance(coordinator, ComelitSerialBridge)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if is_bridge:
|
||||
assert isinstance(coordinator, ComelitSerialBridge)
|
||||
else:
|
||||
assert isinstance(coordinator, ComelitVedoSystem)
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, ALARM_ZONE)
|
||||
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
)
|
||||
|
||||
|
||||
class ComelitVedoBinarySensorEntity(
|
||||
CoordinatorEntity[ComelitVedoSystem | ComelitSerialBridge], BinarySensorEntity
|
||||
CoordinatorEntity[ComelitVedoSystem], BinarySensorEntity
|
||||
):
|
||||
"""Sensor device."""
|
||||
|
||||
@@ -64,7 +56,7 @@ class ComelitVedoBinarySensorEntity(
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ComelitVedoSystem | ComelitSerialBridge,
|
||||
coordinator: ComelitVedoSystem,
|
||||
zone: ComelitVedoZoneObject,
|
||||
config_entry_entry_id: str,
|
||||
) -> None:
|
||||
@@ -76,25 +68,9 @@ class ComelitVedoBinarySensorEntity(
|
||||
self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}"
|
||||
self._attr_device_info = coordinator.platform_device_info(zone, "zone")
|
||||
|
||||
@property
|
||||
def _zone(self) -> ComelitVedoZoneObject:
|
||||
"""Return zone object."""
|
||||
return cast(
|
||||
ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index]
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if alarm is available."""
|
||||
if self._zone.human_status in [
|
||||
AlarmZoneState.FAULTY,
|
||||
AlarmZoneState.UNAVAILABLE,
|
||||
AlarmZoneState.UNKNOWN,
|
||||
]:
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Presence detected."""
|
||||
return self._zone.status_api == "0001"
|
||||
return (
|
||||
self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from asyncio.exceptions import TimeoutError
|
||||
from collections.abc import Mapping
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from aiocomelit import (
|
||||
ComeliteSerialBridgeApi,
|
||||
@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import _LOGGER, CONF_VEDO_PIN, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
|
||||
from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN
|
||||
from .utils import async_client_session
|
||||
|
||||
DEFAULT_HOST = "192.168.1.252"
|
||||
@@ -34,12 +34,9 @@ USER_SCHEMA = vol.Schema(
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
|
||||
vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST),
|
||||
vol.Optional(CONF_VEDO_PIN): cv.string,
|
||||
}
|
||||
)
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{vol.Required(CONF_PIN): cv.string, vol.Optional(CONF_VEDO_PIN): cv.string}
|
||||
)
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||
@@ -75,18 +72,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
finally:
|
||||
await api.logout()
|
||||
|
||||
# Validate VEDO PIN if provided and device type is BRIDGE
|
||||
if data.get(CONF_VEDO_PIN) and data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
if not re.fullmatch(r"[0-9]{4,10}", data[CONF_VEDO_PIN]):
|
||||
raise InvalidVedoPin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(api, ComeliteSerialBridgeApi)
|
||||
|
||||
# Verify VEDO is enabled with the provided PIN
|
||||
if not await api.vedo_enabled(data[CONF_VEDO_PIN]):
|
||||
raise InvalidVedoAuth
|
||||
|
||||
return {"title": data[CONF_HOST]}
|
||||
|
||||
|
||||
@@ -114,10 +99,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "invalid_auth"
|
||||
except InvalidPin:
|
||||
errors["base"] = "invalid_pin"
|
||||
except InvalidVedoPin:
|
||||
errors["base"] = "invalid_vedo_pin"
|
||||
except InvalidVedoAuth:
|
||||
errors["base"] = "invalid_vedo_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
@@ -201,8 +182,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PIN: user_input[CONF_PIN],
|
||||
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
|
||||
}
|
||||
if CONF_VEDO_PIN in user_input:
|
||||
data_to_validate[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
|
||||
await validate_input(self.hass, data_to_validate)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -210,10 +189,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "invalid_auth"
|
||||
except InvalidPin:
|
||||
errors["base"] = "invalid_pin"
|
||||
except InvalidVedoPin:
|
||||
errors["base"] = "invalid_vedo_pin"
|
||||
except InvalidVedoAuth:
|
||||
errors["base"] = "invalid_vedo_auth"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
@@ -223,8 +198,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_PIN: user_input[CONF_PIN],
|
||||
}
|
||||
if CONF_VEDO_PIN in user_input:
|
||||
data_updates[CONF_VEDO_PIN] = user_input[CONF_VEDO_PIN]
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry, data_updates=data_updates
|
||||
)
|
||||
@@ -238,7 +211,6 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PORT, default=reconfigure_entry.data[CONF_PORT]
|
||||
): cv.port,
|
||||
vol.Optional(CONF_PIN): cv.string,
|
||||
vol.Optional(CONF_VEDO_PIN): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -259,11 +231,3 @@ class InvalidAuth(HomeAssistantError):
|
||||
|
||||
class InvalidPin(HomeAssistantError):
|
||||
"""Error to indicate an invalid pin."""
|
||||
|
||||
|
||||
class InvalidVedoPin(HomeAssistantError):
|
||||
"""Error to indicate an invalid VEDO pin."""
|
||||
|
||||
|
||||
class InvalidVedoAuth(HomeAssistantError):
|
||||
"""Error to indicate VEDO authentication failed."""
|
||||
|
||||
@@ -19,7 +19,6 @@ ObjectClassType = (
|
||||
DOMAIN = "comelit"
|
||||
DEFAULT_PORT = 80
|
||||
DEVICE_TYPE_LIST = [BRIDGE, VEDO]
|
||||
CONF_VEDO_PIN = "vedo_pin"
|
||||
|
||||
SCAN_INTERVAL = 5
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Support for Comelit."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import TypeVar, cast
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from aiocomelit.api import ComelitCommonApi, ComeliteSerialBridgeApi, ComelitVedoApi
|
||||
from aiocomelit.api import (
|
||||
AlarmDataObject,
|
||||
ComelitCommonApi,
|
||||
ComeliteSerialBridgeApi,
|
||||
ComelitSerialBridgeObject,
|
||||
ComelitVedoApi,
|
||||
)
|
||||
from aiocomelit.const import (
|
||||
ALARM_AREA,
|
||||
ALARM_ZONE,
|
||||
BRIDGE,
|
||||
CLIMATE,
|
||||
COVER,
|
||||
@@ -34,10 +37,7 @@ type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator]
|
||||
|
||||
T = TypeVar(
|
||||
"T",
|
||||
bound=dict[
|
||||
str,
|
||||
Mapping[int, ObjectClassType],
|
||||
],
|
||||
bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,8 +118,8 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
|
||||
|
||||
async def _async_remove_stale_devices(
|
||||
self,
|
||||
previous_list: Mapping[int, ObjectClassType],
|
||||
current_list: Mapping[int, ObjectClassType],
|
||||
previous_list: dict[int, Any],
|
||||
current_list: dict[int, Any],
|
||||
dev_type: str,
|
||||
) -> None:
|
||||
"""Remove stale devices."""
|
||||
@@ -143,7 +143,9 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
|
||||
)
|
||||
|
||||
|
||||
class ComelitSerialBridge(ComelitBaseCoordinator[T]):
|
||||
class ComelitSerialBridge(
|
||||
ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
|
||||
):
|
||||
"""Queries Comelit Serial Bridge."""
|
||||
|
||||
_hw_version = "20003101"
|
||||
@@ -156,23 +158,17 @@ class ComelitSerialBridge(ComelitBaseCoordinator[T]):
|
||||
host: str,
|
||||
port: int,
|
||||
pin: str,
|
||||
vedo_pin: str | None,
|
||||
session: ClientSession,
|
||||
) -> None:
|
||||
"""Initialize the scanner."""
|
||||
self.api = ComeliteSerialBridgeApi(host, port, pin, session)
|
||||
self.vedo_pin = vedo_pin
|
||||
super().__init__(hass, entry, BRIDGE, host)
|
||||
|
||||
async def _async_update_system_data(
|
||||
self,
|
||||
) -> T:
|
||||
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
|
||||
"""Specific method for updating data."""
|
||||
data: dict[
|
||||
str,
|
||||
Mapping[int, ObjectClassType],
|
||||
] = {}
|
||||
data.update(await self.api.get_all_devices())
|
||||
data = await self.api.get_all_devices()
|
||||
|
||||
if self.data:
|
||||
for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
|
||||
@@ -180,14 +176,10 @@ class ComelitSerialBridge(ComelitBaseCoordinator[T]):
|
||||
self.data[dev_type], data[dev_type], dev_type
|
||||
)
|
||||
|
||||
# Get VEDO alarm data if vedo_pin is configured
|
||||
if self.vedo_pin:
|
||||
data.update(await self.api.get_all_areas_and_zones())
|
||||
|
||||
return cast(T, data)
|
||||
return data
|
||||
|
||||
|
||||
class ComelitVedoSystem(ComelitBaseCoordinator[T]):
|
||||
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
|
||||
"""Queries Comelit VEDO system."""
|
||||
|
||||
_hw_version = "VEDO IP"
|
||||
@@ -204,21 +196,20 @@ class ComelitVedoSystem(ComelitBaseCoordinator[T]):
|
||||
) -> None:
|
||||
"""Initialize the scanner."""
|
||||
self.api = ComelitVedoApi(host, port, pin, session)
|
||||
self.vedo_pin = pin
|
||||
super().__init__(hass, entry, VEDO, host)
|
||||
|
||||
async def _async_update_system_data(
|
||||
self,
|
||||
) -> T:
|
||||
) -> AlarmDataObject:
|
||||
"""Specific method for updating data."""
|
||||
data = await self.api.get_all_areas_and_zones()
|
||||
|
||||
if self.data:
|
||||
for obj_type in (ALARM_AREA, ALARM_ZONE):
|
||||
for obj_type in ("alarm_areas", "alarm_zones"):
|
||||
await self._async_remove_stale_devices(
|
||||
self.data[obj_type],
|
||||
data[obj_type],
|
||||
"area" if obj_type == ALARM_AREA else "zone",
|
||||
"area" if obj_type == "alarm_areas" else "zone",
|
||||
)
|
||||
|
||||
return cast(T, data)
|
||||
return data
|
||||
|
||||
@@ -72,7 +72,7 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
@property
|
||||
def device_status(self) -> int:
|
||||
"""Return current device status."""
|
||||
return cast("int", self.coordinator.data[COVER][self._device.index].status)
|
||||
return self.coordinator.data[COVER][self._device.index].status
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
@@ -86,7 +86,7 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
@property
|
||||
def is_closing(self) -> bool:
|
||||
"""Return if the cover is closing."""
|
||||
return bool(self._current_action("closing"))
|
||||
return self._current_action("closing")
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool:
|
||||
|
||||
@@ -68,4 +68,4 @@ class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if light is on."""
|
||||
return bool(self.coordinator.data[LIGHT][self._device.index].status == STATE_ON)
|
||||
return self.coordinator.data[LIGHT][self._device.index].status == STATE_ON
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.0"]
|
||||
"requirements": ["aiocomelit==1.1.2"]
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
from typing import Final, cast
|
||||
|
||||
from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject
|
||||
from aiocomelit.const import ALARM_ZONE, OTHER, AlarmZoneState
|
||||
from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import UnitOfPower
|
||||
from homeassistant.const import CONF_TYPE, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -52,20 +52,23 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Comelit sensors."""
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
is_bridge = isinstance(coordinator, ComelitSerialBridge)
|
||||
if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE:
|
||||
await async_setup_bridge_entry(hass, config_entry, async_add_entities)
|
||||
else:
|
||||
await async_setup_vedo_entry(hass, config_entry, async_add_entities)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
if is_bridge:
|
||||
assert isinstance(coordinator, ComelitSerialBridge)
|
||||
else:
|
||||
assert isinstance(coordinator, ComelitVedoSystem)
|
||||
|
||||
def _add_new_bridge_entities(
|
||||
new_devices: list[ObjectClassType], dev_type: str
|
||||
) -> None:
|
||||
async def async_setup_bridge_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ComelitConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Comelit Bridge sensors."""
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
assert isinstance(coordinator, ComelitSerialBridge)
|
||||
entities = [
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
@@ -77,32 +80,36 @@ async def async_setup_entry(
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
def _add_new_vedo_entities(
|
||||
new_devices: list[ObjectClassType], dev_type: str
|
||||
) -> None:
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, OTHER)
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_vedo_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ComelitConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Comelit VEDO sensors."""
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
def _add_new_entities(new_devices: list[ObjectClassType], dev_type: str) -> None:
|
||||
"""Add entities for new monitors."""
|
||||
entities = [
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
for device in coordinator.data[dev_type].values()
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device in new_devices
|
||||
]
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
# Bridge native sensors
|
||||
if is_bridge:
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_bridge_entities, OTHER)
|
||||
)
|
||||
|
||||
# Alarm sensors (both via Bridge or VedoSystem)
|
||||
if coordinator.vedo_pin:
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_vedo_entities, ALARM_ZONE)
|
||||
)
|
||||
config_entry.async_on_unload(
|
||||
new_device_listener(coordinator, _add_new_entities, "alarm_zones")
|
||||
)
|
||||
|
||||
|
||||
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
|
||||
@@ -134,16 +141,14 @@ class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
|
||||
)
|
||||
|
||||
|
||||
class ComelitVedoSensorEntity(
|
||||
CoordinatorEntity[ComelitVedoSystem | ComelitSerialBridge], SensorEntity
|
||||
):
|
||||
class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity):
|
||||
"""Sensor device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ComelitVedoSystem | ComelitSerialBridge,
|
||||
coordinator: ComelitVedoSystem,
|
||||
zone: ComelitVedoZoneObject,
|
||||
config_entry_entry_id: str,
|
||||
description: SensorEntityDescription,
|
||||
@@ -161,9 +166,7 @@ class ComelitVedoSensorEntity(
|
||||
@property
|
||||
def _zone_object(self) -> ComelitVedoZoneObject:
|
||||
"""Zone object."""
|
||||
return cast(
|
||||
ComelitVedoZoneObject, self.coordinator.data[ALARM_ZONE][self._zone_index]
|
||||
)
|
||||
return self.coordinator.data["alarm_zones"][self._zone_index]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.",
|
||||
"invalid_vedo_auth": "The provided VEDO PIN is incorrect or VEDO alarm is not enabled on this device.",
|
||||
"invalid_vedo_pin": "The provided VEDO PIN is invalid. It must be a 4-10 digit number.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
@@ -15,34 +13,28 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]",
|
||||
"invalid_vedo_auth": "[%key:component::comelit::config::abort::invalid_vedo_auth%]",
|
||||
"invalid_vedo_pin": "[%key:component::comelit::config::abort::invalid_vedo_pin%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{host}",
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
"vedo_pin": "[%key:component::comelit::config::step::user::data::vedo_pin%]"
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
},
|
||||
"data_description": {
|
||||
"pin": "The PIN of your Comelit device.",
|
||||
"vedo_pin": "[%key:component::comelit::config::step::user::data_description::vedo_pin%]"
|
||||
"pin": "The PIN of your Comelit device."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"vedo_pin": "[%key:component::comelit::config::step::user::data::vedo_pin%]"
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::comelit::config::step::user::data_description::host%]",
|
||||
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
|
||||
"port": "[%key:component::comelit::config::step::user::data_description::port%]",
|
||||
"vedo_pin": "[%key:component::comelit::config::step::user::data_description::vedo_pin%]"
|
||||
"port": "[%key:component::comelit::config::step::user::data_description::port%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -50,15 +42,13 @@
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"type": "Device type",
|
||||
"vedo_pin": "VEDO alarm PIN (optional)"
|
||||
"type": "Device type"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Comelit device.",
|
||||
"pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
|
||||
"port": "The port of your Comelit device.",
|
||||
"type": "The type of your Comelit device.",
|
||||
"vedo_pin": "Optional PIN for VEDO alarm system on Serial Bridge devices. Leave empty if you don't have VEDO alarm enabled."
|
||||
"type": "The type of your Comelit device."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if switch is on."""
|
||||
return bool(
|
||||
return (
|
||||
self.coordinator.data[self._device.type][self._device.index].status
|
||||
== STATE_ON
|
||||
)
|
||||
|
||||
@@ -66,7 +66,6 @@ async def async_setup_entry(
|
||||
name="light",
|
||||
update_method=async_update_data_non_dimmer,
|
||||
update_interval=timedelta(seconds=runtime_data.scan_interval),
|
||||
config_entry=entry,
|
||||
)
|
||||
dimmer_coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
|
||||
hass,
|
||||
@@ -74,7 +73,6 @@ async def async_setup_entry(
|
||||
name="light",
|
||||
update_method=async_update_data_dimmer,
|
||||
update_interval=timedelta(seconds=runtime_data.scan_interval),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
|
||||
@@ -110,7 +110,6 @@ async def async_setup_entry(
|
||||
name="room",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=scan_interval),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2025.12.2"]
|
||||
}
|
||||
|
||||
@@ -14,12 +14,7 @@ from .const import DOMAIN
|
||||
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
|
||||
from .helpers import cookidoo_from_config_entry
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.CALENDAR,
|
||||
Platform.SENSOR,
|
||||
Platform.TODO,
|
||||
]
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.TODO]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Calendar platform for the Cookidoo integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
|
||||
from cookidoo_api import CookidooAuthException, CookidooException
|
||||
from cookidoo_api.types import CookidooCalendarDayRecipe
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
|
||||
from .entity import CookidooBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: CookidooConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the calendar platform for entity."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities([CookidooCalendarEntity(coordinator)])
|
||||
|
||||
|
||||
def recipe_to_event(day_date: date, recipe: CookidooCalendarDayRecipe) -> CalendarEvent:
|
||||
"""Convert a Cookidoo recipe to a CalendarEvent."""
|
||||
return CalendarEvent(
|
||||
start=day_date,
|
||||
end=day_date + timedelta(days=1), # All-day event
|
||||
summary=recipe.name,
|
||||
description=f"Total Time: {recipe.total_time}",
|
||||
)
|
||||
|
||||
|
||||
class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
|
||||
"""A calendar entity."""
|
||||
|
||||
_attr_translation_key = "meal_plan"
|
||||
|
||||
def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
assert coordinator.config_entry.unique_id
|
||||
self._attr_unique_id = coordinator.config_entry.unique_id
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
if not self.coordinator.data.week_plan:
|
||||
return None
|
||||
|
||||
today = date.today()
|
||||
for day_data in self.coordinator.data.week_plan:
|
||||
day_date = date.fromisoformat(day_data.id)
|
||||
if day_date >= today and day_data.recipes:
|
||||
recipe = day_data.recipes[0]
|
||||
return recipe_to_event(day_date, recipe)
|
||||
return None
|
||||
|
||||
async def _fetch_week_plan(self, week_day: date) -> list:
|
||||
"""Fetch a single Cookidoo week plan, retrying once on auth failure."""
|
||||
try:
|
||||
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
|
||||
week_day
|
||||
)
|
||||
except CookidooAuthException:
|
||||
await self.coordinator.cookidoo.refresh_token()
|
||||
return await self.coordinator.cookidoo.get_recipes_in_calendar_week(
|
||||
week_day
|
||||
)
|
||||
except CookidooException as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="calendar_fetch_failed",
|
||||
) from e
|
||||
|
||||
async def async_get_events(
|
||||
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
|
||||
) -> list[CalendarEvent]:
|
||||
"""Get all events in a specific time frame."""
|
||||
events: list[CalendarEvent] = []
|
||||
current_day = start_date.date()
|
||||
while current_day <= end_date.date():
|
||||
week_plan = await self._fetch_week_plan(current_day)
|
||||
for day_data in week_plan:
|
||||
day_date = date.fromisoformat(day_data.id)
|
||||
if start_date.date() <= day_date <= end_date.date():
|
||||
events.extend(
|
||||
recipe_to_event(day_date, recipe) for recipe in day_data.recipes
|
||||
)
|
||||
current_day += timedelta(days=7) # Move to the next week
|
||||
return events
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from cookidoo_api import (
|
||||
@@ -16,7 +16,6 @@ from cookidoo_api import (
|
||||
CookidooSubscription,
|
||||
CookidooUserInfo,
|
||||
)
|
||||
from cookidoo_api.types import CookidooCalendarDay
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL
|
||||
@@ -38,7 +37,6 @@ class CookidooData:
|
||||
ingredient_items: list[CookidooIngredientItem]
|
||||
additional_items: list[CookidooAdditionalItem]
|
||||
subscription: CookidooSubscription | None
|
||||
week_plan: list[CookidooCalendarDay]
|
||||
|
||||
|
||||
class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
@@ -83,7 +81,6 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
ingredient_items = await self.cookidoo.get_ingredient_items()
|
||||
additional_items = await self.cookidoo.get_additional_items()
|
||||
subscription = await self.cookidoo.get_active_subscription()
|
||||
week_plan = await self.cookidoo.get_recipes_in_calendar_week(date.today())
|
||||
except CookidooAuthException:
|
||||
try:
|
||||
await self.cookidoo.refresh_token()
|
||||
@@ -109,5 +106,4 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
|
||||
ingredient_items=ingredient_items,
|
||||
additional_items=additional_items,
|
||||
subscription=subscription,
|
||||
week_plan=week_plan,
|
||||
)
|
||||
|
||||
@@ -54,11 +54,6 @@
|
||||
"name": "Clear shopping list and additional purchases"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"meal_plan": {
|
||||
"name": "Meal plan"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"expires": {
|
||||
"name": "Subscription expiration date"
|
||||
@@ -85,9 +80,6 @@
|
||||
"button_clear_todo_failed": {
|
||||
"message": "Failed to clear all items from the Cookidoo shopping list"
|
||||
},
|
||||
"calendar_fetch_failed": {
|
||||
"message": "Failed to fetch Cookidoo meal plan"
|
||||
},
|
||||
"setup_authentication_exception": {
|
||||
"message": "Authentication failed for {email}, check your email and password"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.46.2", "getmac==0.9.5"],
|
||||
"requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["async-upnp-client==0.46.2"],
|
||||
"requirements": ["async-upnp-client==0.46.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Duck DNS integration."""
|
||||
"""Integrate with DuckDNS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,16 +8,25 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import ATTR_CONFIG_ENTRY
|
||||
from .coordinator import DuckDnsConfigEntry, DuckDnsUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
from .helpers import update_duckdns
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_TXT = "txt"
|
||||
|
||||
DOMAIN = "duckdns"
|
||||
|
||||
SERVICE_SET_TXT = "set_txt"
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -31,11 +40,27 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SERVICE_TXT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector(
|
||||
{
|
||||
"integration": DOMAIN,
|
||||
}
|
||||
),
|
||||
vol.Optional(ATTR_TXT): vol.Any(None, cv.string),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Initialize the DuckDNS component."""
|
||||
|
||||
async_setup_services(hass)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_TXT,
|
||||
update_domain_service,
|
||||
schema=SERVICE_TXT_SCHEMA,
|
||||
)
|
||||
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
@@ -62,6 +87,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> b
|
||||
return True
|
||||
|
||||
|
||||
def get_config_entry(
|
||||
hass: HomeAssistant, entry_id: str | None = None
|
||||
) -> DuckDnsConfigEntry:
|
||||
"""Return config entry or raise if not found or not loaded."""
|
||||
|
||||
if entry_id is None:
|
||||
if not (config_entries := hass.config_entries.async_entries(DOMAIN)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
if len(config_entries) != 1:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_selected",
|
||||
)
|
||||
return config_entries[0]
|
||||
|
||||
if not (entry := hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
async def update_domain_service(call: ServiceCall) -> None:
|
||||
"""Update the DuckDNS entry."""
|
||||
|
||||
entry = get_config_entry(call.hass, call.data.get(ATTR_CONFIG_ENTRY))
|
||||
|
||||
session = async_get_clientsession(call.hass)
|
||||
|
||||
await update_duckdns(
|
||||
session,
|
||||
entry.data[CONF_DOMAIN],
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
txt=call.data.get(ATTR_TXT),
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DuckDnsConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
|
||||
@@ -5,5 +5,3 @@ from typing import Final
|
||||
DOMAIN = "duckdns"
|
||||
|
||||
ATTR_CONFIG_ENTRY: Final = "config_entry_id"
|
||||
ATTR_TXT: Final = "txt"
|
||||
SERVICE_SET_TXT = "set_txt"
|
||||
|
||||
@@ -4,6 +4,5 @@
|
||||
"codeowners": ["@tr4nt0r"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/duckdns",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Actions for Duck DNS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import ClientError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import ConfigEntrySelector
|
||||
|
||||
from .const import ATTR_CONFIG_ENTRY, ATTR_TXT, DOMAIN, SERVICE_SET_TXT
|
||||
from .coordinator import DuckDnsConfigEntry
|
||||
from .helpers import update_duckdns
|
||||
|
||||
SERVICE_TXT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
|
||||
vol.Optional(ATTR_TXT): vol.Any(None, cv.string),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for Habitica integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_TXT,
|
||||
update_domain_service,
|
||||
schema=SERVICE_TXT_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def get_config_entry(
|
||||
hass: HomeAssistant, entry_id: str | None = None
|
||||
) -> DuckDnsConfigEntry:
|
||||
"""Return config entry or raise if not found or not loaded."""
|
||||
|
||||
if entry_id is None:
|
||||
if len(entries := hass.config_entries.async_entries(DOMAIN)) != 1:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_selected",
|
||||
)
|
||||
return entries[0]
|
||||
if not (entry := hass.config_entries.async_get_entry(entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_not_found",
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
async def update_domain_service(call: ServiceCall) -> None:
|
||||
"""Update the DuckDNS entry."""
|
||||
|
||||
entry = get_config_entry(call.hass, call.data.get(ATTR_CONFIG_ENTRY))
|
||||
|
||||
session = async_get_clientsession(call.hass)
|
||||
|
||||
try:
|
||||
if not await update_duckdns(
|
||||
session,
|
||||
entry.data[CONF_DOMAIN],
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
txt=call.data.get(ATTR_TXT),
|
||||
):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={
|
||||
CONF_DOMAIN: entry.data[CONF_DOMAIN],
|
||||
},
|
||||
)
|
||||
except ClientError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
translation_placeholders={
|
||||
CONF_DOMAIN: entry.data[CONF_DOMAIN],
|
||||
},
|
||||
) from e
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from aioecowitt import EcoWittSensor, EcoWittSensorTypes
|
||||
@@ -40,9 +39,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
|
||||
from . import EcowittConfigEntry
|
||||
from .entity import EcowittEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_METRIC: Final = (
|
||||
EcoWittSensorTypes.TEMPERATURE_C,
|
||||
EcoWittSensorTypes.RAIN_COUNT_MM,
|
||||
@@ -61,40 +57,6 @@ _IMPERIAL: Final = (
|
||||
)
|
||||
|
||||
|
||||
_RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING: Final = {
|
||||
"eventrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"hourlyrainin": None,
|
||||
"totalrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"dailyrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"weeklyrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"monthlyrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"yearlyrainin": SensorStateClass.TOTAL_INCREASING,
|
||||
"last24hrainin": None,
|
||||
"eventrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"hourlyrainmm": None,
|
||||
"totalrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"dailyrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"weeklyrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"monthlyrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"yearlyrainmm": SensorStateClass.TOTAL_INCREASING,
|
||||
"last24hrainmm": None,
|
||||
"erain_piezo": SensorStateClass.TOTAL_INCREASING,
|
||||
"hrain_piezo": None,
|
||||
"drain_piezo": SensorStateClass.TOTAL_INCREASING,
|
||||
"wrain_piezo": SensorStateClass.TOTAL_INCREASING,
|
||||
"mrain_piezo": SensorStateClass.TOTAL_INCREASING,
|
||||
"yrain_piezo": SensorStateClass.TOTAL_INCREASING,
|
||||
"last24hrain_piezo": None,
|
||||
"erain_piezomm": SensorStateClass.TOTAL_INCREASING,
|
||||
"hrain_piezomm": None,
|
||||
"drain_piezomm": SensorStateClass.TOTAL_INCREASING,
|
||||
"wrain_piezomm": SensorStateClass.TOTAL_INCREASING,
|
||||
"mrain_piezomm": SensorStateClass.TOTAL_INCREASING,
|
||||
"yrain_piezomm": SensorStateClass.TOTAL_INCREASING,
|
||||
"last24hrain_piezomm": None,
|
||||
}
|
||||
|
||||
|
||||
ECOWITT_SENSORS_MAPPING: Final = {
|
||||
EcoWittSensorTypes.HUMIDITY: SensorEntityDescription(
|
||||
key="HUMIDITY",
|
||||
@@ -323,15 +285,15 @@ async def async_setup_entry(
|
||||
name=sensor.name,
|
||||
)
|
||||
|
||||
if sensor.stype in (
|
||||
EcoWittSensorTypes.RAIN_COUNT_INCHES,
|
||||
EcoWittSensorTypes.RAIN_COUNT_MM,
|
||||
# Only total rain needs state class for long-term statistics
|
||||
if sensor.key in (
|
||||
"totalrainin",
|
||||
"totalrainmm",
|
||||
):
|
||||
if sensor.key not in _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING:
|
||||
_LOGGER.warning("Unknown rain count sensor: %s", sensor.key)
|
||||
return
|
||||
state_class = _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING[sensor.key]
|
||||
description = dataclasses.replace(description, state_class=state_class)
|
||||
description = dataclasses.replace(
|
||||
description,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
)
|
||||
|
||||
async_add_entities([EcowittSensorEntity(sensor, description)])
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.5.0"],
|
||||
"requirements": ["eheimdigital==1.4.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||
]
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from collections.abc import AsyncIterable
|
||||
from io import BytesIO
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
from elevenlabs.core import ApiError
|
||||
@@ -181,17 +180,15 @@ class ElevenLabsSTTEntity(SpeechToTextEntity):
|
||||
)
|
||||
|
||||
try:
|
||||
kwargs: dict[str, Any] = {
|
||||
"file": BytesIO(audio),
|
||||
"file_format": file_format,
|
||||
"model_id": self._stt_model,
|
||||
"tag_audio_events": False,
|
||||
"num_speakers": 1,
|
||||
"diarize": False,
|
||||
}
|
||||
if lang_code is not None:
|
||||
kwargs["language_code"] = lang_code
|
||||
response = await self._client.speech_to_text.convert(**kwargs)
|
||||
response = await self._client.speech_to_text.convert(
|
||||
file=BytesIO(audio),
|
||||
file_format=file_format,
|
||||
model_id=self._stt_model,
|
||||
language_code=lang_code,
|
||||
tag_audio_events=False,
|
||||
num_speakers=1,
|
||||
diarize=False,
|
||||
)
|
||||
except ApiError as exc:
|
||||
_LOGGER.error("Error during processing of STT request: %s", exc)
|
||||
return stt.SpeechResult(None, SpeechResultState.ERROR)
|
||||
|
||||
@@ -620,7 +620,6 @@ ENCHARGE_INVENTORY_SENSORS = (
|
||||
EnvoyEnchargeSensorEntityDescription(
|
||||
key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
value_fn=attrgetter("temperature"),
|
||||
),
|
||||
@@ -635,7 +634,6 @@ ENCHARGE_INVENTORY_SENSORS = (
|
||||
ENCHARGE_POWER_SENSORS = (
|
||||
EnvoyEnchargePowerSensorEntityDescription(
|
||||
key="soc",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
value_fn=attrgetter("soc"),
|
||||
@@ -643,14 +641,12 @@ ENCHARGE_POWER_SENSORS = (
|
||||
EnvoyEnchargePowerSensorEntityDescription(
|
||||
key="apparent_power_mva",
|
||||
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.APPARENT_POWER,
|
||||
value_fn=lambda encharge: encharge.apparent_power_mva * 0.001,
|
||||
),
|
||||
EnvoyEnchargePowerSensorEntityDescription(
|
||||
key="real_power_mw",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
value_fn=lambda encharge: encharge.real_power_mw * 0.001,
|
||||
),
|
||||
@@ -668,7 +664,6 @@ ENPOWER_SENSORS = (
|
||||
EnvoyEnpowerSensorEntityDescription(
|
||||
key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
value_fn=attrgetter("temperature"),
|
||||
),
|
||||
@@ -698,7 +693,6 @@ COLLAR_SENSORS = (
|
||||
EnvoyCollarSensorEntityDescription(
|
||||
key="temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
value_fn=attrgetter("temperature"),
|
||||
),
|
||||
@@ -766,7 +760,6 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
EnvoyEnchargeAggregateSensorEntityDescription(
|
||||
key="battery_level",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
value_fn=attrgetter("state_of_charge"),
|
||||
),
|
||||
@@ -774,7 +767,6 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
key="reserve_soc",
|
||||
translation_key="reserve_soc",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
value_fn=attrgetter("reserve_state_of_charge"),
|
||||
),
|
||||
@@ -782,7 +774,6 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
key="available_energy",
|
||||
translation_key="available_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
value_fn=attrgetter("available_energy"),
|
||||
),
|
||||
@@ -790,7 +781,6 @@ ENCHARGE_AGGREGATE_SENSORS = (
|
||||
key="reserve_energy",
|
||||
translation_key="reserve_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
value_fn=attrgetter("backup_reserve"),
|
||||
),
|
||||
@@ -815,14 +805,12 @@ ACB_BATTERY_POWER_SENSORS = (
|
||||
EnvoyAcbBatterySensorEntityDescription(
|
||||
key="acb_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
value_fn=attrgetter("power"),
|
||||
),
|
||||
EnvoyAcbBatterySensorEntityDescription(
|
||||
key="acb_soc",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
value_fn=attrgetter("state_of_charge"),
|
||||
),
|
||||
@@ -840,7 +828,6 @@ ACB_BATTERY_ENERGY_SENSORS = (
|
||||
key="acb_available_energy",
|
||||
translation_key="acb_available_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.ENERGY_STORAGE,
|
||||
value_fn=attrgetter("charge_wh"),
|
||||
),
|
||||
@@ -858,7 +845,6 @@ AGGREGATE_BATTERY_SENSORS = (
|
||||
EnvoyAggregateBatterySensorEntityDescription(
|
||||
key="aggregated_soc",
|
||||
translation_key="aggregated_soc",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
value_fn=attrgetter("state_of_charge"),
|
||||
@@ -867,7 +853,6 @@ AGGREGATE_BATTERY_SENSORS = (
|
||||
key="aggregated_available_energy",
|
||||
translation_key="aggregated_available_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.ENERGY_STORAGE,
|
||||
value_fn=attrgetter("available_energy"),
|
||||
),
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.10.1",
|
||||
"aioesphomeapi==43.6.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -9,12 +9,14 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FeedReaderConfigEntry, FeedReaderCoordinator, StoredData
|
||||
|
||||
FEEDREADER_KEY: HassKey[StoredData] = HassKey(DOMAIN)
|
||||
CONF_URLS = "urls"
|
||||
|
||||
MY_KEY: HassKey[StoredData] = HassKey(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -> bool:
|
||||
"""Set up Feedreader from a config entry."""
|
||||
storage = hass.data.setdefault(FEEDREADER_KEY, StoredData(hass))
|
||||
storage = hass.data.setdefault(MY_KEY, StoredData(hass))
|
||||
if not storage.is_initialized:
|
||||
await storage.async_setup()
|
||||
|
||||
@@ -40,5 +42,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry)
|
||||
)
|
||||
# if this is the last entry, remove the storage
|
||||
if len(entries) == 1:
|
||||
hass.data.pop(FEEDREADER_KEY)
|
||||
hass.data.pop(MY_KEY)
|
||||
return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT])
|
||||
|
||||
@@ -19,9 +19,6 @@ from .coordinator import FeedReaderCoordinator
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
ATTR_CONTENT = "content"
|
||||
ATTR_DESCRIPTION = "description"
|
||||
ATTR_LINK = "link"
|
||||
@@ -45,15 +42,16 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity):
|
||||
_attr_event_types = [EVENT_FEEDREADER]
|
||||
_attr_name = None
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "latest_feed"
|
||||
_unrecorded_attributes = frozenset(
|
||||
{ATTR_CONTENT, ATTR_DESCRIPTION, ATTR_TITLE, ATTR_LINK}
|
||||
)
|
||||
coordinator: FeedReaderCoordinator
|
||||
|
||||
def __init__(self, coordinator: FeedReaderCoordinator) -> None:
|
||||
"""Initialize the feedreader event."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_latest_feed"
|
||||
self._attr_translation_key = "latest_feed"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
|
||||
name=coordinator.config_entry.title,
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No custom actions are defined.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: missing test for uniqueness of feed URL.
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: missing data descriptions
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: No custom actions are defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: No custom actions are defined.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: No authentication support.
|
||||
test-coverage:
|
||||
status: done
|
||||
comment: Can use freezer for skipping time instead
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No discovery support.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: No discovery support.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Each config entry, represents one service.
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: Matches no available event entity class.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Only one entity per config entry.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: done
|
||||
comment: Only one repair-issue for yaml-import defined.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Each config entry, represents one service.
|
||||
|
||||
# Platinum
|
||||
async-dependency:
|
||||
status: todo
|
||||
comment: feedparser lib is not async.
|
||||
inject-websession:
|
||||
status: todo
|
||||
comment: feedparser lib doesn't take a session as argument.
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: feedparser lib is not fully typed.
|
||||
@@ -21,6 +21,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"import_yaml_error_url_error": {
|
||||
"description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that the URL is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually.",
|
||||
"title": "The Feedreader YAML configuration import failed"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfirefly==0.1.10"]
|
||||
"requirements": ["pyfirefly==0.1.8"]
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
"""The Fish Audio integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fishaudio import AsyncFishAudio
|
||||
from fishaudio.exceptions import AuthenticationError, FishAudioError
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_API_KEY
|
||||
from .types import FishAudioConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.TTS]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FishAudioConfigEntry) -> bool:
|
||||
"""Set up Fish Audio from a config entry."""
|
||||
client = AsyncFishAudio(api_key=entry.data[CONF_API_KEY])
|
||||
|
||||
try:
|
||||
# Validate API key by getting account credits.
|
||||
await client.account.get_credits()
|
||||
except AuthenticationError as exc:
|
||||
raise ConfigEntryAuthFailed(f"Invalid API key: {exc}") from exc
|
||||
except FishAudioError as exc:
|
||||
raise ConfigEntryNotReady(f"Error connecting to Fish Audio: {exc}") from exc
|
||||
|
||||
entry.runtime_data = client
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: FishAudioConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FishAudioConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,352 +0,0 @@
|
||||
"""Config flow for the Fish Audio integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fishaudio import AsyncFishAudio
|
||||
from fishaudio.exceptions import AuthenticationError, FishAudioError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_USER,
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.selector import (
|
||||
LanguageSelector,
|
||||
LanguageSelectorConfig,
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
API_KEYS_URL,
|
||||
BACKEND_MODELS,
|
||||
CONF_API_KEY,
|
||||
CONF_BACKEND,
|
||||
CONF_LANGUAGE,
|
||||
CONF_LATENCY,
|
||||
CONF_NAME,
|
||||
CONF_SELF_ONLY,
|
||||
CONF_SORT_BY,
|
||||
CONF_TITLE,
|
||||
CONF_USER_ID,
|
||||
CONF_VOICE_ID,
|
||||
DOMAIN,
|
||||
LATENCY_OPTIONS,
|
||||
SIGNUP_URL,
|
||||
SORT_BY_OPTIONS,
|
||||
TTS_SUPPORTED_LANGUAGES,
|
||||
)
|
||||
from .error import (
|
||||
CannotConnectError,
|
||||
CannotGetModelsError,
|
||||
InvalidAuthError,
|
||||
UnexpectedError,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_api_key_schema(default: str | None = None) -> vol.Schema:
|
||||
"""Return the schema for API key input."""
|
||||
return vol.Schema(
|
||||
{vol.Required(CONF_API_KEY, default=default or vol.UNDEFINED): str}
|
||||
)
|
||||
|
||||
|
||||
def get_filter_schema(options: dict[str, Any]) -> vol.Schema:
|
||||
"""Return the schema for the filter step."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_TITLE, default=options.get(CONF_TITLE, "")): str,
|
||||
vol.Optional(
|
||||
CONF_LANGUAGE, default=options.get(CONF_LANGUAGE, "Any")
|
||||
): LanguageSelector(
|
||||
LanguageSelectorConfig(
|
||||
languages=TTS_SUPPORTED_LANGUAGES,
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_SORT_BY, default=options.get(CONF_SORT_BY, "task_count")
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=SORT_BY_OPTIONS,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="sort_by",
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_SELF_ONLY, default=options.get(CONF_SELF_ONLY, False)
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_model_selection_schema(
|
||||
options: dict[str, Any],
|
||||
model_options: list[SelectOptionDict],
|
||||
) -> vol.Schema:
|
||||
"""Return the schema for the model selection step."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_VOICE_ID,
|
||||
default=options.get(CONF_VOICE_ID, ""),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=model_options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
custom_value=True,
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_BACKEND,
|
||||
default=options.get(CONF_BACKEND, "s1"),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(value=opt, label=opt) for opt in BACKEND_MODELS
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_LATENCY,
|
||||
default=options.get(CONF_LATENCY, "balanced"),
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(value=opt, label=opt)
|
||||
for opt in LATENCY_OPTIONS
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_NAME,
|
||||
default=options.get(CONF_NAME) or vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _validate_api_key(
|
||||
hass: HomeAssistant, api_key: str
|
||||
) -> tuple[str, AsyncFishAudio]:
|
||||
"""Validate the user input allows us to connect."""
|
||||
client = AsyncFishAudio(api_key=api_key)
|
||||
|
||||
try:
|
||||
# Validate API key and get user info
|
||||
credit_info = await client.account.get_credits()
|
||||
user_id = credit_info.user_id
|
||||
except AuthenticationError as exc:
|
||||
raise InvalidAuthError(exc) from exc
|
||||
except FishAudioError as exc:
|
||||
raise CannotConnectError(exc) from exc
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(exc) from exc
|
||||
|
||||
return user_id, client
|
||||
|
||||
|
||||
class FishAudioConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Fish Audio."""
|
||||
|
||||
VERSION = 1
|
||||
client: AsyncFishAudio | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.client = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=get_api_key_schema(),
|
||||
errors={},
|
||||
description_placeholders={"signup_url": SIGNUP_URL},
|
||||
)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
user_id, self.client = await _validate_api_key(
|
||||
self.hass, user_input[CONF_API_KEY]
|
||||
)
|
||||
except InvalidAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotConnectError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except UnexpectedError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
data: dict[str, Any] = {
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
CONF_USER_ID: user_id,
|
||||
}
|
||||
|
||||
return self.async_create_entry(
|
||||
title="Fish Audio",
|
||||
data=data,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=get_api_key_schema(),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"signup_url": SIGNUP_URL,
|
||||
"api_keys_url": API_KEYS_URL,
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"tts": FishAudioSubentryFlowHandler}
|
||||
|
||||
|
||||
class FishAudioSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for adding and modifying a tts entity."""
|
||||
|
||||
config_data: dict[str, Any]
|
||||
models: list[SelectOptionDict]
|
||||
client: AsyncFishAudio
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow handler."""
|
||||
super().__init__()
|
||||
self.models: list[SelectOptionDict] = []
|
||||
|
||||
async def _async_get_models(
|
||||
self, self_only: bool, language: str | None, title: str | None, sort_by: str
|
||||
) -> list[SelectOptionDict]:
|
||||
"""Get the available models."""
|
||||
try:
|
||||
voices_response = await self.client.voices.list(
|
||||
self_only=self_only,
|
||||
language=language
|
||||
if language and language.strip() and language != "Any"
|
||||
else None,
|
||||
title=title if title and title.strip() else None,
|
||||
sort_by=sort_by,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise CannotGetModelsError(exc) from exc
|
||||
|
||||
voices = voices_response.items
|
||||
|
||||
return [
|
||||
SelectOptionDict(
|
||||
value=voice.id,
|
||||
label=f"{voice.title} - {voice.task_count} uses",
|
||||
)
|
||||
for voice in voices
|
||||
]
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the initial step."""
|
||||
self.config_data = {}
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle reconfiguration of a subentry."""
|
||||
self.config_data = dict(self._get_reconfigure_subentry().data)
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Manage initial options."""
|
||||
entry = self._get_entry()
|
||||
if entry.state != ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
|
||||
self.client = entry.runtime_data
|
||||
|
||||
if user_input is not None:
|
||||
self.config_data.update(user_input)
|
||||
return await self.async_step_model()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=get_filter_schema(self.config_data),
|
||||
errors={},
|
||||
)
|
||||
|
||||
async def async_step_model(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the model selection step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if not self.models:
|
||||
try:
|
||||
self.models = await self._async_get_models(
|
||||
self_only=self.config_data.get(CONF_SELF_ONLY, False),
|
||||
language=self.config_data.get(CONF_LANGUAGE),
|
||||
title=self.config_data.get(CONF_TITLE),
|
||||
sort_by=self.config_data.get(CONF_SORT_BY, "task_count"),
|
||||
)
|
||||
except CannotGetModelsError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
if not self.models:
|
||||
return self.async_abort(reason="no_models_found")
|
||||
|
||||
if CONF_VOICE_ID not in self.config_data and self.models:
|
||||
self.config_data[CONF_VOICE_ID] = self.models[0]["value"]
|
||||
|
||||
if user_input is not None:
|
||||
if (
|
||||
(voice_id := user_input.get(CONF_VOICE_ID))
|
||||
and (backend := user_input.get(CONF_BACKEND))
|
||||
and (name := user_input.get(CONF_NAME))
|
||||
):
|
||||
self.config_data.update(user_input)
|
||||
unique_id = f"{voice_id}-{backend}"
|
||||
|
||||
if self.source == SOURCE_USER:
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data=self.config_data,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
data=self.config_data,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="model",
|
||||
data_schema=get_model_selection_schema(self.config_data, self.models),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Constants for the FishAudio integration."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
DOMAIN = "fish_audio"
|
||||
|
||||
|
||||
CONF_NAME: Literal["name"] = "name"
|
||||
CONF_USER_ID: Literal["user_id"] = "user_id"
|
||||
CONF_API_KEY: Literal["api_key"] = "api_key"
|
||||
CONF_VOICE_ID: Literal["voice_id"] = "voice_id"
|
||||
CONF_BACKEND: Literal["backend"] = "backend"
|
||||
CONF_SELF_ONLY: Literal["self_only"] = "self_only"
|
||||
CONF_LANGUAGE: Literal["language"] = "language"
|
||||
CONF_SORT_BY: Literal["sort_by"] = "sort_by"
|
||||
CONF_LATENCY: Literal["latency"] = "latency"
|
||||
CONF_TITLE: Literal["title"] = "title"
|
||||
|
||||
DEVELOPER_ID = "1e9f9baadce144f5b16dd94cbc0314c8"
|
||||
|
||||
TTS_SUPPORTED_LANGUAGES = [
|
||||
"Any",
|
||||
"en",
|
||||
"zh",
|
||||
"de",
|
||||
"ja",
|
||||
"ar",
|
||||
"fr",
|
||||
"es",
|
||||
"ko",
|
||||
]
|
||||
|
||||
|
||||
BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"]
|
||||
SORT_BY_OPTIONS = ["task_count", "score", "created_at"]
|
||||
LATENCY_OPTIONS = ["normal", "balanced"]
|
||||
|
||||
SIGNUP_URL = "https://fish.audio/"
|
||||
BILLING_URL = "https://fish.audio/app/billing/"
|
||||
API_KEYS_URL = "https://fish.audio/app/api-keys/"
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Exceptions for the Fish Audio integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
_LOGGER = logging.getLogger(__package__)
|
||||
|
||||
|
||||
class FishAudioError(HomeAssistantError):
|
||||
"""Base class for Fish Audio errors."""
|
||||
|
||||
|
||||
class CannotConnectError(FishAudioError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
def __init__(self, exc: Exception) -> None:
|
||||
"""Initialize the connection error."""
|
||||
super().__init__("Cannot connect")
|
||||
|
||||
|
||||
class InvalidAuthError(FishAudioError):
|
||||
"""Error to indicate invalid authentication."""
|
||||
|
||||
def __init__(self, exc: Exception) -> None:
|
||||
"""Initialize the invalid auth error."""
|
||||
super().__init__("Invalid authentication")
|
||||
|
||||
|
||||
class CannotGetModelsError(FishAudioError):
|
||||
"""Error to indicate we cannot get models."""
|
||||
|
||||
def __init__(self, exc: Exception) -> None:
|
||||
"""Initialize the model fetch error."""
|
||||
super().__init__("Cannot get models")
|
||||
|
||||
|
||||
class UnexpectedError(FishAudioError):
|
||||
"""Error to indicate an unexpected error."""
|
||||
|
||||
def __init__(self, exc: Exception) -> None:
|
||||
"""Initialize and log the unexpected error."""
|
||||
super().__init__("Unexpected error")
|
||||
_LOGGER.exception("Unexpected exception: %s", exc)
|
||||
|
||||
|
||||
class AlreadyConfiguredError(FishAudioError):
|
||||
"""Error to indicate already configured."""
|
||||
|
||||
def __init__(self, exc: Exception) -> None:
|
||||
"""Initialize the already configured error."""
|
||||
super().__init__("Already configured")
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "fish_audio",
|
||||
"name": "Fish Audio",
|
||||
"codeowners": ["@noambav"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fish_audio",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["fish_audio_sdk"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fish-audio-sdk==1.1.0"]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities in this integration do not subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
config-entry-unloading: done
|
||||
log-when-unavailable: todo
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: TTS platform has no state to mark unavailable.
|
||||
action-exceptions: done
|
||||
reauthentication-flow: todo
|
||||
parallel-updates: done
|
||||
test-coverage: todo
|
||||
integration-owner: done
|
||||
docs-installation-parameters: todo
|
||||
docs-configuration-parameters: todo
|
||||
|
||||
# Gold
|
||||
entity-translations: todo
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: No device class for TTS entities.
|
||||
devices: done
|
||||
entity-category: done
|
||||
entity-disabled-by-default: todo
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: No physical device to discover.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: No physical device.
|
||||
diagnostics: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow:
|
||||
status: todo
|
||||
comment: Could be useful if default voice disappears.
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: No physical device.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No physical device.
|
||||
repair-issues: todo
|
||||
docs-use-cases: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: This integration does not support devices.
|
||||
docs-supported-functions: todo
|
||||
docs-data-update: todo
|
||||
docs-known-limitations: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-examples: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"error": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "Failed to connect, please check your API key and network connection.",
|
||||
"invalid_auth": "Invalid authentication. Please check your API key. You can get your API key from [Fish Audio API Keys]({api_keys_url}).",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "Your personal API key for accessing the Fish Audio service."
|
||||
},
|
||||
"description": "Enter your Fish Audio API key to begin.\n\nIf you don't have an account, you can sign up for a free one on [Fish Audio]({signup_url}).",
|
||||
"title": "Connect to Fish Audio"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"tts": {
|
||||
"abort": {
|
||||
"already_configured": "This TTS voice is already configured.",
|
||||
"cannot_connect": "Failed to connect to Fish Audio",
|
||||
"entry_not_loaded": "Cannot add TTS voice while the configuration is disabled.",
|
||||
"no_models_found": "No voices found matching the specified filters. Please adjust your filters and try again.",
|
||||
"reconfigure_successful": "Your TTS voice has been updated successfully. The integration will now reload with the new settings."
|
||||
},
|
||||
"entry_type": "TTS voice",
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_model_selected": "You must select a voice to continue.",
|
||||
"no_models_found": "No voices found matching the specified filters."
|
||||
},
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure TTS voice",
|
||||
"user": "Add TTS voice"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"language": "Filter by language",
|
||||
"self_only": "Show only my private voices",
|
||||
"sort_by": "Sort voices by",
|
||||
"title": "Filter by name"
|
||||
},
|
||||
"data_description": {
|
||||
"language": "Display only voices that support the selected language.",
|
||||
"self_only": "When checked, this will only show the voices you have personally created or cloned.",
|
||||
"sort_by": "Choose the order in which the voices are displayed.",
|
||||
"title": "Filter voices by name."
|
||||
},
|
||||
"description": "Apply filters to narrow down the voice list, then click Submit to see the results.",
|
||||
"title": "Voice selection filters"
|
||||
},
|
||||
"model": {
|
||||
"data": {
|
||||
"backend": "AI voice model",
|
||||
"latency": "Latency mode",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"voice_id": "Voice"
|
||||
},
|
||||
"data_description": {
|
||||
"backend": "Select the AI model that will generate the audio.",
|
||||
"latency": "Choose the latency mode: 'normal' for standard processing or 'balanced' for optimized speed.",
|
||||
"name": "Enter a unique name for this TTS voice to easily identify it in Home Assistant.",
|
||||
"voice_id": "Choose from the list of available voices, or manually enter a specific voice ID."
|
||||
},
|
||||
"description": "Select your preferred voice and the AI model to use for speech synthesis.",
|
||||
"title": "Choose your voice and model"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"sort_by": {
|
||||
"options": {
|
||||
"created_at": "Newest",
|
||||
"score": "Highest score",
|
||||
"task_count": "Most uses"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"""TTS platform for the Fish Audio integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fishaudio.exceptions import APIError, RateLimitError
|
||||
|
||||
from homeassistant.components.tts import TextToSpeechEntity, TtsAudioType
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FishAudioConfigEntry
|
||||
from .const import (
|
||||
CONF_BACKEND,
|
||||
CONF_LATENCY,
|
||||
CONF_VOICE_ID,
|
||||
DOMAIN,
|
||||
TTS_SUPPORTED_LANGUAGES,
|
||||
)
|
||||
from .error import UnexpectedError
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FishAudioConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Fish Audio TTS platform."""
|
||||
_LOGGER.debug("Setting up Fish Audio TTS platform")
|
||||
|
||||
_LOGGER.debug("Entry: %s", entry)
|
||||
# Iterate over values
|
||||
for subentry in entry.subentries.values():
|
||||
_LOGGER.debug("Subentry: %s", subentry)
|
||||
if subentry.subentry_type != "tts":
|
||||
continue
|
||||
async_add_entities(
|
||||
[FishAudioTTSEntity(entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class FishAudioTTSEntity(TextToSpeechEntity):
|
||||
"""Fish Audio TTS entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_options = [CONF_VOICE_ID, CONF_BACKEND, CONF_LATENCY]
|
||||
|
||||
def __init__(self, entry: FishAudioConfigEntry, sub_entry: ConfigSubentry) -> None:
|
||||
"""Initialize the TTS entity."""
|
||||
self.client = entry.runtime_data
|
||||
self.sub_entry = sub_entry
|
||||
self._attr_unique_id = sub_entry.subentry_id
|
||||
title = sub_entry.title
|
||||
backend = sub_entry.data[CONF_BACKEND]
|
||||
self._attr_name = title
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, sub_entry.subentry_id)},
|
||||
manufacturer="Fish Audio",
|
||||
model=backend,
|
||||
name=title,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
def default_language(self) -> str:
|
||||
"""Return the default language."""
|
||||
return "en"
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
"""Return a list of supported languages."""
|
||||
return TTS_SUPPORTED_LANGUAGES
|
||||
|
||||
async def async_get_tts_audio(
|
||||
self,
|
||||
message: str,
|
||||
language: str,
|
||||
options: dict[str, Any],
|
||||
) -> TtsAudioType:
|
||||
"""Load tts audio file from engine."""
|
||||
|
||||
_LOGGER.debug("Getting TTS audio for %s", message)
|
||||
|
||||
voice_id = options.get(CONF_VOICE_ID, self.sub_entry.data.get(CONF_VOICE_ID))
|
||||
backend = options.get(CONF_BACKEND, self.sub_entry.data.get(CONF_BACKEND))
|
||||
latency = options.get(
|
||||
CONF_LATENCY, self.sub_entry.data.get(CONF_LATENCY, "balanced")
|
||||
)
|
||||
|
||||
if voice_id is None:
|
||||
raise ServiceValidationError("Voice ID not configured")
|
||||
if backend is None:
|
||||
raise ServiceValidationError("Backend model not configured")
|
||||
|
||||
try:
|
||||
audio = await self.client.tts.convert(
|
||||
text=message,
|
||||
reference_id=voice_id,
|
||||
latency=latency,
|
||||
model=backend,
|
||||
format="mp3",
|
||||
)
|
||||
except RateLimitError as err:
|
||||
_LOGGER.error("Fish Audio TTS rate limited: %s", err)
|
||||
raise HomeAssistantError(f"Rate limited: {err}") from err
|
||||
except APIError as err:
|
||||
_LOGGER.error("Fish Audio TTS request failed: %s", err)
|
||||
raise HomeAssistantError(f"TTS request failed: {err}") from err
|
||||
except Exception as err:
|
||||
raise UnexpectedError(err) from err
|
||||
|
||||
return "mp3", audio
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Type definitions for the Fish Audio integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fishaudio import AsyncFishAudio
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
type FishAudioConfigEntry = ConfigEntry[AsyncFishAudio]
|
||||
@@ -1,13 +1,12 @@
|
||||
"""The Fressnapf Tracker integration."""
|
||||
|
||||
from fressnapftracker import AuthClient, FressnapfTrackerAuthenticationError
|
||||
from fressnapftracker import AuthClient
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import CONF_USER_ID, DOMAIN
|
||||
from .const import CONF_USER_ID
|
||||
from .coordinator import (
|
||||
FressnapfTrackerConfigEntry,
|
||||
FressnapfTrackerDataUpdateCoordinator,
|
||||
@@ -27,16 +26,10 @@ async def async_setup_entry(
|
||||
) -> bool:
|
||||
"""Set up Fressnapf Tracker from a config entry."""
|
||||
auth_client = AuthClient(client=get_async_client(hass))
|
||||
try:
|
||||
devices = await auth_client.get_devices(
|
||||
user_id=entry.data[CONF_USER_ID],
|
||||
user_access_token=entry.data[CONF_ACCESS_TOKEN],
|
||||
)
|
||||
except FressnapfTrackerAuthenticationError as exception:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from exception
|
||||
devices = await auth_client.get_devices(
|
||||
user_id=entry.data[CONF_USER_ID],
|
||||
user_access_token=entry.data[CONF_ACCESS_TOKEN],
|
||||
)
|
||||
|
||||
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
|
||||
for device in devices:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Config flow for the Fressnapf Tracker integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -11,12 +10,7 @@ from fressnapftracker import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
@@ -142,43 +136,40 @@ class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_reauth_reconfigure(
|
||||
self,
|
||||
user_input: dict[str, Any] | None,
|
||||
entry: Any,
|
||||
step_id: str,
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Request a new sms code for reauth or reconfigure flows."""
|
||||
"""Handle reconfiguration of the integration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
errors, success = await self._async_request_sms_code(
|
||||
user_input[CONF_PHONE_NUMBER]
|
||||
)
|
||||
if success:
|
||||
if entry.data[CONF_USER_ID] != self._context[CONF_USER_ID]:
|
||||
if reconfigure_entry.data[CONF_USER_ID] != self._context[CONF_USER_ID]:
|
||||
errors["base"] = "account_change_not_allowed"
|
||||
elif self.source == SOURCE_REAUTH:
|
||||
return await self.async_step_reauth_sms_code()
|
||||
elif self.source == SOURCE_RECONFIGURE:
|
||||
else:
|
||||
return await self.async_step_reconfigure_sms_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA,
|
||||
{CONF_PHONE_NUMBER: entry.data.get(CONF_PHONE_NUMBER)},
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PHONE_NUMBER,
|
||||
default=reconfigure_entry.data.get(CONF_PHONE_NUMBER),
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _async_reauth_reconfigure_sms_code(
|
||||
self,
|
||||
user_input: dict[str, Any] | None,
|
||||
entry: Any,
|
||||
step_id: str,
|
||||
async def async_step_reconfigure_sms_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Verify SMS code for reauth or reconfigure flows."""
|
||||
"""Handle the SMS code step during reconfiguration."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
@@ -187,61 +178,16 @@ class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
if access_token:
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates={
|
||||
self._get_reconfigure_entry(),
|
||||
data={
|
||||
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
|
||||
CONF_USER_ID: self._context[CONF_USER_ID],
|
||||
CONF_ACCESS_TOKEN: access_token,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
step_id="reconfigure_sms_code",
|
||||
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth confirmation step."""
|
||||
return await self._async_reauth_reconfigure(
|
||||
user_input,
|
||||
self._get_reauth_entry(),
|
||||
"reauth_confirm",
|
||||
)
|
||||
|
||||
async def async_step_reauth_sms_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the SMS code step during reauth."""
|
||||
return await self._async_reauth_reconfigure_sms_code(
|
||||
user_input,
|
||||
self._get_reauth_entry(),
|
||||
"reauth_sms_code",
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
return await self._async_reauth_reconfigure(
|
||||
user_input,
|
||||
self._get_reconfigure_entry(),
|
||||
"reconfigure",
|
||||
)
|
||||
|
||||
async def async_step_reconfigure_sms_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the SMS code step during reconfiguration."""
|
||||
return await self._async_reauth_reconfigure_sms_code(
|
||||
user_input,
|
||||
self._get_reconfigure_entry(),
|
||||
"reconfigure_sms_code",
|
||||
)
|
||||
|
||||
@@ -3,17 +3,10 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from fressnapftracker import (
|
||||
ApiClient,
|
||||
Device,
|
||||
FressnapfTrackerError,
|
||||
FressnapfTrackerInvalidDeviceTokenError,
|
||||
Tracker,
|
||||
)
|
||||
from fressnapftracker import ApiClient, Device, FressnapfTrackerError, Tracker
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -53,10 +46,5 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
async def _async_update_data(self) -> Tracker:
|
||||
try:
|
||||
return await self.client.get_tracker()
|
||||
except FressnapfTrackerInvalidDeviceTokenError as exception:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from exception
|
||||
except FressnapfTrackerError as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fressnapftracker import FressnapfTrackerError
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ColorMode,
|
||||
@@ -18,7 +16,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import FressnapfTrackerConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import FressnapfTrackerEntity
|
||||
from .services import handle_fressnapf_tracker_exception
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -64,18 +61,12 @@ class FressnapfTrackerLight(FressnapfTrackerEntity, LightEntity):
|
||||
self.raise_if_not_activatable()
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
|
||||
brightness = int((brightness / 255) * 100)
|
||||
try:
|
||||
await self.coordinator.client.set_led_brightness(brightness)
|
||||
except FressnapfTrackerError as e:
|
||||
handle_fressnapf_tracker_exception(e)
|
||||
await self.coordinator.client.set_led_brightness(brightness)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the device."""
|
||||
try:
|
||||
await self.coordinator.client.set_led_brightness(0)
|
||||
except FressnapfTrackerError as e:
|
||||
handle_fressnapf_tracker_exception(e)
|
||||
await self.coordinator.client.set_led_brightness(0)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
def raise_if_not_activatable(self) -> None:
|
||||
|
||||
@@ -26,7 +26,7 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
@@ -34,7 +34,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Services and service helpers for fressnapf_tracker."""
|
||||
|
||||
from fressnapftracker import FressnapfTrackerError, FressnapfTrackerInvalidTokenError
|
||||
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
def handle_fressnapf_tracker_exception(exception: FressnapfTrackerError):
|
||||
"""Handle the different FressnapfTracker errors."""
|
||||
if isinstance(exception, FressnapfTrackerInvalidTokenError):
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from exception
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error_message": str(exception)},
|
||||
) from exception
|
||||
@@ -2,7 +2,6 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
@@ -12,23 +11,6 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data::phone_number%]"
|
||||
},
|
||||
"data_description": {
|
||||
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data_description::phone_number%]"
|
||||
},
|
||||
"description": "Re-authenticate with your Fressnapf Tracker account to refresh your credentials."
|
||||
},
|
||||
"reauth_sms_code": {
|
||||
"data": {
|
||||
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data::sms_code%]"
|
||||
},
|
||||
"data_description": {
|
||||
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data_description::sms_code%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data::phone_number%]"
|
||||
@@ -36,7 +18,7 @@
|
||||
"data_description": {
|
||||
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data_description::phone_number%]"
|
||||
},
|
||||
"description": "Update your Fressnapf Tracker account configuration."
|
||||
"description": "Re-authenticate with your Fressnapf Tracker account to refresh your credentials."
|
||||
},
|
||||
"reconfigure_sms_code": {
|
||||
"data": {
|
||||
@@ -77,15 +59,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_error": {
|
||||
"message": "An error occurred while communicating with the Fressnapf Tracker API: {error_message}"
|
||||
},
|
||||
"charging": {
|
||||
"message": "The flashlight cannot be activated while charging."
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Your authentication with the Fressnapf Tracker API expired. Please re-authenticate to refresh your credentials."
|
||||
},
|
||||
"low_battery": {
|
||||
"message": "The flashlight cannot be activated due to low battery."
|
||||
},
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fressnapftracker import FressnapfTrackerError
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
SwitchDeviceClass,
|
||||
SwitchEntity,
|
||||
@@ -15,7 +13,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FressnapfTrackerConfigEntry
|
||||
from .entity import FressnapfTrackerEntity
|
||||
from .services import handle_fressnapf_tracker_exception
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -46,18 +43,12 @@ class FressnapfTrackerSwitch(FressnapfTrackerEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the device."""
|
||||
try:
|
||||
await self.coordinator.client.set_energy_saving(True)
|
||||
except FressnapfTrackerError as e:
|
||||
handle_fressnapf_tracker_exception(e)
|
||||
await self.coordinator.client.set_energy_saving(True)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the device."""
|
||||
try:
|
||||
await self.coordinator.client.set_energy_saving(False)
|
||||
except FressnapfTrackerError as e:
|
||||
handle_fressnapf_tracker_exception(e)
|
||||
await self.coordinator.client.set_energy_saving(False)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@property
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251229.0"]
|
||||
"requirements": ["home-assistant-frontend==20251203.3"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["dacite", "gios"],
|
||||
"requirements": ["gios==7.0.0"]
|
||||
"requirements": ["gios==6.1.2"]
|
||||
}
|
||||
|
||||
@@ -48,8 +48,6 @@ async def async_tts_voices(
|
||||
list_voices_response = await client.list_voices()
|
||||
for voice in list_voices_response.voices:
|
||||
language_code = voice.language_codes[0]
|
||||
if not voice.name.startswith(language_code):
|
||||
continue
|
||||
if language_code not in voices:
|
||||
voices[language_code] = []
|
||||
voices[language_code].append(voice.name)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["google-genai==1.56.0"]
|
||||
"requirements": ["google-genai==1.38.0"]
|
||||
}
|
||||
|
||||
@@ -64,9 +64,6 @@ def get_device_list_classic(
|
||||
|
||||
user_id = login_response["user"]["id"]
|
||||
|
||||
# Legacy support: DEFAULT_PLANT_ID ("0") triggers auto-selection of first plant.
|
||||
# Modern config flow always sets a specific plant_id, but old config entries
|
||||
# from earlier versions may still have plant_id="0".
|
||||
if plant_id == DEFAULT_PLANT_ID:
|
||||
try:
|
||||
plant_info = api.plant_list(user_id)
|
||||
@@ -134,9 +131,7 @@ def get_device_list(
|
||||
return get_device_list_v1(api, config)
|
||||
if api_version == "classic":
|
||||
return get_device_list_classic(api, config)
|
||||
# Defensive: api_version is hardcoded in async_setup_entry as "v1" or "classic"
|
||||
# This line is unreachable through normal execution but kept as a safeguard
|
||||
raise ConfigEntryError(f"Unknown API version: {api_version}") # pragma: no cover
|
||||
raise ConfigEntryError(f"Unknown API version: {api_version}")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -31,7 +31,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
|
||||
@@ -75,7 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
|
||||
|
||||
def fetch_and_inject_nvr_events() -> None:
|
||||
"""Fetch and inject NVR events in a single executor job."""
|
||||
if nvr_events := camera.get_event_triggers():
|
||||
if nvr_events := camera.get_event_triggers(None):
|
||||
camera.inject_events(nvr_events)
|
||||
|
||||
await hass.async_add_executor_job(fetch_and_inject_nvr_events)
|
||||
|
||||
@@ -24,7 +24,7 @@ from homeassistant.const import (
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -227,10 +227,7 @@ class HikvisionBinarySensor(BinarySensorEntity):
|
||||
# Register callback with pyhik
|
||||
self._camera.add_update_callback(self._update_callback, self._callback_id)
|
||||
|
||||
@callback
|
||||
def _update_callback(self, msg: str) -> None:
|
||||
"""Update the sensor's state when callback is triggered.
|
||||
|
||||
This is called from pyhik's event stream thread, so we use
|
||||
schedule_update_ha_state which is thread-safe.
|
||||
"""
|
||||
self.schedule_update_ha_state()
|
||||
"""Update the sensor's state when callback is triggered."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -52,10 +52,8 @@ class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
HikCamera, url, port, username, password, ssl
|
||||
)
|
||||
except requests.exceptions.RequestException:
|
||||
_LOGGER.exception("Error connecting to Hikvision device")
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
device_id = camera.get_id
|
||||
device_name = camera.get_name
|
||||
@@ -107,10 +105,10 @@ class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
HikCamera, url, port, username, password, ssl
|
||||
)
|
||||
except requests.exceptions.RequestException:
|
||||
_LOGGER.exception(
|
||||
"Error connecting to Hikvision device during import, aborting"
|
||||
)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
device_id = camera.get_id
|
||||
device_name = camera.get_name
|
||||
@@ -120,6 +118,10 @@ class HikvisionConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(device_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
_LOGGER.warning(
|
||||
"Importing Hikvision config from configuration.yaml for %s", host
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=name or device_name or host,
|
||||
data={
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.26.0"],
|
||||
"requirements": ["aiohomeconnect==0.24.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user