mirror of
https://github.com/home-assistant/core.git
synced 2025-09-24 20:39:28 +00:00
Compare commits
1 Commits
esphome_fi
...
add-includ
Author | SHA1 | Date | |
---|---|---|---|
![]() |
53f20502c5 |
@@ -58,7 +58,6 @@ base_platforms: &base_platforms
|
||||
# Extra components that trigger the full suite
|
||||
components: &components
|
||||
- homeassistant/components/alexa/**
|
||||
- homeassistant/components/analytics/**
|
||||
- homeassistant/components/application_credentials/**
|
||||
- homeassistant/components/assist_pipeline/**
|
||||
- homeassistant/components/auth/**
|
||||
|
@@ -142,7 +142,6 @@ homeassistant.components.cloud.*
|
||||
homeassistant.components.co2signal.*
|
||||
homeassistant.components.comelit.*
|
||||
homeassistant.components.command_line.*
|
||||
homeassistant.components.compit.*
|
||||
homeassistant.components.config.*
|
||||
homeassistant.components.configurator.*
|
||||
homeassistant.components.cookidoo.*
|
||||
@@ -443,7 +442,6 @@ homeassistant.components.rituals_perfume_genie.*
|
||||
homeassistant.components.roborock.*
|
||||
homeassistant.components.roku.*
|
||||
homeassistant.components.romy.*
|
||||
homeassistant.components.route_b_smart_meter.*
|
||||
homeassistant.components.rpi_power.*
|
||||
homeassistant.components.rss_feed_template.*
|
||||
homeassistant.components.russound_rio.*
|
||||
|
16
CODEOWNERS
generated
16
CODEOWNERS
generated
@@ -292,8 +292,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/command_line/ @gjohansson-ST
|
||||
/homeassistant/components/compensation/ @Petro31
|
||||
/tests/components/compensation/ @Petro31
|
||||
/homeassistant/components/compit/ @Przemko92
|
||||
/tests/components/compit/ @Przemko92
|
||||
/homeassistant/components/config/ @home-assistant/core
|
||||
/tests/components/config/ @home-assistant/core
|
||||
/homeassistant/components/configurator/ @home-assistant/core
|
||||
@@ -316,8 +314,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/crownstone/ @Crownstone @RicArch97
|
||||
/homeassistant/components/cups/ @fabaff
|
||||
/tests/components/cups/ @fabaff
|
||||
/homeassistant/components/cync/ @Kinachi249
|
||||
/tests/components/cync/ @Kinachi249
|
||||
/homeassistant/components/daikin/ @fredrike
|
||||
/tests/components/daikin/ @fredrike
|
||||
/homeassistant/components/date/ @home-assistant/core
|
||||
@@ -412,8 +408,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/egardia/ @jeroenterheerdt
|
||||
/homeassistant/components/eheimdigital/ @autinerd
|
||||
/tests/components/eheimdigital/ @autinerd
|
||||
/homeassistant/components/ekeybionyx/ @richardpolzer
|
||||
/tests/components/ekeybionyx/ @richardpolzer
|
||||
/homeassistant/components/electrasmart/ @jafar-atili
|
||||
/tests/components/electrasmart/ @jafar-atili
|
||||
/homeassistant/components/electric_kiwi/ @mikey0000
|
||||
@@ -776,8 +770,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/iqvia/ @bachya
|
||||
/tests/components/iqvia/ @bachya
|
||||
/homeassistant/components/irish_rail_transport/ @ttroy50
|
||||
/homeassistant/components/irm_kmi/ @jdejaegh
|
||||
/tests/components/irm_kmi/ @jdejaegh
|
||||
/homeassistant/components/iron_os/ @tr4nt0r
|
||||
/tests/components/iron_os/ @tr4nt0r
|
||||
/homeassistant/components/isal/ @bdraco
|
||||
@@ -976,6 +968,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/moat/ @bdraco
|
||||
/homeassistant/components/mobile_app/ @home-assistant/core
|
||||
/tests/components/mobile_app/ @home-assistant/core
|
||||
/homeassistant/components/modbus/ @janiversen
|
||||
/tests/components/modbus/ @janiversen
|
||||
/homeassistant/components/modem_callerid/ @tkdrob
|
||||
/tests/components/modem_callerid/ @tkdrob
|
||||
/homeassistant/components/modern_forms/ @wonderslug
|
||||
@@ -1334,8 +1328,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
|
||||
/homeassistant/components/roon/ @pavoni
|
||||
/tests/components/roon/ @pavoni
|
||||
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
|
||||
/tests/components/route_b_smart_meter/ @SeraphicRav
|
||||
/homeassistant/components/rpi_power/ @shenxn @swetoast
|
||||
/tests/components/rpi_power/ @shenxn @swetoast
|
||||
/homeassistant/components/rss_feed_template/ @home-assistant/core
|
||||
@@ -1735,8 +1727,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/volumio/ @OnFreund
|
||||
/homeassistant/components/volvo/ @thomasddn
|
||||
/tests/components/volvo/ @thomasddn
|
||||
/homeassistant/components/volvooncall/ @molobrakos @svrooij
|
||||
/tests/components/volvooncall/ @molobrakos @svrooij
|
||||
/homeassistant/components/volvooncall/ @molobrakos
|
||||
/tests/components/volvooncall/ @molobrakos
|
||||
/homeassistant/components/wake_on_lan/ @ntilley905
|
||||
/tests/components/wake_on_lan/ @ntilley905
|
||||
/homeassistant/components/wake_word/ @home-assistant/core @synesthesiam
|
||||
|
@@ -8,7 +8,6 @@ import logging
|
||||
from aioacaia.acaiascale import AcaiaScale
|
||||
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
|
||||
|
||||
from homeassistant.components.bluetooth import async_get_scanner
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -43,7 +42,6 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
|
||||
name=entry.title,
|
||||
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
|
||||
notify_callback=self.async_update_listeners,
|
||||
scanner=async_get_scanner(hass),
|
||||
)
|
||||
|
||||
@property
|
||||
|
@@ -26,5 +26,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioacaia"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioacaia==0.1.17"]
|
||||
"requirements": ["aioacaia==0.1.14"]
|
||||
}
|
||||
|
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
|
||||
@@ -23,8 +22,6 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for AccuWeather."""
|
||||
|
||||
VERSION = 1
|
||||
_latitude: float | None = None
|
||||
_longitude: float | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -77,46 +74,3 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
self._latitude = entry_data[CONF_LATITUDE]
|
||||
self._longitude = entry_data[CONF_LONGITUDE]
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
websession = async_get_clientsession(self.hass)
|
||||
try:
|
||||
async with timeout(10):
|
||||
accuweather = AccuWeather(
|
||||
user_input[CONF_API_KEY],
|
||||
websession,
|
||||
latitude=self._latitude,
|
||||
longitude=self._longitude,
|
||||
)
|
||||
await accuweather.async_get_location()
|
||||
except (ApiError, ClientConnectorError, TimeoutError, ClientError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidApiKeyError:
|
||||
errors["base"] = "invalid_api_key"
|
||||
except RequestsExceededError:
|
||||
errors["base"] = "requests_exceeded"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
@@ -15,7 +15,6 @@ from aiohttp.client_exceptions import ClientConnectorError
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
DataUpdateCoordinator,
|
||||
@@ -31,7 +30,7 @@ from .const import (
|
||||
UPDATE_INTERVAL_OBSERVATION,
|
||||
)
|
||||
|
||||
EXCEPTIONS = (ApiError, ClientConnectorError, RequestsExceededError)
|
||||
EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,8 +52,6 @@ class AccuWeatherObservationDataUpdateCoordinator(
|
||||
):
|
||||
"""Class to manage fetching AccuWeather data API."""
|
||||
|
||||
config_entry: AccuWeatherConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -90,12 +87,6 @@ class AccuWeatherObservationDataUpdateCoordinator(
|
||||
translation_key="current_conditions_update_error",
|
||||
translation_placeholders={"error": repr(error)},
|
||||
) from error
|
||||
except InvalidApiKeyError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
translation_placeholders={"entry": self.config_entry.title},
|
||||
) from err
|
||||
|
||||
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
|
||||
|
||||
@@ -107,8 +98,6 @@ class AccuWeatherForecastDataUpdateCoordinator(
|
||||
):
|
||||
"""Base class for AccuWeather forecast."""
|
||||
|
||||
config_entry: AccuWeatherConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -148,12 +137,6 @@ class AccuWeatherForecastDataUpdateCoordinator(
|
||||
translation_key="forecast_update_error",
|
||||
translation_placeholders={"error": repr(error)},
|
||||
) from error
|
||||
except InvalidApiKeyError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_error",
|
||||
translation_placeholders={"entry": self.config_entry.title},
|
||||
) from err
|
||||
|
||||
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
|
||||
return result
|
||||
|
@@ -7,17 +7,6 @@
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "API key generated in the AccuWeather APIs portal."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::accuweather::config::step::user::data_description::api_key%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -30,8 +19,7 @@
|
||||
"requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -251,9 +239,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_error": {
|
||||
"message": "Authentication failed for {entry}, please update your API key"
|
||||
},
|
||||
"current_conditions_update_error": {
|
||||
"message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}"
|
||||
},
|
||||
|
@@ -6,19 +6,17 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Final
|
||||
|
||||
from aioairzone.common import GrilleAngle, OperationMode, QAdapt, SleepTimeout
|
||||
from aioairzone.common import GrilleAngle, OperationMode, SleepTimeout
|
||||
from aioairzone.const import (
|
||||
API_COLD_ANGLE,
|
||||
API_HEAT_ANGLE,
|
||||
API_MODE,
|
||||
API_Q_ADAPT,
|
||||
API_SLEEP,
|
||||
AZD_COLD_ANGLE,
|
||||
AZD_HEAT_ANGLE,
|
||||
AZD_MASTER,
|
||||
AZD_MODE,
|
||||
AZD_MODES,
|
||||
AZD_Q_ADAPT,
|
||||
AZD_SLEEP,
|
||||
AZD_ZONES,
|
||||
)
|
||||
@@ -67,14 +65,6 @@ SLEEP_DICT: Final[dict[str, int]] = {
|
||||
"90m": SleepTimeout.SLEEP_90,
|
||||
}
|
||||
|
||||
Q_ADAPT_DICT: Final[dict[str, int]] = {
|
||||
"standard": QAdapt.STANDARD,
|
||||
"power": QAdapt.POWER,
|
||||
"silence": QAdapt.SILENCE,
|
||||
"minimum": QAdapt.MINIMUM,
|
||||
"maximum": QAdapt.MAXIMUM,
|
||||
}
|
||||
|
||||
|
||||
def main_zone_options(
|
||||
zone_data: dict[str, Any],
|
||||
@@ -93,14 +83,6 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
|
||||
options_fn=main_zone_options,
|
||||
translation_key="modes",
|
||||
),
|
||||
AirzoneSelectDescription(
|
||||
api_param=API_Q_ADAPT,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
key=AZD_Q_ADAPT,
|
||||
options=list(Q_ADAPT_DICT),
|
||||
options_dict=Q_ADAPT_DICT,
|
||||
translation_key="q_adapt",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -63,16 +63,6 @@
|
||||
"stop": "Stop"
|
||||
}
|
||||
},
|
||||
"q_adapt": {
|
||||
"name": "Q-Adapt",
|
||||
"state": {
|
||||
"standard": "Standard",
|
||||
"power": "Power",
|
||||
"silence": "Silence",
|
||||
"minimum": "Minimum",
|
||||
"maximum": "Maximum"
|
||||
}
|
||||
},
|
||||
"sleep_times": {
|
||||
"name": "Sleep",
|
||||
"state": {
|
||||
|
@@ -94,24 +94,12 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in BINARY_SENSORS
|
||||
for serial_num in new_devices
|
||||
if sensor_desc.is_supported(
|
||||
coordinator.data[serial_num], sensor_desc.key
|
||||
)
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in BINARY_SENSORS
|
||||
for serial_num in coordinator.data
|
||||
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
|
||||
)
|
||||
|
||||
|
||||
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
||||
|
@@ -57,23 +57,13 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in NOTIFY
|
||||
for serial_num in new_devices
|
||||
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
and sensor_desc.is_supported(coordinator.data[serial_num])
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in NOTIFY
|
||||
for serial_num in coordinator.data
|
||||
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
and sensor_desc.is_supported(coordinator.data[serial_num])
|
||||
)
|
||||
|
||||
|
||||
class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
|
||||
|
@@ -53,7 +53,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
|
@@ -62,22 +62,12 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in SENSORS
|
||||
for serial_num in new_devices
|
||||
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in SENSORS
|
||||
for serial_num in coordinator.data
|
||||
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
|
||||
)
|
||||
|
||||
|
||||
class AmazonSensorEntity(AmazonEntity, SensorEntity):
|
||||
|
@@ -48,22 +48,12 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
|
||||
for switch_desc in SWITCHES
|
||||
for serial_num in new_devices
|
||||
if switch_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
)
|
||||
|
||||
_check_device()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
|
||||
for switch_desc in SWITCHES
|
||||
for serial_num in coordinator.data
|
||||
if switch_desc.subkey in coordinator.data[serial_num].capabilities
|
||||
)
|
||||
|
||||
|
||||
class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
|
@@ -41,7 +41,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
|
||||
return []
|
||||
|
||||
call_ids = await async_extract_entity_ids(call)
|
||||
call_ids = await async_extract_entity_ids(hass, call)
|
||||
entity_ids = []
|
||||
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
|
||||
if entity_id not in call_ids:
|
||||
|
@@ -12,25 +12,10 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .analytics import (
|
||||
Analytics,
|
||||
AnalyticsInput,
|
||||
AnalyticsModifications,
|
||||
DeviceAnalyticsModifications,
|
||||
EntityAnalyticsModifications,
|
||||
async_devices_payload,
|
||||
)
|
||||
from .analytics import Analytics
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
||||
from .http import AnalyticsDevicesView
|
||||
|
||||
__all__ = [
|
||||
"AnalyticsInput",
|
||||
"AnalyticsModifications",
|
||||
"DeviceAnalyticsModifications",
|
||||
"EntityAnalyticsModifications",
|
||||
"async_devices_payload",
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
|
@@ -4,10 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
@@ -36,14 +35,11 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.system_info import async_get_system_info
|
||||
from homeassistant.helpers.typing import UNDEFINED
|
||||
from homeassistant.loader import (
|
||||
Integration,
|
||||
IntegrationNotFound,
|
||||
async_get_integration,
|
||||
async_get_integrations,
|
||||
)
|
||||
from homeassistant.setup import async_get_loaded_integrations
|
||||
@@ -79,115 +75,12 @@ from .const import (
|
||||
ATTR_USER_COUNT,
|
||||
ATTR_UUID,
|
||||
ATTR_VERSION,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PREFERENCE_SCHEMA,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
|
||||
DATA_ANALYTICS_MODIFIERS = "analytics_modifiers"
|
||||
|
||||
type AnalyticsModifier = Callable[
|
||||
[HomeAssistant, AnalyticsInput], Awaitable[AnalyticsModifications]
|
||||
]
|
||||
|
||||
|
||||
@singleton(DATA_ANALYTICS_MODIFIERS)
|
||||
def _async_get_modifiers(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, AnalyticsModifier | None]:
|
||||
"""Return the analytics modifiers."""
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsInput:
|
||||
"""Analytics input for a single integration.
|
||||
|
||||
This is sent to integrations that implement the platform.
|
||||
"""
|
||||
|
||||
device_ids: Iterable[str] = field(default_factory=list)
|
||||
entity_ids: Iterable[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsModifications:
|
||||
"""Analytics config for a single integration.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
devices: Mapping[str, DeviceAnalyticsModifications] | None = None
|
||||
entities: Mapping[str, EntityAnalyticsModifications] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceAnalyticsModifications:
|
||||
"""Analytics config for a single device.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityAnalyticsModifications:
|
||||
"""Analytics config for a single entity.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
|
||||
|
||||
class AnalyticsPlatformProtocol(Protocol):
|
||||
"""Define the format of analytics platforms."""
|
||||
|
||||
async def async_modify_analytics(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
analytics_input: AnalyticsInput,
|
||||
) -> AnalyticsModifications:
|
||||
"""Modify the analytics."""
|
||||
|
||||
|
||||
async def _async_get_analytics_platform(
|
||||
hass: HomeAssistant, domain: str
|
||||
) -> AnalyticsPlatformProtocol | None:
|
||||
"""Get analytics platform."""
|
||||
try:
|
||||
integration = await async_get_integration(hass, domain)
|
||||
except IntegrationNotFound:
|
||||
return None
|
||||
try:
|
||||
return await integration.async_get_platform(DOMAIN)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
async def _async_get_modifier(
|
||||
hass: HomeAssistant, domain: str
|
||||
) -> AnalyticsModifier | None:
|
||||
"""Get analytics modifier."""
|
||||
modifiers = _async_get_modifiers(hass)
|
||||
modifier = modifiers.get(domain, UNDEFINED)
|
||||
|
||||
if modifier is not UNDEFINED:
|
||||
return modifier
|
||||
|
||||
platform = await _async_get_analytics_platform(hass, domain)
|
||||
if platform is None:
|
||||
modifiers[domain] = None
|
||||
return None
|
||||
|
||||
modifier = getattr(platform, "async_modify_analytics", None)
|
||||
modifiers[domain] = modifier
|
||||
return modifier
|
||||
|
||||
|
||||
def gen_uuid() -> str:
|
||||
"""Generate a new UUID."""
|
||||
@@ -500,20 +393,17 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
||||
return domains
|
||||
|
||||
|
||||
DEFAULT_ANALYTICS_CONFIG = AnalyticsModifications()
|
||||
DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
"""Return detailed information about entities and devices."""
|
||||
integrations_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
integration_inputs: dict[str, tuple[list[str], list[str]]] = {}
|
||||
integration_configs: dict[str, AnalyticsModifications] = {}
|
||||
# We need to refer to other devices, for example in `via_device` field.
|
||||
# We don't however send the original device ids outside of Home Assistant,
|
||||
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
|
||||
device_id_mapping: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# Get device list
|
||||
for device_entry in dev_reg.devices.values():
|
||||
if not device_entry.primary_config_entry:
|
||||
continue
|
||||
@@ -526,113 +416,27 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
continue
|
||||
|
||||
integration_domain = config_entry.domain
|
||||
|
||||
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
|
||||
integration_input[0].append(device_entry.id)
|
||||
|
||||
# Get entity list
|
||||
for entity_entry in ent_reg.entities.values():
|
||||
integration_domain = entity_entry.platform
|
||||
|
||||
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
|
||||
integration_input[1].append(entity_entry.entity_id)
|
||||
|
||||
integrations = {
|
||||
domain: integration
|
||||
for domain, integration in (
|
||||
await async_get_integrations(hass, integration_inputs.keys())
|
||||
).items()
|
||||
if isinstance(integration, Integration)
|
||||
}
|
||||
|
||||
# Filter out custom integrations and integrations that are not device or hub type
|
||||
integration_inputs = {
|
||||
domain: integration_info
|
||||
for domain, integration_info in integration_inputs.items()
|
||||
if (integration := integrations.get(domain)) is not None
|
||||
and integration.is_built_in
|
||||
and integration.integration_type in ("device", "hub")
|
||||
}
|
||||
|
||||
# Call integrations that implement the analytics platform
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
if (
|
||||
modifier := await _async_get_modifier(hass, integration_domain)
|
||||
) is not None:
|
||||
try:
|
||||
integration_config = await modifier(
|
||||
hass, AnalyticsInput(*integration_input)
|
||||
)
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception(
|
||||
"Calling async_modify_analytics for integration '%s' failed: %s",
|
||||
integration_domain,
|
||||
err,
|
||||
)
|
||||
integration_configs[integration_domain] = AnalyticsModifications(
|
||||
remove=True
|
||||
)
|
||||
continue
|
||||
|
||||
if not isinstance(integration_config, AnalyticsModifications):
|
||||
LOGGER.error( # type: ignore[unreachable]
|
||||
"Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig",
|
||||
integration_domain,
|
||||
)
|
||||
integration_configs[integration_domain] = AnalyticsModifications(
|
||||
remove=True
|
||||
)
|
||||
continue
|
||||
|
||||
integration_configs[integration_domain] = integration_config
|
||||
|
||||
integrations_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# We need to refer to other devices, for example in `via_device` field.
|
||||
# We don't however send the original device ids outside of Home Assistant,
|
||||
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
|
||||
device_id_mapping: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# Fill out information about devices
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
integration_config = integration_configs.get(
|
||||
integration_domain, DEFAULT_ANALYTICS_CONFIG
|
||||
)
|
||||
|
||||
if integration_config.remove:
|
||||
continue
|
||||
|
||||
integration_info = integrations_info.setdefault(
|
||||
integration_domain, {"devices": [], "entities": []}
|
||||
)
|
||||
|
||||
devices_info = integration_info["devices"]
|
||||
|
||||
for device_id in integration_input[0]:
|
||||
device_config = DEFAULT_DEVICE_ANALYTICS_CONFIG
|
||||
if integration_config.devices is not None:
|
||||
device_config = integration_config.devices.get(device_id, device_config)
|
||||
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
|
||||
|
||||
if device_config.remove:
|
||||
continue
|
||||
|
||||
device_entry = dev_reg.devices[device_id]
|
||||
|
||||
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
|
||||
|
||||
devices_info.append(
|
||||
{
|
||||
"entities": [],
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
"manufacturer": device_entry.manufacturer,
|
||||
"model": device_entry.model,
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
}
|
||||
)
|
||||
devices_info.append(
|
||||
{
|
||||
"entities": [],
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
"manufacturer": device_entry.manufacturer,
|
||||
"model": device_entry.model,
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Fill out via_device with new device ids
|
||||
for integration_info in integrations_info.values():
|
||||
@@ -641,15 +445,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
continue
|
||||
device_info["via_device"] = device_id_mapping.get(device_info["via_device"])
|
||||
|
||||
# Fill out information about entities
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
integration_config = integration_configs.get(
|
||||
integration_domain, DEFAULT_ANALYTICS_CONFIG
|
||||
)
|
||||
|
||||
if integration_config.remove:
|
||||
continue
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
for entity_entry in ent_reg.entities.values():
|
||||
integration_domain = entity_entry.platform
|
||||
integration_info = integrations_info.setdefault(
|
||||
integration_domain, {"devices": [], "entities": []}
|
||||
)
|
||||
@@ -657,49 +456,53 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
devices_info = integration_info["devices"]
|
||||
entities_info = integration_info["entities"]
|
||||
|
||||
for entity_id in integration_input[1]:
|
||||
entity_config = DEFAULT_ENTITY_ANALYTICS_CONFIG
|
||||
if integration_config.entities is not None:
|
||||
entity_config = integration_config.entities.get(
|
||||
entity_id, entity_config
|
||||
entity_state = hass.states.get(entity_entry.entity_id)
|
||||
|
||||
entity_info = {
|
||||
# LIMITATION: `assumed_state` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
# It is also not present, if entity is not in the state machine,
|
||||
# which can happen for disabled entities.
|
||||
"assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
|
||||
if entity_state is not None
|
||||
else None,
|
||||
"capabilities": entity_entry.capabilities,
|
||||
"domain": entity_entry.domain,
|
||||
"entity_category": entity_entry.entity_category,
|
||||
"has_entity_name": entity_entry.has_entity_name,
|
||||
"original_device_class": entity_entry.original_device_class,
|
||||
# LIMITATION: `unit_of_measurement` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||
}
|
||||
|
||||
if (
|
||||
((device_id := entity_entry.device_id) is not None)
|
||||
and ((new_device_id := device_id_mapping.get(device_id)) is not None)
|
||||
and (new_device_id[0] == integration_domain)
|
||||
):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
else:
|
||||
entities_info.append(entity_info)
|
||||
|
||||
integrations = {
|
||||
domain: integration
|
||||
for domain, integration in (
|
||||
await async_get_integrations(hass, integrations_info.keys())
|
||||
).items()
|
||||
if isinstance(integration, Integration)
|
||||
}
|
||||
|
||||
for domain, integration_info in integrations_info.items():
|
||||
if integration := integrations.get(domain):
|
||||
integration_info["is_custom_integration"] = not integration.is_built_in
|
||||
# Include version for custom integrations
|
||||
if not integration.is_built_in and integration.version:
|
||||
integration_info["custom_integration_version"] = str(
|
||||
integration.version
|
||||
)
|
||||
|
||||
if entity_config.remove:
|
||||
continue
|
||||
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
|
||||
entity_state = hass.states.get(entity_entry.entity_id)
|
||||
|
||||
entity_info = {
|
||||
# LIMITATION: `assumed_state` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
# It is also not present, if entity is not in the state machine,
|
||||
# which can happen for disabled entities.
|
||||
"assumed_state": (
|
||||
entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
|
||||
if entity_state is not None
|
||||
else None
|
||||
),
|
||||
"domain": entity_entry.domain,
|
||||
"entity_category": entity_entry.entity_category,
|
||||
"has_entity_name": entity_entry.has_entity_name,
|
||||
"original_device_class": entity_entry.original_device_class,
|
||||
# LIMITATION: `unit_of_measurement` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||
}
|
||||
|
||||
if (
|
||||
((device_id_ := entity_entry.device_id) is not None)
|
||||
and ((new_device_id := device_id_mapping.get(device_id_)) is not None)
|
||||
and (new_device_id[0] == integration_domain)
|
||||
):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
else:
|
||||
entities_info.append(entity_info)
|
||||
|
||||
return {
|
||||
"version": "home-assistant:1",
|
||||
"home_assistant": HA_VERSION,
|
||||
|
@@ -109,7 +109,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
)
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
if (state is not None) and (state.state in self.options):
|
||||
if state is not None and state.state in self.options:
|
||||
self._attr_current_option = state.state
|
||||
|
||||
if self.registry_entry and (device_id := self.registry_entry.device_id):
|
||||
@@ -119,7 +119,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
|
||||
def cleanup() -> None:
|
||||
"""Clean up registered device."""
|
||||
pipeline_data.pipeline_devices.pop(device_id, None)
|
||||
pipeline_data.pipeline_devices.pop(device_id)
|
||||
|
||||
self.async_on_remove(cleanup)
|
||||
|
||||
|
@@ -2,12 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from yalexs.activity import ActivityType
|
||||
from yalexs.lock import Lock, LockOperation, LockStatus
|
||||
from yalexs.activity import ActivityType, ActivityTypes
|
||||
from yalexs.lock import Lock, LockStatus
|
||||
from yalexs.util import get_latest_activity, update_lock_detail_from_activity
|
||||
|
||||
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature
|
||||
@@ -49,25 +50,30 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity):
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the device."""
|
||||
await self._perform_lock_operation(LockOperation.LOCK)
|
||||
if self._data.push_updates_connected:
|
||||
await self._data.async_lock_async(self._device_id, self._hyper_bridge)
|
||||
return
|
||||
await self._call_lock_operation(self._data.async_lock)
|
||||
|
||||
async def async_open(self, **kwargs: Any) -> None:
|
||||
"""Open/unlatch the device."""
|
||||
await self._perform_lock_operation(LockOperation.OPEN)
|
||||
if self._data.push_updates_connected:
|
||||
await self._data.async_unlatch_async(self._device_id, self._hyper_bridge)
|
||||
return
|
||||
await self._call_lock_operation(self._data.async_unlatch)
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the device."""
|
||||
await self._perform_lock_operation(LockOperation.UNLOCK)
|
||||
if self._data.push_updates_connected:
|
||||
await self._data.async_unlock_async(self._device_id, self._hyper_bridge)
|
||||
return
|
||||
await self._call_lock_operation(self._data.async_unlock)
|
||||
|
||||
async def _perform_lock_operation(self, operation: LockOperation) -> None:
|
||||
"""Perform a lock operation."""
|
||||
async def _call_lock_operation(
|
||||
self, lock_operation: Callable[[str], Coroutine[Any, Any, list[ActivityTypes]]]
|
||||
) -> None:
|
||||
try:
|
||||
activities = await self._data.async_operate_lock(
|
||||
self._device_id,
|
||||
operation,
|
||||
self._data.push_updates_connected,
|
||||
self._hyper_bridge,
|
||||
)
|
||||
activities = await lock_operation(self._device_id)
|
||||
except ClientResponseError as err:
|
||||
if err.status == LOCK_JAMMED_ERR:
|
||||
self._detail.lock_status = LockStatus.JAMMED
|
||||
|
@@ -13,30 +13,20 @@ from bluecurrent_api.exceptions import (
|
||||
RequestLimitReached,
|
||||
WebsocketError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
BCU_APP,
|
||||
CHARGEPOINT_SETTINGS,
|
||||
CHARGEPOINT_STATUS,
|
||||
CHARGING_CARD_ID,
|
||||
DOMAIN,
|
||||
EVSE_ID,
|
||||
LOGGER,
|
||||
PLUG_AND_CHARGE,
|
||||
SERVICE_START_CHARGE_SESSION,
|
||||
VALUE,
|
||||
)
|
||||
|
||||
@@ -44,7 +34,6 @@ type BlueCurrentConfigEntry = ConfigEntry[Connector]
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
|
||||
CHARGE_POINTS = "CHARGE_POINTS"
|
||||
CHARGE_CARDS = "CHARGE_CARDS"
|
||||
DATA = "data"
|
||||
DELAY = 5
|
||||
|
||||
@@ -52,16 +41,6 @@ GRID = "GRID"
|
||||
OBJECT = "object"
|
||||
VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE_ID): cv.string,
|
||||
# When no charging card is provided, use no charging card (BCU_APP = no charging card).
|
||||
vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
|
||||
@@ -88,66 +67,6 @@ async def async_setup_entry(
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Blue Current."""
|
||||
|
||||
async def start_charge_session(service_call: ServiceCall) -> None:
|
||||
"""Start a charge session with the provided device and charge card ID."""
|
||||
# When no charge card is provided, use the default charge card set in the config flow.
|
||||
charging_card_id = service_call.data[CHARGING_CARD_ID]
|
||||
device_id = service_call.data[CONF_DEVICE_ID]
|
||||
|
||||
# Get the device based on the given device ID.
|
||||
device = dr.async_get(hass).devices.get(device_id)
|
||||
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="invalid_device_id"
|
||||
)
|
||||
|
||||
blue_current_config_entry: ConfigEntry | None = None
|
||||
|
||||
for config_entry_id in device.config_entries:
|
||||
config_entry = hass.config_entries.async_get_entry(config_entry_id)
|
||||
if not config_entry or config_entry.domain != DOMAIN:
|
||||
# Not the blue_current config entry.
|
||||
continue
|
||||
|
||||
if config_entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
|
||||
)
|
||||
|
||||
blue_current_config_entry = config_entry
|
||||
break
|
||||
|
||||
if not blue_current_config_entry:
|
||||
# The device is not connected to a valid blue_current config entry.
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN, translation_key="no_config_entry"
|
||||
)
|
||||
|
||||
connector = blue_current_config_entry.runtime_data
|
||||
|
||||
# Get the evse_id from the identifier of the device.
|
||||
evse_id = next(
|
||||
identifier[1]
|
||||
for identifier in device.identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
)
|
||||
|
||||
await connector.client.start_session(evse_id, charging_card_id)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_START_CHARGE_SESSION,
|
||||
start_charge_session,
|
||||
SERVICE_START_CHARGE_SESSION_SCHEMA,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
|
||||
) -> bool:
|
||||
@@ -168,7 +87,6 @@ class Connector:
|
||||
self.client = client
|
||||
self.charge_points: dict[str, dict] = {}
|
||||
self.grid: dict[str, Any] = {}
|
||||
self.charge_cards: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def on_data(self, message: dict) -> None:
|
||||
"""Handle received data."""
|
||||
|
@@ -8,12 +8,6 @@ LOGGER = logging.getLogger(__package__)
|
||||
|
||||
EVSE_ID = "evse_id"
|
||||
MODEL_TYPE = "model_type"
|
||||
CARD = "card"
|
||||
UID = "uid"
|
||||
BCU_APP = "BCU-APP"
|
||||
WITHOUT_CHARGING_CARD = "without_charging_card"
|
||||
CHARGING_CARD_ID = "charging_card_id"
|
||||
SERVICE_START_CHARGE_SESSION = "start_charge_session"
|
||||
PLUG_AND_CHARGE = "plug_and_charge"
|
||||
VALUE = "value"
|
||||
PERMISSION = "permission"
|
||||
|
@@ -42,10 +42,5 @@
|
||||
"default": "mdi:lock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"start_charge_session": {
|
||||
"service": "mdi:play"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +0,0 @@
|
||||
start_charge_session:
|
||||
fields:
|
||||
device_id:
|
||||
selector:
|
||||
device:
|
||||
integration: blue_current
|
||||
required: true
|
||||
|
||||
charging_card_id:
|
||||
selector:
|
||||
text:
|
||||
required: false
|
@@ -22,16 +22,6 @@
|
||||
"wrong_account": "Wrong account: Please authenticate with the API token for {email}."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"card": "Card"
|
||||
},
|
||||
"description": "Select the default charging card you want to use"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"activity": {
|
||||
@@ -146,39 +136,5 @@
|
||||
"name": "Block charge point"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"select_charging_card": {
|
||||
"options": {
|
||||
"without_charging_card": "Without charging card"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"start_charge_session": {
|
||||
"name": "Start charge session",
|
||||
"description": "Starts a new charge session on a specified charge point.",
|
||||
"fields": {
|
||||
"charging_card_id": {
|
||||
"name": "Charging card ID",
|
||||
"description": "Optional charging card ID that will be used to start a charge session. When not provided, no charging card will be used."
|
||||
},
|
||||
"device_id": {
|
||||
"name": "Device ID",
|
||||
"description": "The ID of the Blue Current charge point."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_device_id": {
|
||||
"message": "Invalid device ID given."
|
||||
},
|
||||
"config_entry_not_loaded": {
|
||||
"message": "Config entry not loaded."
|
||||
},
|
||||
"no_config_entry": {
|
||||
"message": "Device has not a valid blue_current config entry."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -10,7 +10,6 @@ from asyncio import Future
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from bleak import BleakScanner
|
||||
from habluetooth import (
|
||||
BaseHaScanner,
|
||||
BluetoothScannerDevice,
|
||||
@@ -39,16 +38,13 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_get_scanner(hass: HomeAssistant) -> BleakScanner:
|
||||
"""Return a HaBleakScannerWrapper cast to BleakScanner.
|
||||
def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
|
||||
"""Return a HaBleakScannerWrapper.
|
||||
|
||||
This is a wrapper around our BleakScanner singleton that allows
|
||||
multiple integrations to share the same BleakScanner.
|
||||
|
||||
The wrapper is cast to BleakScanner for type compatibility with
|
||||
libraries expecting a BleakScanner instance.
|
||||
"""
|
||||
return cast(BleakScanner, HaBleakScannerWrapper())
|
||||
return HaBleakScannerWrapper()
|
||||
|
||||
|
||||
@hass_callback
|
||||
|
@@ -205,7 +205,6 @@ class BringActivityCoordinator(BringBaseCoordinator[dict[str, BringActivityData]
|
||||
|
||||
async def _async_update_data(self) -> dict[str, BringActivityData]:
|
||||
"""Fetch activity data from bring."""
|
||||
self.lists = self.coordinator.lists
|
||||
|
||||
list_dict: dict[str, BringActivityData] = {}
|
||||
for lst in self.lists:
|
||||
|
@@ -43,7 +43,7 @@ async def async_setup_entry(
|
||||
)
|
||||
lists_added |= new_lists
|
||||
|
||||
coordinator.data.async_add_listener(add_entities)
|
||||
coordinator.activity.async_add_listener(add_entities)
|
||||
add_entities()
|
||||
|
||||
|
||||
@@ -67,8 +67,7 @@ class BringEventEntity(BringBaseEntity, EventEntity):
|
||||
|
||||
def _async_handle_event(self) -> None:
|
||||
"""Handle the activity event."""
|
||||
if (bring_list := self.coordinator.data.get(self._list_uuid)) is None:
|
||||
return
|
||||
bring_list = self.coordinator.data[self._list_uuid]
|
||||
last_event_triggered = self.state
|
||||
if bring_list.activity.timeline and (
|
||||
last_event_triggered is None
|
||||
|
@@ -25,11 +25,7 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo
|
||||
return await cloud.payments.subscription_info()
|
||||
except PaymentsApiError as exception:
|
||||
_LOGGER.error("Failed to fetch subscription information - %s", exception)
|
||||
except TimeoutError:
|
||||
_LOGGER.error(
|
||||
"A timeout of %s was reached while trying to fetch subscription information",
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
@@ -29,23 +29,10 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data["alarm_zones"])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitVedoBinarySensorEntity(
|
||||
coordinator, device, config_entry.entry_id
|
||||
)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
)
|
||||
|
||||
|
||||
class ComelitVedoBinarySensorEntity(
|
||||
|
@@ -29,21 +29,10 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[COVER])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[COVER].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[COVER].values()
|
||||
)
|
||||
|
||||
|
||||
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
|
||||
|
@@ -27,21 +27,10 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[LIGHT])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitLightEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[LIGHT].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
async_add_entities(
|
||||
ComelitLightEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[LIGHT].values()
|
||||
)
|
||||
|
||||
|
||||
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
|
||||
|
@@ -57,7 +57,9 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
dynamic-devices:
|
||||
status: todo
|
||||
comment: missing implementation
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: no config or diagnostic entities
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Final, cast
|
||||
|
||||
from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject
|
||||
from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject
|
||||
from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -65,24 +65,15 @@ async def async_setup_bridge_entry(
|
||||
|
||||
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data[OTHER])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_BRIDGE_TYPES
|
||||
for device in coordinator.data[OTHER].values()
|
||||
if device.index in new_devices
|
||||
entities: list[ComelitBridgeSensorEntity] = []
|
||||
for device in coordinator.data[OTHER].values():
|
||||
entities.extend(
|
||||
ComelitBridgeSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
for sensor_desc in SENSOR_BRIDGE_TYPES
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
async def async_setup_vedo_entry(
|
||||
@@ -94,24 +85,15 @@ async def async_setup_vedo_entry(
|
||||
|
||||
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
|
||||
|
||||
known_devices: set[int] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data["alarm_zones"])
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
for device in coordinator.data["alarm_zones"].values()
|
||||
if device.index in new_devices
|
||||
entities: list[ComelitVedoSensorEntity] = []
|
||||
for device in coordinator.data["alarm_zones"].values():
|
||||
entities.extend(
|
||||
ComelitVedoSensorEntity(
|
||||
coordinator, device, config_entry.entry_id, sensor_desc
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
for sensor_desc in SENSOR_VEDO_TYPES
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
|
||||
|
@@ -39,25 +39,6 @@ async def async_setup_entry(
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
known_devices: dict[str, set[int]] = {
|
||||
dev_type: set() for dev_type in (IRRIGATION, OTHER)
|
||||
}
|
||||
|
||||
def _check_device() -> None:
|
||||
for dev_type in (IRRIGATION, OTHER):
|
||||
current_devices = set(coordinator.data[dev_type])
|
||||
new_devices = current_devices - known_devices[dev_type]
|
||||
if new_devices:
|
||||
known_devices[dev_type].update(new_devices)
|
||||
async_add_entities(
|
||||
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
|
||||
for device in coordinator.data[dev_type].values()
|
||||
if device.index in new_devices
|
||||
)
|
||||
|
||||
_check_device()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
|
||||
|
||||
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
|
||||
"""Switch device."""
|
||||
|
@@ -1,45 +0,0 @@
|
||||
"""The Compit integration."""
|
||||
|
||||
from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth
|
||||
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool:
|
||||
"""Set up Compit from a config entry."""
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
connector = CompitApiConnector(session)
|
||||
try:
|
||||
connected = await connector.init(
|
||||
entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], hass.config.language
|
||||
)
|
||||
except CannotConnect as e:
|
||||
raise ConfigEntryNotReady(f"Error while connecting to Compit: {e}") from e
|
||||
except InvalidAuth as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Invalid credentials for {entry.data[CONF_EMAIL]}"
|
||||
) from e
|
||||
|
||||
if not connected:
|
||||
raise ConfigEntryAuthFailed("Authentication API error")
|
||||
|
||||
coordinator = CompitDataUpdateCoordinator(hass, entry, connector)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CompitConfigEntry) -> bool:
|
||||
"""Unload an entry for the Compit integration."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
@@ -1,265 +0,0 @@
|
||||
"""Module contains the CompitClimate class for controlling climate entities."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from compit_inext_api import Param, Parameter
|
||||
from compit_inext_api.consts import (
|
||||
CompitFanMode,
|
||||
CompitHVACMode,
|
||||
CompitParameter,
|
||||
CompitPresetMode,
|
||||
)
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
FAN_OFF,
|
||||
PRESET_AWAY,
|
||||
PRESET_ECO,
|
||||
PRESET_HOME,
|
||||
PRESET_NONE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
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, MANUFACTURER_NAME
|
||||
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
# Device class for climate devices in Compit system
|
||||
CLIMATE_DEVICE_CLASS = 10
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
COMPIT_MODE_MAP = {
|
||||
CompitHVACMode.COOL: HVACMode.COOL,
|
||||
CompitHVACMode.HEAT: HVACMode.HEAT,
|
||||
CompitHVACMode.OFF: HVACMode.OFF,
|
||||
}
|
||||
|
||||
COMPIT_FANSPEED_MAP = {
|
||||
CompitFanMode.OFF: FAN_OFF,
|
||||
CompitFanMode.AUTO: FAN_AUTO,
|
||||
CompitFanMode.LOW: FAN_LOW,
|
||||
CompitFanMode.MEDIUM: FAN_MEDIUM,
|
||||
CompitFanMode.HIGH: FAN_HIGH,
|
||||
CompitFanMode.HOLIDAY: FAN_AUTO,
|
||||
}
|
||||
|
||||
COMPIT_PRESET_MAP = {
|
||||
CompitPresetMode.AUTO: PRESET_HOME,
|
||||
CompitPresetMode.HOLIDAY: PRESET_ECO,
|
||||
CompitPresetMode.MANUAL: PRESET_NONE,
|
||||
CompitPresetMode.AWAY: PRESET_AWAY,
|
||||
}
|
||||
|
||||
HVAC_MODE_TO_COMPIT_MODE = {v: k for k, v in COMPIT_MODE_MAP.items()}
|
||||
FAN_MODE_TO_COMPIT_FAN_MODE = {v: k for k, v in COMPIT_FANSPEED_MAP.items()}
|
||||
PRESET_MODE_TO_COMPIT_PRESET_MODE = {v: k for k, v in COMPIT_PRESET_MAP.items()}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CompitConfigEntry,
|
||||
async_add_devices: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the CompitClimate platform from a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
climate_entities = []
|
||||
for device_id in coordinator.connector.all_devices:
|
||||
device = coordinator.connector.all_devices[device_id]
|
||||
|
||||
if device.definition.device_class == CLIMATE_DEVICE_CLASS:
|
||||
climate_entities.append(
|
||||
CompitClimate(
|
||||
coordinator,
|
||||
device_id,
|
||||
{
|
||||
parameter.parameter_code: parameter
|
||||
for parameter in device.definition.parameters
|
||||
},
|
||||
device.definition.name,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_devices(climate_entities)
|
||||
|
||||
|
||||
class CompitClimate(CoordinatorEntity[CompitDataUpdateCoordinator], ClimateEntity):
|
||||
"""Representation of a Compit climate device."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_hvac_modes = [*COMPIT_MODE_MAP.values()]
|
||||
_attr_name = None
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CompitDataUpdateCoordinator,
|
||||
device_id: int,
|
||||
parameters: dict[str, Parameter],
|
||||
device_name: str,
|
||||
) -> None:
|
||||
"""Initialize the climate device."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{device_name}_{device_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(device_id))},
|
||||
name=device_name,
|
||||
manufacturer=MANUFACTURER_NAME,
|
||||
model=device_name,
|
||||
)
|
||||
|
||||
self.parameters = parameters
|
||||
self.device_id = device_id
|
||||
self.available_presets: Parameter | None = self.parameters.get(
|
||||
CompitParameter.PRESET_MODE.value
|
||||
)
|
||||
self.available_fan_modes: Parameter | None = self.parameters.get(
|
||||
CompitParameter.FAN_MODE.value
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.device_id in self.coordinator.connector.all_devices
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
value = self.get_parameter_value(CompitParameter.CURRENT_TEMPERATURE)
|
||||
if value is None:
|
||||
return None
|
||||
return float(value.value)
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
value = self.get_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE)
|
||||
if value is None:
|
||||
return None
|
||||
return float(value.value)
|
||||
|
||||
@cached_property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return the available preset modes."""
|
||||
if self.available_presets is None or self.available_presets.details is None:
|
||||
return []
|
||||
|
||||
preset_modes = []
|
||||
for item in self.available_presets.details:
|
||||
if item is not None:
|
||||
ha_preset = COMPIT_PRESET_MAP.get(CompitPresetMode(item.state))
|
||||
if ha_preset and ha_preset not in preset_modes:
|
||||
preset_modes.append(ha_preset)
|
||||
|
||||
return preset_modes
|
||||
|
||||
@cached_property
|
||||
def fan_modes(self) -> list[str] | None:
|
||||
"""Return the available fan modes."""
|
||||
if self.available_fan_modes is None or self.available_fan_modes.details is None:
|
||||
return []
|
||||
|
||||
fan_modes = []
|
||||
for item in self.available_fan_modes.details:
|
||||
if item is not None:
|
||||
ha_fan_mode = COMPIT_FANSPEED_MAP.get(CompitFanMode(item.state))
|
||||
if ha_fan_mode and ha_fan_mode not in fan_modes:
|
||||
fan_modes.append(ha_fan_mode)
|
||||
|
||||
return fan_modes
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
preset_mode = self.get_parameter_value(CompitParameter.PRESET_MODE)
|
||||
|
||||
if preset_mode:
|
||||
compit_preset_mode = CompitPresetMode(preset_mode.value)
|
||||
return COMPIT_PRESET_MAP.get(compit_preset_mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
fan_mode = self.get_parameter_value(CompitParameter.FAN_MODE)
|
||||
if fan_mode:
|
||||
compit_fan_mode = CompitFanMode(fan_mode.value)
|
||||
return COMPIT_FANSPEED_MAP.get(compit_fan_mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
hvac_mode = self.get_parameter_value(CompitParameter.HVAC_MODE)
|
||||
if hvac_mode:
|
||||
compit_hvac_mode = CompitHVACMode(hvac_mode.value)
|
||||
return COMPIT_MODE_MAP.get(compit_hvac_mode)
|
||||
return None
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
if temp is None:
|
||||
raise ServiceValidationError("Temperature argument missing")
|
||||
await self.set_parameter_value(CompitParameter.SET_TARGET_TEMPERATURE, temp)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target HVAC mode."""
|
||||
|
||||
if not (mode := HVAC_MODE_TO_COMPIT_MODE.get(hvac_mode)):
|
||||
raise ServiceValidationError(f"Invalid hvac mode {hvac_mode}")
|
||||
|
||||
await self.set_parameter_value(CompitParameter.HVAC_MODE, mode.value)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target preset mode."""
|
||||
|
||||
compit_preset = PRESET_MODE_TO_COMPIT_PRESET_MODE.get(preset_mode)
|
||||
if compit_preset is None:
|
||||
raise ServiceValidationError(f"Invalid preset mode: {preset_mode}")
|
||||
|
||||
await self.set_parameter_value(CompitParameter.PRESET_MODE, compit_preset.value)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
|
||||
compit_fan_mode = FAN_MODE_TO_COMPIT_FAN_MODE.get(fan_mode)
|
||||
if compit_fan_mode is None:
|
||||
raise ServiceValidationError(f"Invalid fan mode: {fan_mode}")
|
||||
|
||||
await self.set_parameter_value(CompitParameter.FAN_MODE, compit_fan_mode.value)
|
||||
|
||||
async def set_parameter_value(self, parameter: CompitParameter, value: int) -> None:
|
||||
"""Call the API to set a parameter to a new value."""
|
||||
await self.coordinator.connector.set_device_parameter(
|
||||
self.device_id, parameter, value
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def get_parameter_value(self, parameter: CompitParameter) -> Param | None:
|
||||
"""Get the parameter value from the device state."""
|
||||
return self.coordinator.connector.get_device_parameter(
|
||||
self.device_id, parameter
|
||||
)
|
@@ -1,110 +0,0 @@
|
||||
"""Config flow for Compit integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from compit_inext_api import CannotConnect, CompitApiConnector, InvalidAuth
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CompitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Compit."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
session = async_create_clientsession(self.hass)
|
||||
api = CompitApiConnector(session)
|
||||
success = False
|
||||
try:
|
||||
success = await api.init(
|
||||
user_input[CONF_EMAIL],
|
||||
user_input[CONF_PASSWORD],
|
||||
self.hass.config.language,
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if not success:
|
||||
# Api returned unexpected result but no exception
|
||||
_LOGGER.error("Compit api returned unexpected result")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_EMAIL])
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_EMAIL], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle re-auth."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm re-authentication."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
reauth_entry_data = reauth_entry.data
|
||||
|
||||
if user_input:
|
||||
# Reuse async_step_user with combined credentials
|
||||
return await self.async_step_user(
|
||||
{
|
||||
CONF_EMAIL: reauth_entry_data[CONF_EMAIL],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_SCHEMA,
|
||||
description_placeholders={CONF_EMAIL: reauth_entry_data[CONF_EMAIL]},
|
||||
errors=errors,
|
||||
)
|
@@ -1,4 +0,0 @@
|
||||
"""Constants for the Compit integration."""
|
||||
|
||||
DOMAIN = "compit"
|
||||
MANUFACTURER_NAME = "Compit"
|
@@ -1,43 +0,0 @@
|
||||
"""Define an object to manage fetching Compit data."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from compit_inext_api import CompitApiConnector, DeviceInstance
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
type CompitConfigEntry = ConfigEntry[CompitDataUpdateCoordinator]
|
||||
|
||||
|
||||
class CompitDataUpdateCoordinator(DataUpdateCoordinator[dict[int, DeviceInstance]]):
|
||||
"""Class to manage fetching data from the API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
connector: CompitApiConnector,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.connector = connector
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[int, DeviceInstance]:
|
||||
"""Update data via library."""
|
||||
await self.connector.update_state(device_id=None) # Update all devices
|
||||
return self.connector.all_devices
|
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "compit",
|
||||
"name": "Compit",
|
||||
"codeowners": ["@Przemko92"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/compit",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.3.1"]
|
||||
}
|
@@ -1,86 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use any common modules.
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly 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
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service and does not support discovery.
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any entities that should disabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no need for icon translations.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: done
|
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Please enter your https://inext.compit.pl/ credentials.",
|
||||
"title": "Connect to Compit iNext",
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "The email address of your inext.compit.pl account",
|
||||
"password": "The password of your inext.compit.pl account"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "Please update your password for {email}",
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::compit::config::step::user::data_description::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -8,13 +8,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Context,
|
||||
HomeAssistant,
|
||||
async_get_hass,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent, singleton
|
||||
|
||||
@@ -36,7 +30,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .default_agent import DefaultAgent
|
||||
from .trigger import TriggerDetails
|
||||
|
||||
|
||||
@singleton.singleton("conversation_agent")
|
||||
@@ -147,7 +140,6 @@ class AgentManager:
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self.default_agent: DefaultAgent | None = None
|
||||
self.triggers_details: list[TriggerDetails] = []
|
||||
|
||||
@callback
|
||||
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
|
||||
@@ -199,20 +191,4 @@ class AgentManager:
|
||||
|
||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||
"""Set up the default agent."""
|
||||
agent.update_triggers(self.triggers_details)
|
||||
self.default_agent = agent
|
||||
|
||||
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
|
||||
"""Register a trigger."""
|
||||
self.triggers_details.append(trigger_details)
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_triggers(self.triggers_details)
|
||||
|
||||
@callback
|
||||
def unregister_trigger() -> None:
|
||||
"""Unregister the trigger."""
|
||||
self.triggers_details.remove(trigger_details)
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_triggers(self.triggers_details)
|
||||
|
||||
return unregister_trigger
|
||||
|
@@ -4,11 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import IO, Any, cast
|
||||
|
||||
@@ -51,7 +53,6 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
|
||||
from homeassistant.core import Event, callback
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
@@ -73,16 +74,17 @@ from .const import DOMAIN, ConversationEntityFeature
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
from .trigger import TriggerDetails
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
||||
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
|
||||
|
||||
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
|
||||
REGEX_TYPE = type(re.compile(""))
|
||||
TRIGGER_CALLBACK_TYPE = Callable[
|
||||
[ConversationInput, RecognizeResult], Awaitable[str | None]
|
||||
]
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
|
||||
@@ -108,6 +110,14 @@ class LanguageIntents:
|
||||
fuzzy_responses: FuzzyLanguageResponses | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TriggerData:
|
||||
"""List of sentences and the callback for a trigger."""
|
||||
|
||||
sentences: list[str]
|
||||
callback: TRIGGER_CALLBACK_TYPE
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SentenceTriggerResult:
|
||||
"""Result when matching a sentence trigger in an automation."""
|
||||
@@ -143,8 +153,8 @@ class IntentCacheKey:
|
||||
language: str
|
||||
"""Language of text."""
|
||||
|
||||
satellite_id: str | None
|
||||
"""Satellite id from user input."""
|
||||
device_id: str | None
|
||||
"""Device id from user input."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -230,23 +240,21 @@ class DefaultAgent(ConversationEntity):
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# intent -> [sentences]
|
||||
self._config_intents: dict[str, Any] = config_intents
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self._triggers_details: list[TriggerDetails] = []
|
||||
self._trigger_intents: Intents | None = None
|
||||
|
||||
# Slot lists for entities, areas, etc.
|
||||
self._slot_lists: dict[str, SlotList] | None = None
|
||||
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
|
||||
|
||||
# Used to filter slot lists before intent matching
|
||||
self._exposed_names_trie: Trie | None = None
|
||||
self._unexposed_names_trie: Trie | None = None
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self.trigger_sentences: list[TriggerData] = []
|
||||
self._trigger_intents: Intents | None = None
|
||||
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# LRU cache to avoid unnecessary intent matching
|
||||
self._intent_cache = IntentCache(capacity=128)
|
||||
|
||||
@@ -435,15 +443,9 @@ class DefaultAgent(ConversationEntity):
|
||||
}
|
||||
for entity in result.entities_list
|
||||
}
|
||||
|
||||
satellite_id = user_input.satellite_id
|
||||
device_id = user_input.device_id
|
||||
satellite_area, device_id = self._get_satellite_area_and_device(
|
||||
satellite_id, device_id
|
||||
)
|
||||
if satellite_area is not None:
|
||||
slots["preferred_area_id"] = {"value": satellite_area.id}
|
||||
|
||||
device_area = self._get_device_area(user_input.device_id)
|
||||
if device_area:
|
||||
slots["preferred_area_id"] = {"value": device_area.id}
|
||||
async_conversation_trace_append(
|
||||
ConversationTraceEventType.TOOL_CALL,
|
||||
{
|
||||
@@ -465,8 +467,8 @@ class DefaultAgent(ConversationEntity):
|
||||
user_input.context,
|
||||
language,
|
||||
assistant=DOMAIN,
|
||||
device_id=device_id,
|
||||
satellite_id=satellite_id,
|
||||
device_id=user_input.device_id,
|
||||
satellite_id=user_input.satellite_id,
|
||||
conversation_agent_id=user_input.agent_id,
|
||||
)
|
||||
except intent.MatchFailedError as match_error:
|
||||
@@ -532,9 +534,7 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
# Try cache first
|
||||
cache_key = IntentCacheKey(
|
||||
text=user_input.text,
|
||||
language=language,
|
||||
satellite_id=user_input.satellite_id,
|
||||
text=user_input.text, language=language, device_id=user_input.device_id
|
||||
)
|
||||
cache_value = self._intent_cache.get(cache_key)
|
||||
if cache_value is not None:
|
||||
@@ -1190,8 +1190,8 @@ class DefaultAgent(ConversationEntity):
|
||||
fuzzy_responses=fuzzy_responses,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_clear_slot_list(self, event: Event[Any] | None = None) -> None:
|
||||
@core.callback
|
||||
def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None:
|
||||
"""Clear slot lists when a registry has changed."""
|
||||
# Two subscribers can be scheduled at same time
|
||||
_LOGGER.debug("Clearing slot lists")
|
||||
@@ -1304,40 +1304,28 @@ class DefaultAgent(ConversationEntity):
|
||||
self, user_input: ConversationInput
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return intent recognition context for user input."""
|
||||
satellite_area, _ = self._get_satellite_area_and_device(
|
||||
user_input.satellite_id, user_input.device_id
|
||||
)
|
||||
if satellite_area is None:
|
||||
if not user_input.device_id:
|
||||
return None
|
||||
|
||||
return {"area": {"value": satellite_area.name, "text": satellite_area.name}}
|
||||
device_area = self._get_device_area(user_input.device_id)
|
||||
if device_area is None:
|
||||
return None
|
||||
|
||||
def _get_satellite_area_and_device(
|
||||
self, satellite_id: str | None, device_id: str | None = None
|
||||
) -> tuple[ar.AreaEntry | None, str | None]:
|
||||
"""Return area entry and device id."""
|
||||
hass = self.hass
|
||||
return {"area": {"value": device_area.name, "text": device_area.name}}
|
||||
|
||||
area_id: str | None = None
|
||||
def _get_device_area(self, device_id: str | None) -> ar.AreaEntry | None:
|
||||
"""Return area object for given device identifier."""
|
||||
if device_id is None:
|
||||
return None
|
||||
|
||||
if (
|
||||
satellite_id is not None
|
||||
and (entity_entry := er.async_get(hass).async_get(satellite_id)) is not None
|
||||
):
|
||||
area_id = entity_entry.area_id
|
||||
device_id = entity_entry.device_id
|
||||
devices = dr.async_get(self.hass)
|
||||
device = devices.async_get(device_id)
|
||||
if (device is None) or (device.area_id is None):
|
||||
return None
|
||||
|
||||
if (
|
||||
area_id is None
|
||||
and device_id is not None
|
||||
and (device_entry := dr.async_get(hass).async_get(device_id)) is not None
|
||||
):
|
||||
area_id = device_entry.area_id
|
||||
areas = ar.async_get(self.hass)
|
||||
|
||||
if area_id is None:
|
||||
return None, device_id
|
||||
|
||||
return ar.async_get(hass).async_get_area(area_id), device_id
|
||||
return areas.async_get_area(device.area_id)
|
||||
|
||||
def _get_error_text(
|
||||
self,
|
||||
@@ -1361,14 +1349,22 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
return response_template.async_render(response_args)
|
||||
|
||||
@callback
|
||||
def update_triggers(self, triggers_details: list[TriggerDetails]) -> None:
|
||||
"""Update triggers."""
|
||||
self._triggers_details = triggers_details
|
||||
@core.callback
|
||||
def register_trigger(
|
||||
self,
|
||||
sentences: list[str],
|
||||
callback: TRIGGER_CALLBACK_TYPE,
|
||||
) -> core.CALLBACK_TYPE:
|
||||
"""Register a list of sentences that will trigger a callback when recognized."""
|
||||
trigger_data = TriggerData(sentences=sentences, callback=callback)
|
||||
self.trigger_sentences.append(trigger_data)
|
||||
|
||||
# Force rebuild on next use
|
||||
self._trigger_intents = None
|
||||
|
||||
return functools.partial(self._unregister_trigger, trigger_data)
|
||||
|
||||
@core.callback
|
||||
def _rebuild_trigger_intents(self) -> None:
|
||||
"""Rebuild the HassIL intents object from the current trigger sentences."""
|
||||
intents_dict = {
|
||||
@@ -1377,8 +1373,8 @@ class DefaultAgent(ConversationEntity):
|
||||
# Use trigger data index as a virtual intent name for HassIL.
|
||||
# This works because the intents are rebuilt on every
|
||||
# register/unregister.
|
||||
str(trigger_id): {"data": [{"sentences": trigger_details.sentences}]}
|
||||
for trigger_id, trigger_details in enumerate(self._triggers_details)
|
||||
str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]}
|
||||
for trigger_id, trigger_data in enumerate(self.trigger_sentences)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1398,6 +1394,14 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
_LOGGER.debug("Rebuilt trigger intents: %s", intents_dict)
|
||||
|
||||
@core.callback
|
||||
def _unregister_trigger(self, trigger_data: TriggerData) -> None:
|
||||
"""Unregister a set of trigger sentences."""
|
||||
self.trigger_sentences.remove(trigger_data)
|
||||
|
||||
# Force rebuild on next use
|
||||
self._trigger_intents = None
|
||||
|
||||
async def async_recognize_sentence_trigger(
|
||||
self, user_input: ConversationInput
|
||||
) -> SentenceTriggerResult | None:
|
||||
@@ -1406,7 +1410,7 @@ class DefaultAgent(ConversationEntity):
|
||||
Calls the registered callbacks if there's a match and returns a sentence
|
||||
trigger result.
|
||||
"""
|
||||
if not self._triggers_details:
|
||||
if not self.trigger_sentences:
|
||||
# No triggers registered
|
||||
return None
|
||||
|
||||
@@ -1451,7 +1455,7 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
# Gather callback responses in parallel
|
||||
trigger_callbacks = [
|
||||
self._triggers_details[trigger_id].callback(user_input, trigger_result)
|
||||
self.trigger_sentences[trigger_id].callback(user_input, trigger_result)
|
||||
for trigger_id, trigger_result in result.matched_triggers.items()
|
||||
]
|
||||
|
||||
|
@@ -169,11 +169,12 @@ async def websocket_list_sentences(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""List custom registered sentences."""
|
||||
manager = get_agent_manager(hass)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
sentences = []
|
||||
for trigger_details in manager.triggers_details:
|
||||
sentences.extend(trigger_details.sentences)
|
||||
for trigger_data in agent.trigger_sentences:
|
||||
sentences.extend(trigger_data.sentences)
|
||||
|
||||
connection.send_result(msg["id"], {"trigger_sentences": sentences})
|
||||
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"]
|
||||
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"]
|
||||
}
|
||||
|
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hassil.recognize import RecognizeResult
|
||||
@@ -17,7 +15,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
|
||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.script import ScriptRunResult
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType
|
||||
@@ -26,18 +24,6 @@ from .agent_manager import get_agent_manager
|
||||
from .const import DOMAIN
|
||||
from .models import ConversationInput
|
||||
|
||||
TRIGGER_CALLBACK_TYPE = Callable[
|
||||
[ConversationInput, RecognizeResult], Awaitable[str | None]
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TriggerDetails:
|
||||
"""List of sentences and the callback for a trigger."""
|
||||
|
||||
sentences: list[str]
|
||||
callback: TRIGGER_CALLBACK_TYPE
|
||||
|
||||
|
||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
"""Validate result does not contain punctuation."""
|
||||
@@ -85,8 +71,6 @@ async def async_attach_trigger(
|
||||
trigger_data = trigger_info["trigger_data"]
|
||||
sentences = config.get(CONF_COMMAND, [])
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
job = HassJob(action)
|
||||
|
||||
async def call_action(
|
||||
@@ -108,14 +92,6 @@ async def async_attach_trigger(
|
||||
for entity_name, entity in result.entities.items()
|
||||
}
|
||||
|
||||
satellite_id = user_input.satellite_id
|
||||
device_id = user_input.device_id
|
||||
if (
|
||||
satellite_id is not None
|
||||
and (satellite_entry := ent_reg.async_get(satellite_id)) is not None
|
||||
):
|
||||
device_id = satellite_entry.device_id
|
||||
|
||||
trigger_input: dict[str, Any] = { # Satisfy type checker
|
||||
**trigger_data,
|
||||
"platform": DOMAIN,
|
||||
@@ -124,8 +100,8 @@ async def async_attach_trigger(
|
||||
"slots": { # direct access to values
|
||||
entity_name: entity["value"] for entity_name, entity in details.items()
|
||||
},
|
||||
"device_id": device_id,
|
||||
"satellite_id": satellite_id,
|
||||
"device_id": user_input.device_id,
|
||||
"satellite_id": user_input.satellite_id,
|
||||
"user_input": user_input.as_dict(),
|
||||
}
|
||||
|
||||
@@ -148,6 +124,6 @@ async def async_attach_trigger(
|
||||
# two trigger copies for who will provide a response.
|
||||
return None
|
||||
|
||||
return get_agent_manager(hass).register_trigger(
|
||||
TriggerDetails(sentences=sentences, callback=call_action)
|
||||
)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
return agent.register_trigger(sentences, call_action)
|
||||
|
@@ -1,58 +0,0 @@
|
||||
"""The Cync integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pycync import Auth, Cync, User
|
||||
from pycync.exceptions import AuthFailedError, CyncError
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_AUTHORIZE_STRING,
|
||||
CONF_EXPIRES_AT,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_USER_ID,
|
||||
)
|
||||
from .coordinator import CyncConfigEntry, CyncCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool:
|
||||
"""Set up Cync from a config entry."""
|
||||
user_info = User(
|
||||
entry.data[CONF_ACCESS_TOKEN],
|
||||
entry.data[CONF_REFRESH_TOKEN],
|
||||
entry.data[CONF_AUTHORIZE_STRING],
|
||||
entry.data[CONF_USER_ID],
|
||||
expires_at=entry.data[CONF_EXPIRES_AT],
|
||||
)
|
||||
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
|
||||
|
||||
try:
|
||||
cync = await Cync.create(cync_auth)
|
||||
except AuthFailedError as ex:
|
||||
raise ConfigEntryAuthFailed("User token invalid") from ex
|
||||
except CyncError as ex:
|
||||
raise ConfigEntryNotReady("Unable to connect to Cync") from ex
|
||||
|
||||
devices_coordinator = CyncCoordinator(hass, entry, cync)
|
||||
|
||||
cync.set_update_callback(devices_coordinator.on_data_update)
|
||||
|
||||
await devices_coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = devices_coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
cync = entry.runtime_data.cync
|
||||
await cync.shut_down()
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
@@ -1,118 +0,0 @@
|
||||
"""Config flow for the Cync integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pycync import Auth
|
||||
from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_AUTHORIZE_STRING,
|
||||
CONF_EXPIRES_AT,
|
||||
CONF_REFRESH_TOKEN,
|
||||
CONF_TWO_FACTOR_CODE,
|
||||
CONF_USER_ID,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_EMAIL): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_TWO_FACTOR_SCHEMA = vol.Schema({vol.Required(CONF_TWO_FACTOR_CODE): str})
|
||||
|
||||
|
||||
class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Cync."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
cync_auth: Auth
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Attempt login with user credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
self.cync_auth = Auth(
|
||||
async_get_clientsession(self.hass),
|
||||
username=user_input[CONF_EMAIL],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
await self.cync_auth.login()
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except TwoFactorRequiredError:
|
||||
return await self.async_step_two_factor()
|
||||
except CyncError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_two_factor(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Attempt login with the two factor auth code sent to the user."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
|
||||
)
|
||||
try:
|
||||
await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE])
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CyncError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._create_config_entry(self.cync_auth.username)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def _create_config_entry(self, user_email: str) -> ConfigFlowResult:
|
||||
"""Create the Cync config entry using input user data."""
|
||||
|
||||
cync_user = self.cync_auth.user
|
||||
await self.async_set_unique_id(str(cync_user.user_id))
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
config = {
|
||||
CONF_USER_ID: cync_user.user_id,
|
||||
CONF_AUTHORIZE_STRING: cync_user.authorize,
|
||||
CONF_EXPIRES_AT: cync_user.expires_at,
|
||||
CONF_ACCESS_TOKEN: cync_user.access_token,
|
||||
CONF_REFRESH_TOKEN: cync_user.refresh_token,
|
||||
}
|
||||
return self.async_create_entry(title=user_email, data=config)
|
@@ -1,9 +0,0 @@
|
||||
"""Constants for the Cync integration."""
|
||||
|
||||
DOMAIN = "cync"
|
||||
|
||||
CONF_TWO_FACTOR_CODE = "two_factor_code"
|
||||
CONF_USER_ID = "user_id"
|
||||
CONF_AUTHORIZE_STRING = "authorize_string"
|
||||
CONF_EXPIRES_AT = "expires_at"
|
||||
CONF_REFRESH_TOKEN = "refresh_token"
|
@@ -1,87 +0,0 @@
|
||||
"""Coordinator to handle keeping device states up to date."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import time
|
||||
|
||||
from pycync import Cync, CyncDevice, User
|
||||
from pycync.exceptions import AuthFailedError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import CONF_EXPIRES_AT, CONF_REFRESH_TOKEN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type CyncConfigEntry = ConfigEntry[CyncCoordinator]
|
||||
|
||||
|
||||
class CyncCoordinator(DataUpdateCoordinator[dict[int, CyncDevice]]):
|
||||
"""Coordinator to handle updating Cync device states."""
|
||||
|
||||
config_entry: CyncConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: CyncConfigEntry, cync: Cync
|
||||
) -> None:
|
||||
"""Initialize the Cync coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="Cync Data Coordinator",
|
||||
config_entry=config_entry,
|
||||
update_interval=timedelta(seconds=30),
|
||||
always_update=True,
|
||||
)
|
||||
self.cync = cync
|
||||
|
||||
async def on_data_update(self, data: dict[int, CyncDevice]) -> None:
|
||||
"""Update registered devices with new data."""
|
||||
merged_data = self.data | data if self.data else data
|
||||
self.async_set_updated_data(merged_data)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator with initial device states."""
|
||||
logged_in_user = self.cync.get_logged_in_user()
|
||||
if logged_in_user.access_token != self.config_entry.data[CONF_ACCESS_TOKEN]:
|
||||
await self._update_config_cync_credentials(logged_in_user)
|
||||
|
||||
async def _async_update_data(self) -> dict[int, CyncDevice]:
|
||||
"""First, refresh the user's auth token if it is set to expire in less than one hour.
|
||||
|
||||
Then, fetch all current device states.
|
||||
"""
|
||||
|
||||
logged_in_user = self.cync.get_logged_in_user()
|
||||
if logged_in_user.expires_at - time.time() < 3600:
|
||||
await self._async_refresh_cync_credentials()
|
||||
|
||||
self.cync.update_device_states()
|
||||
current_device_states = self.cync.get_devices()
|
||||
|
||||
return {device.device_id: device for device in current_device_states}
|
||||
|
||||
async def _async_refresh_cync_credentials(self) -> None:
|
||||
"""Attempt to refresh the Cync user's authentication token."""
|
||||
|
||||
try:
|
||||
refreshed_user = await self.cync.refresh_credentials()
|
||||
except AuthFailedError as ex:
|
||||
raise ConfigEntryAuthFailed("Unable to refresh user token") from ex
|
||||
else:
|
||||
await self._update_config_cync_credentials(refreshed_user)
|
||||
|
||||
async def _update_config_cync_credentials(self, user_info: User) -> None:
|
||||
"""Update the config entry with current user info."""
|
||||
|
||||
new_data = {**self.config_entry.data}
|
||||
new_data[CONF_ACCESS_TOKEN] = user_info.access_token
|
||||
new_data[CONF_REFRESH_TOKEN] = user_info.refresh_token
|
||||
new_data[CONF_EXPIRES_AT] = user_info.expires_at
|
||||
self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
|
@@ -1,45 +0,0 @@
|
||||
"""Setup for a generic entity type for the Cync integration."""
|
||||
|
||||
from pycync.devices import CyncDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CyncCoordinator
|
||||
|
||||
|
||||
class CyncBaseEntity(CoordinatorEntity[CyncCoordinator]):
|
||||
"""Generic base entity for Cync devices."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CyncDevice,
|
||||
coordinator: CyncCoordinator,
|
||||
room_name: str | None = None,
|
||||
) -> None:
|
||||
"""Pass coordinator to CoordinatorEntity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._cync_device_id = device.device_id
|
||||
self._attr_unique_id = device.unique_id
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.unique_id)},
|
||||
manufacturer="GE Lighting",
|
||||
name=device.name,
|
||||
suggested_area=room_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Determines whether this device is currently available."""
|
||||
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data is not None
|
||||
and self._cync_device_id in self.coordinator.data
|
||||
and self.coordinator.data[self._cync_device_id].is_online
|
||||
)
|
@@ -1,180 +0,0 @@
|
||||
"""Support for Cync light entities."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pycync import CyncLight
|
||||
from pycync.devices.capabilities import CyncCapability
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
filter_supported_color_modes,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.color import value_to_brightness
|
||||
from homeassistant.util.scaling import scale_ranged_value_to_int_range
|
||||
|
||||
from .coordinator import CyncConfigEntry, CyncCoordinator
|
||||
from .entity import CyncBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CyncConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Cync lights from a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
cync = coordinator.cync
|
||||
|
||||
entities_to_add = []
|
||||
|
||||
for home in cync.get_homes():
|
||||
for room in home.rooms:
|
||||
room_lights = [
|
||||
CyncLightEntity(device, coordinator, room.name)
|
||||
for device in room.devices
|
||||
if isinstance(device, CyncLight)
|
||||
]
|
||||
entities_to_add.extend(room_lights)
|
||||
|
||||
group_lights = [
|
||||
CyncLightEntity(device, coordinator, room.name)
|
||||
for group in room.groups
|
||||
for device in group.devices
|
||||
if isinstance(device, CyncLight)
|
||||
]
|
||||
entities_to_add.extend(group_lights)
|
||||
|
||||
async_add_entities(entities_to_add)
|
||||
|
||||
|
||||
class CyncLightEntity(CyncBaseEntity, LightEntity):
|
||||
"""Representation of a Cync light."""
|
||||
|
||||
_attr_color_mode = ColorMode.ONOFF
|
||||
_attr_min_color_temp_kelvin = 2000
|
||||
_attr_max_color_temp_kelvin = 7000
|
||||
_attr_translation_key = "light"
|
||||
_attr_name = None
|
||||
|
||||
BRIGHTNESS_SCALE = (0, 100)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CyncLight,
|
||||
coordinator: CyncCoordinator,
|
||||
room_name: str | None = None,
|
||||
) -> None:
|
||||
"""Set up base attributes."""
|
||||
super().__init__(device, coordinator, room_name)
|
||||
|
||||
supported_color_modes = {ColorMode.ONOFF}
|
||||
if device.supports_capability(CyncCapability.CCT_COLOR):
|
||||
supported_color_modes.add(ColorMode.COLOR_TEMP)
|
||||
if device.supports_capability(CyncCapability.DIMMING):
|
||||
supported_color_modes.add(ColorMode.BRIGHTNESS)
|
||||
if device.supports_capability(CyncCapability.RGB_COLOR):
|
||||
supported_color_modes.add(ColorMode.RGB)
|
||||
self._attr_supported_color_modes = filter_supported_color_modes(
|
||||
supported_color_modes
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if the light is on."""
|
||||
return self._device.is_on
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
"""Provide the light's current brightness."""
|
||||
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int:
|
||||
"""Return color temperature in kelvin."""
|
||||
return scale_ranged_value_to_int_range(
|
||||
(1, 100),
|
||||
(self.min_color_temp_kelvin, self.max_color_temp_kelvin),
|
||||
self._device.color_temp,
|
||||
)
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int]:
|
||||
"""Provide the light's current color in RGB format."""
|
||||
return self._device.rgb
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str | None:
|
||||
"""Return the active color mode."""
|
||||
|
||||
if (
|
||||
self._device.supports_capability(CyncCapability.CCT_COLOR)
|
||||
and self._device.color_mode > 0
|
||||
and self._device.color_mode <= 100
|
||||
):
|
||||
return ColorMode.COLOR_TEMP
|
||||
if (
|
||||
self._device.supports_capability(CyncCapability.RGB_COLOR)
|
||||
and self._device.color_mode == 254
|
||||
):
|
||||
return ColorMode.RGB
|
||||
if self._device.supports_capability(CyncCapability.DIMMING):
|
||||
return ColorMode.BRIGHTNESS
|
||||
|
||||
return ColorMode.ONOFF
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Process an action on the light."""
|
||||
if not kwargs:
|
||||
await self._device.turn_on()
|
||||
|
||||
elif kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None:
|
||||
color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
|
||||
converted_color_temp = self._normalize_color_temp(color_temp)
|
||||
|
||||
await self._device.set_color_temp(converted_color_temp)
|
||||
elif kwargs.get(ATTR_RGB_COLOR) is not None:
|
||||
rgb = kwargs.get(ATTR_RGB_COLOR)
|
||||
|
||||
await self._device.set_rgb(rgb)
|
||||
elif kwargs.get(ATTR_BRIGHTNESS) is not None:
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
converted_brightness = self._normalize_brightness(brightness)
|
||||
|
||||
await self._device.set_brightness(converted_brightness)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
await self._device.turn_off()
|
||||
|
||||
def _normalize_brightness(self, brightness: float | None) -> int | None:
|
||||
"""Return calculated brightness value scaled between 0-100."""
|
||||
if brightness is not None:
|
||||
return int((brightness / 255) * 100)
|
||||
|
||||
return None
|
||||
|
||||
def _normalize_color_temp(self, color_temp_kelvin: float | None) -> int | None:
|
||||
"""Return calculated color temp value scaled between 1-100."""
|
||||
if color_temp_kelvin is not None:
|
||||
kelvin_range = self.max_color_temp_kelvin - self.min_color_temp_kelvin
|
||||
scaled_kelvin = int(
|
||||
((color_temp_kelvin - self.min_color_temp_kelvin) / kelvin_range) * 100
|
||||
)
|
||||
if scaled_kelvin == 0:
|
||||
scaled_kelvin += 1
|
||||
|
||||
return scaled_kelvin
|
||||
return None
|
||||
|
||||
@property
|
||||
def _device(self) -> CyncLight:
|
||||
"""Fetch the reference to the backing Cync light for this device."""
|
||||
|
||||
return self.coordinator.data[self._cync_device_id]
|
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "cync",
|
||||
"name": "Cync",
|
||||
"codeowners": ["@Kinachi249"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cync",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycync==0.4.0"]
|
||||
}
|
@@ -1,69 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
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: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"email": "Your Cync account's email address",
|
||||
"password": "Your Cync account's password"
|
||||
}
|
||||
},
|
||||
"two_factor": {
|
||||
"data": {
|
||||
"two_factor_code": "Two-factor code"
|
||||
},
|
||||
"data_description": {
|
||||
"two_factor_code": "The two-factor code sent to your Cync account's email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -9,7 +9,6 @@
|
||||
"conversation",
|
||||
"dhcp",
|
||||
"energy",
|
||||
"file",
|
||||
"go2rtc",
|
||||
"history",
|
||||
"homeassistant_alerts",
|
||||
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/droplet",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pydroplet==2.3.3"],
|
||||
"requirements": ["pydroplet==2.3.2"],
|
||||
"zeroconf": ["_droplet._tcp.local."]
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==14.0.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.7.0"]
|
||||
}
|
||||
|
@@ -5,11 +5,9 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from deebot_client.capabilities import CapabilityNumber, CapabilitySet
|
||||
from deebot_client.device import Device
|
||||
from deebot_client.capabilities import CapabilitySet
|
||||
from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent
|
||||
from deebot_client.events.base import Event
|
||||
from deebot_client.events.water_info import WaterCustomAmountEvent
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
@@ -77,19 +75,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = (
|
||||
native_step=1.0,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
EcovacsNumberEntityDescription[WaterCustomAmountEvent](
|
||||
capability_fn=lambda caps: (
|
||||
caps.water.amount
|
||||
if caps.water and isinstance(caps.water.amount, CapabilityNumber)
|
||||
else None
|
||||
),
|
||||
value_fn=lambda e: e.value,
|
||||
key="water_amount",
|
||||
translation_key="water_amount",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_step=1.0,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -115,18 +100,6 @@ class EcovacsNumberEntity[EventT: Event](
|
||||
|
||||
entity_description: EcovacsNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: Device,
|
||||
capability: CapabilitySet[EventT, [int]],
|
||||
entity_description: EcovacsNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
super().__init__(device, capability, entity_description)
|
||||
if isinstance(capability, CapabilityNumber):
|
||||
self._attr_native_min_value = capability.min
|
||||
self._attr_native_max_value = capability.max
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up the event listeners now that hass is ready."""
|
||||
await super().async_added_to_hass()
|
||||
|
@@ -33,11 +33,7 @@ class EcovacsSelectEntityDescription[EventT: Event](
|
||||
|
||||
ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = (
|
||||
EcovacsSelectEntityDescription[WaterAmountEvent](
|
||||
capability_fn=lambda caps: (
|
||||
caps.water.amount
|
||||
if caps.water and isinstance(caps.water.amount, CapabilitySetTypes)
|
||||
else None
|
||||
),
|
||||
capability_fn=lambda caps: caps.water.amount if caps.water else None,
|
||||
current_option_fn=lambda e: get_name_key(e.value),
|
||||
options_fn=lambda water: [get_name_key(amount) for amount in water.types],
|
||||
key="water_amount",
|
||||
|
@@ -102,9 +102,6 @@
|
||||
},
|
||||
"volume": {
|
||||
"name": "Volume"
|
||||
},
|
||||
"water_amount": {
|
||||
"name": "Water flow level"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -155,10 +152,8 @@
|
||||
"station_state": {
|
||||
"name": "Station state",
|
||||
"state": {
|
||||
"drying_mop": "Drying mop",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"emptying_dustbin": "Emptying dustbin",
|
||||
"washing_mop": "Washing mop"
|
||||
"emptying_dustbin": "Emptying dustbin"
|
||||
}
|
||||
},
|
||||
"stats_area": {
|
||||
@@ -179,7 +174,7 @@
|
||||
},
|
||||
"select": {
|
||||
"water_amount": {
|
||||
"name": "[%key:component::ecovacs::entity::number::water_amount::name%]",
|
||||
"name": "Water flow level",
|
||||
"state": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
|
@@ -7,6 +7,8 @@ import random
|
||||
import string
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deebot_client.events.station import State
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import slugify
|
||||
|
||||
@@ -47,6 +49,9 @@ def get_supported_entities(
|
||||
@callback
|
||||
def get_name_key(enum: Enum) -> str:
|
||||
"""Return the lower case name of the enum."""
|
||||
if enum is State.EMPTYING:
|
||||
# Will be fixed in the next major release of deebot-client
|
||||
return "emptying_dustbin"
|
||||
return enum.name.lower()
|
||||
|
||||
|
||||
|
@@ -253,7 +253,6 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
),
|
||||
EcoWittSensorTypes.PM4: SensorEntityDescription(
|
||||
key="PM4",
|
||||
device_class=SensorDeviceClass.PM4,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
|
@@ -1,24 +0,0 @@
|
||||
"""The Ekey Bionyx integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.EVENT]
|
||||
|
||||
|
||||
type EkeyBionyxConfigEntry = ConfigEntry
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool:
|
||||
"""Set up the Ekey Bionyx config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
@@ -1,14 +0,0 @@
|
||||
"""application_credentials platform the Ekey Bionyx integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
return AuthorizationServer(
|
||||
authorize_url=OAUTH2_AUTHORIZE,
|
||||
token_url=OAUTH2_TOKEN,
|
||||
)
|
@@ -1,271 +0,0 @@
|
||||
"""Config flow for ekey bionyx."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
import aiohttp
|
||||
import ekey_bionyxpy
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.webhook import (
|
||||
async_generate_id as webhook_generate_id,
|
||||
async_generate_path as webhook_generate_path,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.helpers.selector import SelectOptionDict, SelectSelector
|
||||
|
||||
from .const import API_URL, DOMAIN, INTEGRATION_NAME, SCOPE
|
||||
|
||||
# Valid webhook name: starts with letter or underscore, contains letters, digits, spaces, dots, and underscores, does not end with space or dot
|
||||
VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$")
|
||||
|
||||
|
||||
class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth):
|
||||
"""ekey bionyx authentication before a ConfigEntry exists.
|
||||
|
||||
This implementation directly provides the token without supporting refresh.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: aiohttp.ClientSession,
|
||||
token: dict[str, Any],
|
||||
) -> None:
|
||||
"""Initialize ConfigFlowEkeyApi."""
|
||||
super().__init__(websession, API_URL)
|
||||
self._token = token
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return the token for the Ekey API."""
|
||||
return self._token["access_token"]
|
||||
|
||||
|
||||
class EkeyFlowData(TypedDict):
|
||||
"""Type for Flow Data."""
|
||||
|
||||
api: NotRequired[ekey_bionyxpy.BionyxAPI]
|
||||
system: NotRequired[ekey_bionyxpy.System]
|
||||
systems: NotRequired[list[ekey_bionyxpy.System]]
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
"""Config flow to handle ekey bionyx OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
check_deletion_task: asyncio.Task[None] | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize OAuth2FlowHandler."""
|
||||
super().__init__()
|
||||
self._data: EkeyFlowData = {}
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict[str, Any]:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {"scope": SCOPE}
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Start the user facing flow by initializing the API and getting the systems."""
|
||||
client = ConfigFlowEkeyApi(async_get_clientsession(self.hass), data[CONF_TOKEN])
|
||||
ap = ekey_bionyxpy.BionyxAPI(client)
|
||||
self._data["api"] = ap
|
||||
try:
|
||||
system_res = await ap.get_systems()
|
||||
except aiohttp.ClientResponseError:
|
||||
return self.async_abort(
|
||||
reason="cannot_connect",
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
)
|
||||
system = [s for s in system_res if s.own_system]
|
||||
if len(system) == 0:
|
||||
return self.async_abort(reason="no_own_systems")
|
||||
self._data["systems"] = system
|
||||
if len(system) == 1:
|
||||
# skipping choose_system since there is only one
|
||||
self._data["system"] = system[0]
|
||||
return await self.async_step_check_system(user_input=None)
|
||||
return await self.async_step_choose_system(user_input=None)
|
||||
|
||||
async def async_step_choose_system(
|
||||
self, user_input: dict[str, Any] | None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog to choose System if multiple systems are present."""
|
||||
if user_input is None:
|
||||
options: list[SelectOptionDict] = [
|
||||
{"value": s.system_id, "label": s.system_name}
|
||||
for s in self._data["systems"]
|
||||
]
|
||||
data_schema = {vol.Required("system"): SelectSelector({"options": options})}
|
||||
return self.async_show_form(
|
||||
step_id="choose_system",
|
||||
data_schema=vol.Schema(data_schema),
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
)
|
||||
self._data["system"] = [
|
||||
s for s in self._data["systems"] if s.system_id == user_input["system"]
|
||||
][0]
|
||||
return await self.async_step_check_system(user_input=None)
|
||||
|
||||
async def async_step_check_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Check if system has open webhooks."""
|
||||
system = self._data["system"]
|
||||
await self.async_set_unique_id(system.system_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if (
|
||||
system.function_webhook_quotas["free"] == 0
|
||||
and system.function_webhook_quotas["used"] == 0
|
||||
):
|
||||
return self.async_abort(
|
||||
reason="no_available_webhooks",
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
)
|
||||
|
||||
if system.function_webhook_quotas["used"] > 0:
|
||||
return await self.async_step_delete_webhooks()
|
||||
return await self.async_step_webhooks(user_input=None)
|
||||
|
||||
async def async_step_webhooks(
|
||||
self, user_input: dict[str, Any] | None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog to setup webhooks."""
|
||||
system = self._data["system"]
|
||||
|
||||
errors: dict[str, str] | None = None
|
||||
if user_input is not None:
|
||||
errors = {}
|
||||
for key, webhook_name in user_input.items():
|
||||
if key == CONF_URL:
|
||||
continue
|
||||
if not re.match(VALID_NAME_PATTERN, webhook_name):
|
||||
errors.update({key: "invalid_name"})
|
||||
try:
|
||||
cv.url(user_input[CONF_URL])
|
||||
except vol.Invalid:
|
||||
errors[CONF_URL] = "invalid_url"
|
||||
if set(user_input) == {CONF_URL}:
|
||||
errors["base"] = "no_webhooks_provided"
|
||||
|
||||
if not errors:
|
||||
webhook_data = [
|
||||
{
|
||||
"auth": secrets.token_hex(32),
|
||||
"name": webhook_name,
|
||||
"webhook_id": webhook_generate_id(),
|
||||
}
|
||||
for key, webhook_name in user_input.items()
|
||||
if key != CONF_URL
|
||||
]
|
||||
for webhook in webhook_data:
|
||||
wh_def: ekey_bionyxpy.WebhookData = {
|
||||
"integrationName": "Home Assistant",
|
||||
"functionName": webhook["name"],
|
||||
"locationName": "Home Assistant",
|
||||
"definition": {
|
||||
"url": user_input[CONF_URL]
|
||||
+ webhook_generate_path(webhook["webhook_id"]),
|
||||
"authentication": {"apiAuthenticationType": "None"},
|
||||
"securityLevel": "AllowHttp",
|
||||
"method": "Post",
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"content": json.dumps({"auth": webhook["auth"]}),
|
||||
},
|
||||
},
|
||||
}
|
||||
webhook["ekey_id"] = (await system.add_webhook(wh_def)).webhook_id
|
||||
return self.async_create_entry(
|
||||
title=self._data["system"].system_name,
|
||||
data={"webhooks": webhook_data},
|
||||
)
|
||||
|
||||
data_schema: dict[Any, Any] = {
|
||||
vol.Optional(f"webhook{i + 1}"): vol.All(str, vol.Length(max=50))
|
||||
for i in range(self._data["system"].function_webhook_quotas["free"])
|
||||
}
|
||||
data_schema[vol.Required(CONF_URL)] = str
|
||||
return self.async_show_form(
|
||||
step_id="webhooks",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(data_schema),
|
||||
{
|
||||
CONF_URL: get_url(
|
||||
self.hass,
|
||||
allow_ip=True,
|
||||
prefer_external=False,
|
||||
)
|
||||
}
|
||||
| (user_input or {}),
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"webhooks_available": str(
|
||||
self._data["system"].function_webhook_quotas["free"]
|
||||
),
|
||||
"ekeybionyx": INTEGRATION_NAME,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_delete_webhooks(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Form to delete Webhooks."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="delete_webhooks")
|
||||
for webhook in await self._data["system"].get_webhooks():
|
||||
await webhook.delete()
|
||||
return await self.async_step_wait_for_deletion(user_input=None)
|
||||
|
||||
async def async_step_wait_for_deletion(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Wait for webhooks to be deleted in another flow."""
|
||||
uncompleted_task: asyncio.Task[None] | None = None
|
||||
|
||||
if not self.check_deletion_task:
|
||||
self.check_deletion_task = self.hass.async_create_task(
|
||||
self.async_check_deletion_status()
|
||||
)
|
||||
if not self.check_deletion_task.done():
|
||||
progress_action = "check_deletion_status"
|
||||
uncompleted_task = self.check_deletion_task
|
||||
if uncompleted_task:
|
||||
return self.async_show_progress(
|
||||
step_id="wait_for_deletion",
|
||||
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
|
||||
progress_action=progress_action,
|
||||
progress_task=uncompleted_task,
|
||||
)
|
||||
self.check_deletion_task = None
|
||||
return self.async_show_progress_done(next_step_id="webhooks")
|
||||
|
||||
async def async_check_deletion_status(self) -> None:
|
||||
"""Check if webhooks have been deleted."""
|
||||
while True:
|
||||
self._data["systems"] = await self._data["api"].get_systems()
|
||||
self._data["system"] = [
|
||||
s
|
||||
for s in self._data["systems"]
|
||||
if s.system_id == self._data["system"].system_id
|
||||
][0]
|
||||
if self._data["system"].function_webhook_quotas["used"] == 0:
|
||||
break
|
||||
await asyncio.sleep(5)
|
@@ -1,13 +0,0 @@
|
||||
"""Constants for the Ekey Bionyx integration."""
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "ekeybionyx"
|
||||
INTEGRATION_NAME = "ekey bionyx"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/authorize"
|
||||
OAUTH2_TOKEN = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/token"
|
||||
API_URL = "https://api.bionyx.io/3rd-party/api"
|
||||
SCOPE = "https://ekeybionyxprod.onmicrosoft.com/3rd-party-api/api-access"
|
@@ -1,70 +0,0 @@
|
||||
"""Event platform for ekey bionyx integration."""
|
||||
|
||||
from aiohttp.hdrs import METH_POST
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from homeassistant.components.event import EventDeviceClass, EventEntity
|
||||
from homeassistant.components.webhook import (
|
||||
async_register as webhook_register,
|
||||
async_unregister as webhook_unregister,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EkeyBionyxConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EkeyBionyxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Ekey event."""
|
||||
async_add_entities(EkeyEvent(data) for data in entry.data["webhooks"])
|
||||
|
||||
|
||||
class EkeyEvent(EventEntity):
|
||||
"""Ekey Event."""
|
||||
|
||||
_attr_device_class = EventDeviceClass.BUTTON
|
||||
_attr_event_types = ["event happened"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: dict[str, str],
|
||||
) -> None:
|
||||
"""Initialise a Ekey event entity."""
|
||||
self._attr_name = data["name"]
|
||||
self._attr_unique_id = data["ekey_id"]
|
||||
self._webhook_id = data["webhook_id"]
|
||||
self._auth = data["auth"]
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self) -> None:
|
||||
"""Handle the webhook event."""
|
||||
self._trigger_event("event happened")
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks with your device API/library."""
|
||||
|
||||
async def async_webhook_handler(
|
||||
hass: HomeAssistant, webhook_id: str, request: Request
|
||||
) -> Response | None:
|
||||
if (await request.json())["auth"] == self._auth:
|
||||
self._async_handle_event()
|
||||
return None
|
||||
|
||||
webhook_register(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"Ekey {self._attr_name}",
|
||||
self._webhook_id,
|
||||
async_webhook_handler,
|
||||
allowed_methods=[METH_POST],
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister Webhook."""
|
||||
webhook_unregister(self.hass, self._webhook_id)
|
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "ekeybionyx",
|
||||
"name": "ekey bionyx",
|
||||
"codeowners": ["@richardpolzer"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ekeybionyx",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["ekey-bionyxpy==1.0.0"]
|
||||
}
|
@@ -1,92 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
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:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: This integration has no way of knowing if the fingerprint reader is offline.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: This integration has no way of knowing if the fingerprint reader is offline.
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: This integration does not poll.
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: This integration does not store the tokens.
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This integration does not support discovery.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: This integration has no entities that should be disabled by default.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: This integration does not connect to any device or service.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"choose_system": {
|
||||
"data": {
|
||||
"system": "System"
|
||||
},
|
||||
"data_description": {
|
||||
"system": "System the event entities should be set up for."
|
||||
},
|
||||
"description": "Please select the {ekeybionyx} system which you want to connect to Home Assistant."
|
||||
},
|
||||
"webhooks": {
|
||||
"description": "Please name your event entities. These event entities will be mapped as functions in the {ekeybionyx} app. You can configure up to {webhooks_available} event entities. Leaving a name empty will skip the setup of that event entity.",
|
||||
"data": {
|
||||
"webhook1": "Event entity 1",
|
||||
"webhook2": "Event entity 2",
|
||||
"webhook3": "Event entity 3",
|
||||
"webhook4": "Event entity 4",
|
||||
"webhook5": "Event entity 5",
|
||||
"url": "Home Assistant URL"
|
||||
},
|
||||
"data_description": {
|
||||
"webhook1": "Name of event entity 1 that will be mapped into a function",
|
||||
"webhook2": "Name of event entity 2 that will be mapped into a function",
|
||||
"webhook3": "Name of event entity 3 that will be mapped into a function",
|
||||
"webhook4": "Name of event entity 4 that will be mapped into a function",
|
||||
"webhook5": "Name of event entity 5 that will be mapped into a function",
|
||||
"url": "Home Assistant instance URL which can be reached from the fingerprint controller"
|
||||
}
|
||||
},
|
||||
"delete_webhooks": {
|
||||
"description": "This system has already been connected to Home Assistant. If you continue, the previously configured functions will be deleted."
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
"check_deletion_status": "Please open the {ekeybionyx} app and confirm the deletion of the functions."
|
||||
},
|
||||
"error": {
|
||||
"invalid_name": "Name is invalid",
|
||||
"invalid_url": "URL is invalid",
|
||||
"no_webhooks_provided": "No event names provided"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"no_available_webhooks": "There are no available webhooks in the {ekeybionyx} system. Please delete some and try again.",
|
||||
"no_own_systems": "Your account does not have admin access to any systems.",
|
||||
"cannot_connect": "Connection to {ekeybionyx} failed. Please check your Internet connection and try again."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
}
|
||||
}
|
||||
}
|
@@ -51,7 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b
|
||||
client_info=CLIENT_INFO,
|
||||
zeroconf_instance=zeroconf_instance,
|
||||
noise_psk=noise_psk,
|
||||
timezone=hass.config.time_zone,
|
||||
)
|
||||
|
||||
domain_data = DomainData.get(hass)
|
||||
|
@@ -57,7 +57,6 @@ from .manager import async_replace_device
|
||||
|
||||
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
|
||||
ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
|
||||
ERROR_INVALID_PASSWORD_AUTH = "invalid_auth"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
|
||||
@@ -138,22 +137,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._password = ""
|
||||
return await self._async_authenticate_or_add()
|
||||
|
||||
if error == ERROR_INVALID_PASSWORD_AUTH or (
|
||||
error is None and self._device_info and self._device_info.uses_password
|
||||
):
|
||||
return await self.async_step_authenticate()
|
||||
|
||||
if error is None and entry_data.get(CONF_NOISE_PSK):
|
||||
# Device was configured with encryption but now connects without it.
|
||||
# Check if it's the same device before offering to remove encryption.
|
||||
if self._reauth_entry.unique_id and self._device_mac:
|
||||
expected_mac = format_mac(self._reauth_entry.unique_id)
|
||||
actual_mac = format_mac(self._device_mac)
|
||||
if expected_mac != actual_mac:
|
||||
# Different device at the same IP - do not offer to remove encryption
|
||||
return self._async_abort_wrong_device(
|
||||
self._reauth_entry, expected_mac, actual_mac
|
||||
)
|
||||
return await self.async_step_reauth_encryption_removed_confirm()
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
@@ -524,28 +508,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_DEVICE_NAME: self._device_name,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _async_abort_wrong_device(
|
||||
self, entry: ConfigEntry, expected_mac: str, actual_mac: str
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort flow because a different device was found at the IP address."""
|
||||
assert self._host is not None
|
||||
assert self._device_name is not None
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
reason = "reconfigure_unique_id_changed"
|
||||
else:
|
||||
reason = "reauth_unique_id_changed"
|
||||
return self.async_abort(
|
||||
reason=reason,
|
||||
description_placeholders={
|
||||
"name": entry.data.get(CONF_DEVICE_NAME, entry.title),
|
||||
"host": self._host,
|
||||
"expected_mac": expected_mac,
|
||||
"unexpected_mac": actual_mac,
|
||||
"unexpected_device_name": self._device_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_validated_connection(self) -> ConfigFlowResult:
|
||||
"""Handle validated connection."""
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
@@ -577,10 +539,17 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
# Reauth was triggered a while ago, and since than
|
||||
# a new device resides at the same IP address.
|
||||
assert self._device_name is not None
|
||||
return self._async_abort_wrong_device(
|
||||
self._reauth_entry,
|
||||
format_mac(self._reauth_entry.unique_id),
|
||||
format_mac(self.unique_id),
|
||||
return self.async_abort(
|
||||
reason="reauth_unique_id_changed",
|
||||
description_placeholders={
|
||||
"name": self._reauth_entry.data.get(
|
||||
CONF_DEVICE_NAME, self._reauth_entry.title
|
||||
),
|
||||
"host": self._host,
|
||||
"expected_mac": format_mac(self._reauth_entry.unique_id),
|
||||
"unexpected_mac": format_mac(self.unique_id),
|
||||
"unexpected_device_name": self._device_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_reconfig_validated_connection(self) -> ConfigFlowResult:
|
||||
@@ -620,10 +589,17 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if self._reconfig_entry.data.get(CONF_DEVICE_NAME) == self._device_name:
|
||||
self._entry_with_name_conflict = self._reconfig_entry
|
||||
return await self.async_step_name_conflict()
|
||||
return self._async_abort_wrong_device(
|
||||
self._reconfig_entry,
|
||||
format_mac(self._reconfig_entry.unique_id),
|
||||
format_mac(self.unique_id),
|
||||
return self.async_abort(
|
||||
reason="reconfigure_unique_id_changed",
|
||||
description_placeholders={
|
||||
"name": self._reconfig_entry.data.get(
|
||||
CONF_DEVICE_NAME, self._reconfig_entry.title
|
||||
),
|
||||
"host": self._host,
|
||||
"expected_mac": format_mac(self._reconfig_entry.unique_id),
|
||||
"unexpected_mac": format_mac(self.unique_id),
|
||||
"unexpected_device_name": self._device_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_encryption_key(
|
||||
@@ -696,15 +672,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
cli = APIClient(
|
||||
host,
|
||||
port or DEFAULT_PORT,
|
||||
self._password or "",
|
||||
"",
|
||||
zeroconf_instance=zeroconf_instance,
|
||||
noise_psk=noise_psk,
|
||||
)
|
||||
try:
|
||||
await cli.connect()
|
||||
self._device_info = await cli.device_info()
|
||||
except InvalidAuthAPIError:
|
||||
return ERROR_INVALID_PASSWORD_AUTH
|
||||
except RequiresEncryptionAPIError:
|
||||
return ERROR_REQUIRES_ENCRYPTION_KEY
|
||||
except InvalidEncryptionKeyAPIError as ex:
|
||||
|
@@ -49,13 +49,11 @@ from aioesphomeapi import (
|
||||
from aioesphomeapi.model import ButtonInfo
|
||||
from bleak_esphome.backend.device import ESPHomeBluetoothDevice
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.assist_satellite import AssistSatelliteConfiguration
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers import discovery_flow, entity_registry as er
|
||||
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -470,7 +468,7 @@ class RuntimeEntryData:
|
||||
|
||||
@callback
|
||||
def async_on_connect(
|
||||
self, hass: HomeAssistant, device_info: DeviceInfo, api_version: APIVersion
|
||||
self, device_info: DeviceInfo, api_version: APIVersion
|
||||
) -> None:
|
||||
"""Call when the entry has been connected."""
|
||||
self.available = True
|
||||
@@ -486,29 +484,6 @@ class RuntimeEntryData:
|
||||
# be marked as unavailable or not.
|
||||
self.expected_disconnect = True
|
||||
|
||||
if not device_info.zwave_proxy_feature_flags:
|
||||
return
|
||||
|
||||
assert self.client.connected_address
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass,
|
||||
"zwave_js",
|
||||
{"source": config_entries.SOURCE_ESPHOME},
|
||||
ESPHomeServiceInfo(
|
||||
name=device_info.name,
|
||||
zwave_home_id=device_info.zwave_home_id or None,
|
||||
ip_address=self.client.connected_address,
|
||||
port=self.client.port,
|
||||
noise_psk=self.client.noise_psk,
|
||||
),
|
||||
discovery_key=discovery_flow.DiscoveryKey(
|
||||
domain=DOMAIN,
|
||||
key=device_info.mac_address,
|
||||
version=1,
|
||||
),
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_register_assist_satellite_config_updated_callback(
|
||||
self,
|
||||
|
@@ -372,9 +372,6 @@ class ESPHomeManager:
|
||||
"""Subscribe to states and list entities on successful API login."""
|
||||
try:
|
||||
await self._on_connect()
|
||||
except InvalidAuthAPIError as err:
|
||||
_LOGGER.warning("Authentication failed for %s: %s", self.host, err)
|
||||
await self._start_reauth_and_disconnect()
|
||||
except APIConnectionError as err:
|
||||
_LOGGER.warning(
|
||||
"Error getting setting up connection for %s: %s", self.host, err
|
||||
@@ -508,7 +505,7 @@ class ESPHomeManager:
|
||||
|
||||
api_version = cli.api_version
|
||||
assert api_version is not None, "API version must be set"
|
||||
entry_data.async_on_connect(hass, device_info, api_version)
|
||||
entry_data.async_on_connect(device_info, api_version)
|
||||
|
||||
await self._handle_dynamic_encryption_key(device_info)
|
||||
|
||||
@@ -644,14 +641,7 @@ class ESPHomeManager:
|
||||
if self.reconnect_logic:
|
||||
await self.reconnect_logic.stop()
|
||||
return
|
||||
await self._start_reauth_and_disconnect()
|
||||
|
||||
async def _start_reauth_and_disconnect(self) -> None:
|
||||
"""Start reauth flow and stop reconnection attempts."""
|
||||
self.entry.async_start_reauth(self.hass)
|
||||
await self.cli.disconnect()
|
||||
if self.reconnect_logic:
|
||||
await self.reconnect_logic.stop()
|
||||
|
||||
async def _handle_dynamic_encryption_key(
|
||||
self, device_info: EsphomeDeviceInfo
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==41.9.0",
|
||||
"aioesphomeapi==41.1.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.3.0"
|
||||
],
|
||||
|
@@ -194,21 +194,6 @@ class EsphomeAssistSatelliteWakeWordSelect(
|
||||
self._attr_options = [NO_WAKE_WORD, *sorted(self._wake_words)]
|
||||
|
||||
option = self._attr_current_option
|
||||
|
||||
if (
|
||||
(self._wake_word_index == 0)
|
||||
and (len(config.active_wake_words) == 1)
|
||||
and (option in (None, NO_WAKE_WORD))
|
||||
):
|
||||
option = next(
|
||||
(
|
||||
wake_word
|
||||
for wake_word, wake_word_id in self._wake_words.items()
|
||||
if wake_word_id == config.active_wake_words[0]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if (
|
||||
(option is None)
|
||||
or ((wake_word_id := self._wake_words.get(option)) is None)
|
||||
|
@@ -162,12 +162,12 @@ def setup_service_functions(
|
||||
It appears that all TCC-compatible systems support the same three zones modes.
|
||||
"""
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
@verify_domain_control(hass, DOMAIN)
|
||||
async def force_refresh(call: ServiceCall) -> None:
|
||||
"""Obtain the latest state data via the vendor's RESTful API."""
|
||||
await coordinator.async_refresh()
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
@verify_domain_control(hass, DOMAIN)
|
||||
async def set_system_mode(call: ServiceCall) -> None:
|
||||
"""Set the system mode."""
|
||||
assert coordinator.tcs is not None # mypy
|
||||
@@ -179,7 +179,7 @@ def setup_service_functions(
|
||||
}
|
||||
async_dispatcher_send(hass, DOMAIN, payload)
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
@verify_domain_control(hass, DOMAIN)
|
||||
async def set_zone_override(call: ServiceCall) -> None:
|
||||
"""Set the zone override (setpoint)."""
|
||||
entity_id = call.data[ATTR_ENTITY_ID]
|
||||
|
@@ -66,6 +66,26 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
|
||||
key="last_alarm_type_name",
|
||||
translation_key="last_alarm_type_name",
|
||||
),
|
||||
"Record_Mode": SensorEntityDescription(
|
||||
key="Record_Mode",
|
||||
translation_key="record_mode",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"battery_camera_work_mode": SensorEntityDescription(
|
||||
key="battery_camera_work_mode",
|
||||
translation_key="battery_camera_work_mode",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"powerStatus": SensorEntityDescription(
|
||||
key="powerStatus",
|
||||
translation_key="power_status",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
"OnlineStatus": SensorEntityDescription(
|
||||
key="OnlineStatus",
|
||||
translation_key="online_status",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,16 +96,26 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up EZVIZ sensors based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[EzvizSensor] = []
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
for camera, sensors in coordinator.data.items():
|
||||
entities.extend(
|
||||
EzvizSensor(coordinator, camera, sensor)
|
||||
for camera in coordinator.data
|
||||
for sensor, value in coordinator.data[camera].items()
|
||||
if sensor in SENSOR_TYPES
|
||||
if value is not None
|
||||
]
|
||||
)
|
||||
for sensor, value in sensors.items()
|
||||
if sensor in SENSOR_TYPES and value is not None
|
||||
)
|
||||
|
||||
optionals = sensors.get("optionals", {})
|
||||
entities.extend(
|
||||
EzvizSensor(coordinator, camera, optional_key)
|
||||
for optional_key in ("powerStatus", "OnlineStatus")
|
||||
if optional_key in optionals
|
||||
)
|
||||
|
||||
if "mode" in optionals.get("Record_Mode", {}):
|
||||
entities.append(EzvizSensor(coordinator, camera, "mode"))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class EzvizSensor(EzvizEntity, SensorEntity):
|
||||
|
@@ -147,6 +147,18 @@
|
||||
},
|
||||
"last_alarm_type_name": {
|
||||
"name": "Last alarm type name"
|
||||
},
|
||||
"record_mode": {
|
||||
"name": "Record mode"
|
||||
},
|
||||
"battery_camera_work_mode": {
|
||||
"name": "Battery work mode"
|
||||
},
|
||||
"power_status": {
|
||||
"name": "Power status"
|
||||
},
|
||||
"online_status": {
|
||||
"name": "Online status"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@@ -7,22 +7,11 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .services import async_register_services
|
||||
|
||||
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the file component."""
|
||||
async_register_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a file component entry."""
|
||||
|
@@ -6,7 +6,3 @@ CONF_TIMESTAMP = "timestamp"
|
||||
|
||||
DEFAULT_NAME = "File"
|
||||
FILE_ICON = "mdi:file"
|
||||
|
||||
SERVICE_READ_FILE = "read_file"
|
||||
ATTR_FILE_NAME = "file_name"
|
||||
ATTR_FILE_ENCODING = "file_encoding"
|
||||
|
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"services": {
|
||||
"read_file": {
|
||||
"service": "mdi:file"
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,88 +0,0 @@
|
||||
"""File Service calls."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE
|
||||
|
||||
|
||||
def async_register_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for File integration."""
|
||||
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_READ_FILE):
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_READ_FILE,
|
||||
read_file,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_FILE_NAME): cv.string,
|
||||
vol.Required(ATTR_FILE_ENCODING): cv.string,
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
ENCODING_LOADERS: dict[str, tuple[Callable, type[Exception]]] = {
|
||||
"json": (json.loads, json.JSONDecodeError),
|
||||
"yaml": (yaml.safe_load, yaml.YAMLError),
|
||||
}
|
||||
|
||||
|
||||
def read_file(call: ServiceCall) -> dict:
|
||||
"""Handle read_file service call."""
|
||||
file_name = call.data[ATTR_FILE_NAME]
|
||||
file_encoding = call.data[ATTR_FILE_ENCODING].lower()
|
||||
|
||||
if not call.hass.config.is_allowed_path(file_name):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_access_to_path",
|
||||
translation_placeholders={"filename": file_name},
|
||||
)
|
||||
|
||||
if file_encoding not in ENCODING_LOADERS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_file_encoding",
|
||||
translation_placeholders={
|
||||
"filename": file_name,
|
||||
"encoding": file_encoding,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with open(file_name, encoding="utf-8") as file:
|
||||
file_content = file.read()
|
||||
except FileNotFoundError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_not_found",
|
||||
translation_placeholders={"filename": file_name},
|
||||
) from err
|
||||
except OSError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_read_error",
|
||||
translation_placeholders={"filename": file_name},
|
||||
) from err
|
||||
|
||||
loader, error_type = ENCODING_LOADERS[file_encoding]
|
||||
try:
|
||||
data = loader(file_content)
|
||||
except error_type as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_decoding",
|
||||
translation_placeholders={"filename": file_name, "encoding": file_encoding},
|
||||
) from err
|
||||
|
||||
return {"data": data}
|
@@ -1,14 +0,0 @@
|
||||
# Describes the format for available file services
|
||||
read_file:
|
||||
fields:
|
||||
file_name:
|
||||
example: "www/my_file.json"
|
||||
selector:
|
||||
text:
|
||||
file_encoding:
|
||||
example: "JSON"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "JSON"
|
||||
- "YAML"
|
@@ -64,37 +64,6 @@
|
||||
},
|
||||
"write_access_failed": {
|
||||
"message": "Write access to {filename} failed: {exc}."
|
||||
},
|
||||
"no_access_to_path": {
|
||||
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||
},
|
||||
"unsupported_file_encoding": {
|
||||
"message": "Cannot read {filename}, unsupported file encoding {encoding}."
|
||||
},
|
||||
"file_decoding": {
|
||||
"message": "Cannot read file {filename} as {encoding}."
|
||||
},
|
||||
"file_not_found": {
|
||||
"message": "File {filename} not found."
|
||||
},
|
||||
"file_read_error": {
|
||||
"message": "Error reading {filename}."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"read_file": {
|
||||
"name": "Read file",
|
||||
"description": "Reads a file and returns the contents.",
|
||||
"fields": {
|
||||
"file_name": {
|
||||
"name": "File name",
|
||||
"description": "Name of the file to read."
|
||||
},
|
||||
"file_encoding": {
|
||||
"name": "File encoding",
|
||||
"description": "Encoding of the file (JSON, YAML.)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -31,12 +31,11 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema(
|
||||
|
||||
async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None:
|
||||
"""Call Fritz set guest wifi password service."""
|
||||
target_entry_ids = await async_extract_config_entry_ids(service_call)
|
||||
hass = service_call.hass
|
||||
target_entry_ids = await async_extract_config_entry_ids(hass, service_call)
|
||||
target_entries: list[FritzConfigEntry] = [
|
||||
loaded_entry
|
||||
for loaded_entry in service_call.hass.config_entries.async_loaded_entries(
|
||||
DOMAIN
|
||||
)
|
||||
for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
if loaded_entry.entry_id in target_entry_ids
|
||||
]
|
||||
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250924.0"]
|
||||
"requirements": ["home-assistant-frontend==20250903.5"]
|
||||
}
|
||||
|
@@ -124,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) ->
|
||||
def setup_service_functions(hass: HomeAssistant, broker):
|
||||
"""Set up the service functions."""
|
||||
|
||||
@verify_domain_control(DOMAIN)
|
||||
@verify_domain_control(hass, DOMAIN)
|
||||
async def set_zone_mode(call: ServiceCall) -> None:
|
||||
"""Set the system mode."""
|
||||
entity_id = call.data[ATTR_ENTITY_ID]
|
||||
|
@@ -29,7 +29,6 @@ from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -71,21 +70,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def generate_content(call: ServiceCall) -> ServiceResponse:
|
||||
"""Generate content from text and optionally images."""
|
||||
LOGGER.warning(
|
||||
"Action '%s.%s' is deprecated and will be removed in the 2026.4.0 release. "
|
||||
"Please use the 'ai_task.generate_data' action instead",
|
||||
DOMAIN,
|
||||
SERVICE_GENERATE_CONTENT,
|
||||
)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_generate_content",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_generate_content",
|
||||
)
|
||||
|
||||
prompt_parts = [call.data[CONF_PROMPT]]
|
||||
|
||||
|
@@ -150,16 +150,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_generate_content": {
|
||||
"title": "Deprecated 'generate_content' action",
|
||||
"description": "Action 'google_generative_ai_conversation.generate_content' is deprecated and will be removed in the 2026.4.0 release. Please use the 'ai_task.generate_data' action instead"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"generate_content": {
|
||||
"name": "Generate content (deprecated)",
|
||||
"description": "Generate content from a prompt consisting of text and optionally images (deprecated)",
|
||||
"name": "Generate content",
|
||||
"description": "Generate content from a prompt consisting of text and optionally images",
|
||||
"fields": {
|
||||
"prompt": {
|
||||
"name": "Prompt",
|
||||
|
@@ -51,7 +51,7 @@ async def _extract_gmail_config_entries(
|
||||
) -> list[GoogleMailConfigEntry]:
|
||||
return [
|
||||
entry
|
||||
for entry_id in await async_extract_config_entry_ids(call)
|
||||
for entry_id in await async_extract_config_entry_ids(call.hass, call)
|
||||
if (entry := call.hass.config_entries.async_get_entry(entry_id))
|
||||
and entry.domain == DOMAIN
|
||||
]
|
||||
|
@@ -10,8 +10,9 @@ from typing import Self, cast
|
||||
from google_photos_library_api.exceptions import GooglePhotosApiError
|
||||
from google_photos_library_api.model import Album, MediaItem
|
||||
|
||||
from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
|
||||
from homeassistant.components.media_player import MediaClass, MediaType
|
||||
from homeassistant.components.media_source import (
|
||||
BrowseError,
|
||||
BrowseMediaSource,
|
||||
MediaSource,
|
||||
MediaSourceItem,
|
||||
|
@@ -39,7 +39,6 @@ ATTR_ADD_CHECKLIST_ITEM = "add_checklist_item"
|
||||
ATTR_REMOVE_CHECKLIST_ITEM = "remove_checklist_item"
|
||||
ATTR_SCORE_CHECKLIST_ITEM = "score_checklist_item"
|
||||
ATTR_UNSCORE_CHECKLIST_ITEM = "unscore_checklist_item"
|
||||
ATTR_COLLAPSE_CHECKLIST = "collapse_checklist"
|
||||
ATTR_REMINDER = "reminder"
|
||||
ATTR_REMOVE_REMINDER = "remove_reminder"
|
||||
ATTR_CLEAR_REMINDER = "clear_reminder"
|
||||
|
@@ -47,7 +47,6 @@ from .const import (
|
||||
ATTR_ALIAS,
|
||||
ATTR_CLEAR_DATE,
|
||||
ATTR_CLEAR_REMINDER,
|
||||
ATTR_COLLAPSE_CHECKLIST,
|
||||
ATTR_CONFIG_ENTRY,
|
||||
ATTR_COST,
|
||||
ATTR_COUNTER_DOWN,
|
||||
@@ -131,11 +130,6 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
COLLAPSE_CHECKLIST_MAP = {
|
||||
"collapsed": True,
|
||||
"expanded": False,
|
||||
}
|
||||
|
||||
BASE_TASK_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
|
||||
@@ -166,7 +160,6 @@ BASE_TASK_SCHEMA = vol.Schema(
|
||||
vol.Optional(ATTR_REMOVE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_SCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_UNSCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
|
||||
vol.Optional(ATTR_COLLAPSE_CHECKLIST): vol.In(COLLAPSE_CHECKLIST_MAP),
|
||||
vol.Optional(ATTR_START_DATE): cv.date,
|
||||
vol.Optional(ATTR_INTERVAL): vol.All(int, vol.Range(0)),
|
||||
vol.Optional(ATTR_REPEAT): vol.All(cv.ensure_list, [vol.In(WEEK_DAYS)]),
|
||||
@@ -230,7 +223,6 @@ ITEMID_MAP = {
|
||||
"shiny_seed": Skill.SHINY_SEED,
|
||||
}
|
||||
|
||||
|
||||
SERVICE_TASK_TYPE_MAP = {
|
||||
SERVICE_UPDATE_REWARD: TaskType.REWARD,
|
||||
SERVICE_CREATE_REWARD: TaskType.REWARD,
|
||||
@@ -722,9 +714,6 @@ async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa:
|
||||
):
|
||||
data["checklist"] = checklist
|
||||
|
||||
if collapse_checklist := call.data.get(ATTR_COLLAPSE_CHECKLIST):
|
||||
data["collapseChecklist"] = COLLAPSE_CHECKLIST_MAP[collapse_checklist]
|
||||
|
||||
reminders = current_task.reminders if current_task else []
|
||||
|
||||
if add_reminders := call.data.get(ATTR_REMINDER):
|
||||
|
@@ -275,15 +275,6 @@ update_todo:
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
collapse_checklist: &collapse_checklist
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- collapsed
|
||||
- expanded
|
||||
mode: list
|
||||
translation_key: collapse_checklist
|
||||
priority: *priority
|
||||
duedate_options:
|
||||
collapsed: true
|
||||
@@ -327,7 +318,6 @@ create_todo:
|
||||
name: *name
|
||||
notes: *notes
|
||||
add_checklist_item: *add_checklist_item
|
||||
collapse_checklist: *collapse_checklist
|
||||
priority: *priority
|
||||
date: *due_date
|
||||
reminder: *reminder
|
||||
@@ -429,7 +419,6 @@ create_daily:
|
||||
name: *name
|
||||
notes: *notes
|
||||
add_checklist_item: *add_checklist_item
|
||||
collapse_checklist: *collapse_checklist
|
||||
priority: *priority
|
||||
start_date: *start_date
|
||||
frequency: *frequency_daily
|
||||
|
@@ -66,9 +66,7 @@
|
||||
"repeat_weekly_options_description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly.",
|
||||
"repeat_monthly_options_name": "Monthly repeat day",
|
||||
"repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly.",
|
||||
"quest_name": "Quest",
|
||||
"collapse_checklist_name": "Collapse/expand checklist",
|
||||
"collapse_checklist_description": "Whether the checklist of a task is displayed as collapsed or expanded in Habitica."
|
||||
"quest_name": "Quest"
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
@@ -1008,10 +1006,6 @@
|
||||
"unscore_checklist_item": {
|
||||
"name": "[%key:component::habitica::common::unscore_checklist_item_name%]",
|
||||
"description": "[%key:component::habitica::common::unscore_checklist_item_description%]"
|
||||
},
|
||||
"collapse_checklist": {
|
||||
"name": "[%key:component::habitica::common::collapse_checklist_name%]",
|
||||
"description": "[%key:component::habitica::common::collapse_checklist_description%]"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
@@ -1076,10 +1070,6 @@
|
||||
"add_checklist_item": {
|
||||
"name": "[%key:component::habitica::common::checklist_options_name%]",
|
||||
"description": "[%key:component::habitica::common::add_checklist_item_description%]"
|
||||
},
|
||||
"collapse_checklist": {
|
||||
"name": "[%key:component::habitica::common::collapse_checklist_name%]",
|
||||
"description": "[%key:component::habitica::common::collapse_checklist_description%]"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
@@ -1161,10 +1151,6 @@
|
||||
"name": "[%key:component::habitica::common::unscore_checklist_item_name%]",
|
||||
"description": "[%key:component::habitica::common::unscore_checklist_item_description%]"
|
||||
},
|
||||
"collapse_checklist": {
|
||||
"name": "[%key:component::habitica::common::collapse_checklist_name%]",
|
||||
"description": "[%key:component::habitica::common::collapse_checklist_description%]"
|
||||
},
|
||||
"streak": {
|
||||
"name": "Adjust streak",
|
||||
"description": "Adjust or reset the streak counter of the daily."
|
||||
@@ -1261,10 +1247,6 @@
|
||||
"name": "[%key:component::habitica::common::checklist_options_name%]",
|
||||
"description": "[%key:component::habitica::common::add_checklist_item_description%]"
|
||||
},
|
||||
"collapse_checklist": {
|
||||
"name": "[%key:component::habitica::common::collapse_checklist_name%]",
|
||||
"description": "[%key:component::habitica::common::collapse_checklist_description%]"
|
||||
},
|
||||
"reminder": {
|
||||
"name": "[%key:component::habitica::common::reminder_options_name%]",
|
||||
"description": "[%key:component::habitica::common::reminder_description%]"
|
||||
@@ -1343,12 +1325,6 @@
|
||||
"day_of_month": "Day of the month",
|
||||
"day_of_week": "Day of the week"
|
||||
}
|
||||
},
|
||||
"collapse_checklist": {
|
||||
"options": {
|
||||
"collapsed": "Collapsed",
|
||||
"expanded": "Expanded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -73,7 +73,6 @@ from . import ( # noqa: F401
|
||||
config_flow,
|
||||
diagnostics,
|
||||
sensor,
|
||||
switch,
|
||||
system_health,
|
||||
update,
|
||||
)
|
||||
@@ -150,7 +149,7 @@ _DEPRECATED_HassioServiceInfo = DeprecatedConstant(
|
||||
# If new platforms are added, be sure to import them above
|
||||
# so we do not make other components that depend on hassio
|
||||
# wait for the import of the platforms
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.UPDATE]
|
||||
|
||||
CONF_FRONTEND_REPO = "development_repo"
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user