Initialize docker stack repo
This commit is contained in:
229
homeassistant/config/custom_components/hacs/__init__.py
Normal file
229
homeassistant/config/custom_components/hacs/__init__.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""HACS gives you a powerful UI to handle downloads of all your custom needs.
|
||||
|
||||
For more details about this integration, please refer to the documentation at
|
||||
https://hacs.xyz/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogithubapi import AIOGitHubAPIException, GitHub, GitHubAPI
|
||||
from aiogithubapi.const import ACCEPT_HEADERS
|
||||
from awesomeversion import AwesomeVersion
|
||||
from homeassistant.components.frontend import async_remove_panel
|
||||
from homeassistant.components.lovelace.system_health import system_health_info
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import Platform, __version__ as HAVERSION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.start import async_at_start
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
from .base import HacsBase
|
||||
from .const import DOMAIN, HACS_SYSTEM_ID, MINIMUM_HA_VERSION, STARTUP
|
||||
from .data_client import HacsDataClient
|
||||
from .enums import HacsDisabledReason, HacsStage, LovelaceMode
|
||||
from .frontend import async_register_frontend
|
||||
from .utils.data import HacsData
|
||||
from .utils.queue_manager import QueueManager
|
||||
from .utils.version import version_left_higher_or_equal_then_right
|
||||
from .websocket import async_register_websocket_commands
|
||||
|
||||
PLATFORMS = [Platform.SWITCH, Platform.UPDATE]
|
||||
|
||||
|
||||
async def _async_initialize_integration(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
) -> bool:
|
||||
"""Initialize the integration"""
|
||||
hass.data[DOMAIN] = hacs = HacsBase()
|
||||
hacs.enable_hacs()
|
||||
|
||||
if config_entry.source == SOURCE_IMPORT:
|
||||
# Import is not supported
|
||||
hass.async_create_task(hass.config_entries.async_remove(config_entry.entry_id))
|
||||
return False
|
||||
|
||||
hacs.configuration.update_from_dict(
|
||||
{
|
||||
"config_entry": config_entry,
|
||||
**config_entry.data,
|
||||
**config_entry.options,
|
||||
},
|
||||
)
|
||||
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
|
||||
hacs.set_stage(None)
|
||||
|
||||
hacs.log.info(STARTUP, integration.version)
|
||||
|
||||
clientsession = async_get_clientsession(hass)
|
||||
|
||||
hacs.integration = integration
|
||||
hacs.version = integration.version
|
||||
hacs.configuration.dev = integration.version == "0.0.0"
|
||||
hacs.hass = hass
|
||||
hacs.queue = QueueManager(hass=hass)
|
||||
hacs.data = HacsData(hacs=hacs)
|
||||
hacs.data_client = HacsDataClient(
|
||||
session=clientsession,
|
||||
client_name=f"HACS/{integration.version}",
|
||||
)
|
||||
hacs.system.running = True
|
||||
hacs.session = clientsession
|
||||
|
||||
hacs.core.lovelace_mode = LovelaceMode.YAML
|
||||
try:
|
||||
lovelace_info = await system_health_info(hacs.hass)
|
||||
hacs.core.lovelace_mode = LovelaceMode(lovelace_info.get("mode", "yaml"))
|
||||
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
# If this happens, the users YAML is not valid, we assume YAML mode
|
||||
pass
|
||||
hacs.core.config_path = hacs.hass.config.path()
|
||||
|
||||
if hacs.core.ha_version is None:
|
||||
hacs.core.ha_version = AwesomeVersion(HAVERSION)
|
||||
|
||||
## Legacy GitHub client
|
||||
hacs.github = GitHub(
|
||||
hacs.configuration.token,
|
||||
clientsession,
|
||||
headers={
|
||||
"User-Agent": f"HACS/{hacs.version}",
|
||||
"Accept": ACCEPT_HEADERS["preview"],
|
||||
},
|
||||
)
|
||||
|
||||
## New GitHub client
|
||||
hacs.githubapi = GitHubAPI(
|
||||
token=hacs.configuration.token,
|
||||
session=clientsession,
|
||||
**{"client_name": f"HACS/{hacs.version}"},
|
||||
)
|
||||
|
||||
async def async_startup():
|
||||
"""HACS startup tasks."""
|
||||
hacs.enable_hacs()
|
||||
|
||||
try:
|
||||
import custom_components.custom_updater
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
hacs.log.critical(
|
||||
"HACS cannot be used with custom_updater. "
|
||||
"To use HACS you need to remove custom_updater from `custom_components`",
|
||||
)
|
||||
|
||||
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
|
||||
return False
|
||||
|
||||
if not version_left_higher_or_equal_then_right(
|
||||
hacs.core.ha_version.string,
|
||||
MINIMUM_HA_VERSION,
|
||||
):
|
||||
hacs.log.critical(
|
||||
"You need HA version %s or newer to use this integration.",
|
||||
MINIMUM_HA_VERSION,
|
||||
)
|
||||
hacs.disable_hacs(HacsDisabledReason.CONSTRAINS)
|
||||
return False
|
||||
|
||||
if not await hacs.data.restore():
|
||||
hacs.disable_hacs(HacsDisabledReason.RESTORE)
|
||||
return False
|
||||
|
||||
hacs.set_active_categories()
|
||||
|
||||
async_register_websocket_commands(hass)
|
||||
await async_register_frontend(hass, hacs)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
hacs.set_stage(HacsStage.SETUP)
|
||||
if hacs.system.disabled:
|
||||
return False
|
||||
|
||||
hacs.set_stage(HacsStage.WAITING)
|
||||
hacs.log.info("Setup complete, waiting for Home Assistant before startup tasks starts")
|
||||
|
||||
# Schedule startup tasks
|
||||
async_at_start(hass=hass, at_start_cb=hacs.startup_tasks)
|
||||
|
||||
return not hacs.system.disabled
|
||||
|
||||
async def async_try_startup(_=None):
|
||||
"""Startup wrapper for yaml config."""
|
||||
try:
|
||||
startup_result = await async_startup()
|
||||
except AIOGitHubAPIException:
|
||||
startup_result = False
|
||||
if not startup_result:
|
||||
if hacs.system.disabled_reason != HacsDisabledReason.INVALID_TOKEN:
|
||||
hacs.log.info("Could not setup HACS, trying again in 15 min")
|
||||
async_call_later(hass, 900, async_try_startup)
|
||||
return
|
||||
hacs.enable_hacs()
|
||||
|
||||
await async_try_startup()
|
||||
|
||||
# Remove old (v0-v1) sensor if it exists, can be removed in v3
|
||||
er = async_get_entity_registry(hass)
|
||||
if old_sensor := er.async_get_entity_id("sensor", DOMAIN, HACS_SYSTEM_ID):
|
||||
er.async_remove(old_sensor)
|
||||
|
||||
# Mischief managed!
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Set up this integration using UI."""
|
||||
config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry))
|
||||
setup_result = await _async_initialize_integration(hass=hass, config_entry=config_entry)
|
||||
hacs: HacsBase = hass.data[DOMAIN]
|
||||
return setup_result and not hacs.system.disabled
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Handle removal of an entry."""
|
||||
hacs: HacsBase = hass.data[DOMAIN]
|
||||
|
||||
if hacs.queue.has_pending_tasks:
|
||||
hacs.log.warning("Pending tasks, can not unload, try again later.")
|
||||
return False
|
||||
|
||||
# Clear out pending queue
|
||||
hacs.queue.clear()
|
||||
|
||||
for task in hacs.recurring_tasks:
|
||||
# Cancel all pending tasks
|
||||
task()
|
||||
|
||||
# Store data
|
||||
await hacs.data.async_write(force=True)
|
||||
|
||||
try:
|
||||
if hass.data.get("frontend_panels", {}).get("hacs"):
|
||||
hacs.log.info("Removing sidepanel")
|
||||
async_remove_panel(hass, "hacs")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
|
||||
hacs.set_stage(None)
|
||||
hacs.disable_hacs(HacsDisabledReason.REMOVED)
|
||||
|
||||
hass.data.pop(DOMAIN, None)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Reload the HACS config entry."""
|
||||
if not await async_unload_entry(hass, config_entry):
|
||||
return
|
||||
await async_setup_entry(hass, config_entry)
|
||||
1110
homeassistant/config/custom_components/hacs/base.py
Normal file
1110
homeassistant/config/custom_components/hacs/base.py
Normal file
File diff suppressed because it is too large
Load Diff
225
homeassistant/config/custom_components/hacs/config_flow.py
Normal file
225
homeassistant/config/custom_components/hacs/config_flow.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Adds config flow for HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogithubapi import (
|
||||
GitHubDeviceAPI,
|
||||
GitHubException,
|
||||
GitHubLoginDeviceModel,
|
||||
GitHubLoginOauthModel,
|
||||
)
|
||||
from aiogithubapi.common.const import OAUTH_USER_LOGIN
|
||||
from awesomeversion import AwesomeVersion
|
||||
from homeassistant.config_entries import ConfigFlow, OptionsFlow
|
||||
from homeassistant.const import __version__ as HAVERSION
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import UnknownFlow
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.loader import async_get_integration
|
||||
import voluptuous as vol
|
||||
|
||||
from .base import HacsBase
|
||||
from .const import CLIENT_ID, DOMAIN, LOCALE, MINIMUM_HA_VERSION
|
||||
from .utils.configuration_schema import (
|
||||
APPDAEMON,
|
||||
COUNTRY,
|
||||
SIDEPANEL_ICON,
|
||||
SIDEPANEL_TITLE,
|
||||
)
|
||||
from .utils.logger import LOGGER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
class HacsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for HACS."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
hass: HomeAssistant
|
||||
activation_task: asyncio.Task | None = None
|
||||
device: GitHubDeviceAPI | None = None
|
||||
|
||||
_registration: GitHubLoginDeviceModel | None = None
|
||||
_activation: GitHubLoginOauthModel | None = None
|
||||
_reauth: bool = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize."""
|
||||
self._errors = {}
|
||||
self._user_input = {}
|
||||
|
||||
async def async_step_user(self, user_input):
|
||||
"""Handle a flow initialized by the user."""
|
||||
self._errors = {}
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
if self.hass.data.get(DOMAIN):
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
if user_input:
|
||||
if [x for x in user_input if x.startswith("acc_") and not user_input[x]]:
|
||||
self._errors["base"] = "acc"
|
||||
return await self._show_config_form(user_input)
|
||||
|
||||
self._user_input = user_input
|
||||
|
||||
return await self.async_step_device(user_input)
|
||||
|
||||
# Initial form
|
||||
return await self._show_config_form(user_input)
|
||||
|
||||
async def async_step_device(self, _user_input):
|
||||
"""Handle device steps."""
|
||||
|
||||
async def _wait_for_activation() -> None:
|
||||
try:
|
||||
response = await self.device.activation(device_code=self._registration.device_code)
|
||||
self._activation = response.data
|
||||
finally:
|
||||
|
||||
async def _progress():
|
||||
with suppress(UnknownFlow):
|
||||
await self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
|
||||
|
||||
if not self.device:
|
||||
integration = await async_get_integration(self.hass, DOMAIN)
|
||||
self.device = GitHubDeviceAPI(
|
||||
client_id=CLIENT_ID,
|
||||
session=aiohttp_client.async_get_clientsession(self.hass),
|
||||
**{"client_name": f"HACS/{integration.version}"},
|
||||
)
|
||||
try:
|
||||
response = await self.device.register()
|
||||
self._registration = response.data
|
||||
except GitHubException as exception:
|
||||
LOGGER.exception(exception)
|
||||
return self.async_abort(reason="could_not_register")
|
||||
|
||||
if self.activation_task is None:
|
||||
self.activation_task = self.hass.async_create_task(_wait_for_activation())
|
||||
|
||||
if self.activation_task.done():
|
||||
if (exception := self.activation_task.exception()) is not None:
|
||||
LOGGER.exception(exception)
|
||||
return self.async_show_progress_done(next_step_id="could_not_register")
|
||||
return self.async_show_progress_done(next_step_id="device_done")
|
||||
|
||||
show_progress_kwargs = {
|
||||
"step_id": "device",
|
||||
"progress_action": "wait_for_device",
|
||||
"description_placeholders": {
|
||||
"url": OAUTH_USER_LOGIN,
|
||||
"code": self._registration.user_code,
|
||||
},
|
||||
"progress_task": self.activation_task,
|
||||
}
|
||||
return self.async_show_progress(**show_progress_kwargs)
|
||||
|
||||
async def _show_config_form(self, user_input):
|
||||
"""Show the configuration form to edit location data."""
|
||||
|
||||
if not user_input:
|
||||
user_input = {}
|
||||
|
||||
if AwesomeVersion(HAVERSION) < MINIMUM_HA_VERSION:
|
||||
return self.async_abort(
|
||||
reason="min_ha_version",
|
||||
description_placeholders={"version": MINIMUM_HA_VERSION},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("acc_logs", default=user_input.get("acc_logs", False)): bool,
|
||||
vol.Required("acc_addons", default=user_input.get("acc_addons", False)): bool,
|
||||
vol.Required(
|
||||
"acc_untested", default=user_input.get("acc_untested", False)
|
||||
): bool,
|
||||
vol.Required("acc_disable", default=user_input.get("acc_disable", False)): bool,
|
||||
}
|
||||
),
|
||||
errors=self._errors,
|
||||
)
|
||||
|
||||
async def async_step_device_done(self, user_input: dict[str, bool] | None = None):
|
||||
"""Handle device steps"""
|
||||
if self._reauth:
|
||||
existing_entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry, data={**existing_entry.data, "token": self._activation.access_token}
|
||||
)
|
||||
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={
|
||||
"token": self._activation.access_token,
|
||||
},
|
||||
options={
|
||||
"experimental": True,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_could_not_register(self, _user_input=None):
|
||||
"""Handle issues that need transition await from progress step."""
|
||||
return self.async_abort(reason="could_not_register")
|
||||
|
||||
async def async_step_reauth(self, _user_input=None):
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(self, user_input=None):
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
self._reauth = True
|
||||
return await self.async_step_device(None)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
return HacsOptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class HacsOptionsFlowHandler(OptionsFlow):
|
||||
"""HACS config flow options handler."""
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Initialize HACS options flow."""
|
||||
if AwesomeVersion(HAVERSION) < "2024.11.99":
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, _user_input=None):
|
||||
"""Manage the options."""
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initialized by the user."""
|
||||
hacs: HacsBase = self.hass.data.get(DOMAIN)
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data={**user_input, "experimental": True})
|
||||
|
||||
if hacs is None or hacs.configuration is None:
|
||||
return self.async_abort(reason="not_setup")
|
||||
|
||||
if hacs.queue.has_pending_tasks:
|
||||
return self.async_abort(reason="pending_tasks")
|
||||
|
||||
schema = {
|
||||
vol.Optional(SIDEPANEL_TITLE, default=hacs.configuration.sidepanel_title): str,
|
||||
vol.Optional(SIDEPANEL_ICON, default=hacs.configuration.sidepanel_icon): str,
|
||||
vol.Optional(COUNTRY, default=hacs.configuration.country): vol.In(LOCALE),
|
||||
vol.Optional(APPDAEMON, default=hacs.configuration.appdaemon): bool,
|
||||
}
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema))
|
||||
294
homeassistant/config/custom_components/hacs/const.py
Normal file
294
homeassistant/config/custom_components/hacs/const.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""Constants for HACS"""
|
||||
|
||||
from typing import TypeVar
|
||||
|
||||
from aiogithubapi.common.const import ACCEPT_HEADERS
|
||||
|
||||
NAME_SHORT = "HACS"
|
||||
DOMAIN = "hacs"
|
||||
CLIENT_ID = "395a8e669c5de9f7c6e8"
|
||||
MINIMUM_HA_VERSION = "2024.4.1"
|
||||
|
||||
URL_BASE = "/hacsfiles"
|
||||
|
||||
TV = TypeVar("TV")
|
||||
|
||||
PACKAGE_NAME = "custom_components.hacs"
|
||||
|
||||
DEFAULT_CONCURRENT_TASKS = 15
|
||||
DEFAULT_CONCURRENT_BACKOFF_TIME = 1
|
||||
|
||||
HACS_REPOSITORY_ID = "172733314"
|
||||
|
||||
HACS_ACTION_GITHUB_API_HEADERS = {
|
||||
"User-Agent": "HACS/action",
|
||||
"Accept": ACCEPT_HEADERS["preview"],
|
||||
}
|
||||
|
||||
VERSION_STORAGE = "6"
|
||||
STORENAME = "hacs"
|
||||
|
||||
HACS_SYSTEM_ID = "0717a0cd-745c-48fd-9b16-c8534c9704f9-bc944b0f-fd42-4a58-a072-ade38d1444cd"
|
||||
|
||||
STARTUP = """
|
||||
-------------------------------------------------------------------
|
||||
HACS (Home Assistant Community Store)
|
||||
|
||||
Version: %s
|
||||
This is a custom integration
|
||||
If you have any issues with this you need to open an issue here:
|
||||
https://github.com/hacs/integration/issues
|
||||
-------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
LOCALE = [
|
||||
"ALL",
|
||||
"AF",
|
||||
"AL",
|
||||
"DZ",
|
||||
"AS",
|
||||
"AD",
|
||||
"AO",
|
||||
"AI",
|
||||
"AQ",
|
||||
"AG",
|
||||
"AR",
|
||||
"AM",
|
||||
"AW",
|
||||
"AU",
|
||||
"AT",
|
||||
"AZ",
|
||||
"BS",
|
||||
"BH",
|
||||
"BD",
|
||||
"BB",
|
||||
"BY",
|
||||
"BE",
|
||||
"BZ",
|
||||
"BJ",
|
||||
"BM",
|
||||
"BT",
|
||||
"BO",
|
||||
"BQ",
|
||||
"BA",
|
||||
"BW",
|
||||
"BV",
|
||||
"BR",
|
||||
"IO",
|
||||
"BN",
|
||||
"BG",
|
||||
"BF",
|
||||
"BI",
|
||||
"KH",
|
||||
"CM",
|
||||
"CA",
|
||||
"CV",
|
||||
"KY",
|
||||
"CF",
|
||||
"TD",
|
||||
"CL",
|
||||
"CN",
|
||||
"CX",
|
||||
"CC",
|
||||
"CO",
|
||||
"KM",
|
||||
"CG",
|
||||
"CD",
|
||||
"CK",
|
||||
"CR",
|
||||
"HR",
|
||||
"CU",
|
||||
"CW",
|
||||
"CY",
|
||||
"CZ",
|
||||
"CI",
|
||||
"DK",
|
||||
"DJ",
|
||||
"DM",
|
||||
"DO",
|
||||
"EC",
|
||||
"EG",
|
||||
"SV",
|
||||
"GQ",
|
||||
"ER",
|
||||
"EE",
|
||||
"ET",
|
||||
"FK",
|
||||
"FO",
|
||||
"FJ",
|
||||
"FI",
|
||||
"FR",
|
||||
"GF",
|
||||
"PF",
|
||||
"TF",
|
||||
"GA",
|
||||
"GM",
|
||||
"GE",
|
||||
"DE",
|
||||
"GH",
|
||||
"GI",
|
||||
"GR",
|
||||
"GL",
|
||||
"GD",
|
||||
"GP",
|
||||
"GU",
|
||||
"GT",
|
||||
"GG",
|
||||
"GN",
|
||||
"GW",
|
||||
"GY",
|
||||
"HT",
|
||||
"HM",
|
||||
"VA",
|
||||
"HN",
|
||||
"HK",
|
||||
"HU",
|
||||
"IS",
|
||||
"IN",
|
||||
"ID",
|
||||
"IR",
|
||||
"IQ",
|
||||
"IE",
|
||||
"IM",
|
||||
"IL",
|
||||
"IT",
|
||||
"JM",
|
||||
"JP",
|
||||
"JE",
|
||||
"JO",
|
||||
"KZ",
|
||||
"KE",
|
||||
"KI",
|
||||
"KP",
|
||||
"KR",
|
||||
"KW",
|
||||
"KG",
|
||||
"LA",
|
||||
"LV",
|
||||
"LB",
|
||||
"LS",
|
||||
"LR",
|
||||
"LY",
|
||||
"LI",
|
||||
"LT",
|
||||
"LU",
|
||||
"MO",
|
||||
"MK",
|
||||
"MG",
|
||||
"MW",
|
||||
"MY",
|
||||
"MV",
|
||||
"ML",
|
||||
"MT",
|
||||
"MH",
|
||||
"MQ",
|
||||
"MR",
|
||||
"MU",
|
||||
"YT",
|
||||
"MX",
|
||||
"FM",
|
||||
"MD",
|
||||
"MC",
|
||||
"MN",
|
||||
"ME",
|
||||
"MS",
|
||||
"MA",
|
||||
"MZ",
|
||||
"MM",
|
||||
"NA",
|
||||
"NR",
|
||||
"NP",
|
||||
"NL",
|
||||
"NC",
|
||||
"NZ",
|
||||
"NI",
|
||||
"NE",
|
||||
"NG",
|
||||
"NU",
|
||||
"NF",
|
||||
"MP",
|
||||
"NO",
|
||||
"OM",
|
||||
"PK",
|
||||
"PW",
|
||||
"PS",
|
||||
"PA",
|
||||
"PG",
|
||||
"PY",
|
||||
"PE",
|
||||
"PH",
|
||||
"PN",
|
||||
"PL",
|
||||
"PT",
|
||||
"PR",
|
||||
"QA",
|
||||
"RO",
|
||||
"RU",
|
||||
"RW",
|
||||
"RE",
|
||||
"BL",
|
||||
"SH",
|
||||
"KN",
|
||||
"LC",
|
||||
"MF",
|
||||
"PM",
|
||||
"VC",
|
||||
"WS",
|
||||
"SM",
|
||||
"ST",
|
||||
"SA",
|
||||
"SN",
|
||||
"RS",
|
||||
"SC",
|
||||
"SL",
|
||||
"SG",
|
||||
"SX",
|
||||
"SK",
|
||||
"SI",
|
||||
"SB",
|
||||
"SO",
|
||||
"ZA",
|
||||
"GS",
|
||||
"SS",
|
||||
"ES",
|
||||
"LK",
|
||||
"SD",
|
||||
"SR",
|
||||
"SJ",
|
||||
"SZ",
|
||||
"SE",
|
||||
"CH",
|
||||
"SY",
|
||||
"TW",
|
||||
"TJ",
|
||||
"TZ",
|
||||
"TH",
|
||||
"TL",
|
||||
"TG",
|
||||
"TK",
|
||||
"TO",
|
||||
"TT",
|
||||
"TN",
|
||||
"TR",
|
||||
"TM",
|
||||
"TC",
|
||||
"TV",
|
||||
"UG",
|
||||
"UA",
|
||||
"AE",
|
||||
"GB",
|
||||
"US",
|
||||
"UM",
|
||||
"UY",
|
||||
"UZ",
|
||||
"VU",
|
||||
"VE",
|
||||
"VN",
|
||||
"VG",
|
||||
"VI",
|
||||
"WF",
|
||||
"EH",
|
||||
"YE",
|
||||
"ZM",
|
||||
"ZW",
|
||||
]
|
||||
38
homeassistant/config/custom_components/hacs/coordinator.py
Normal file
38
homeassistant/config/custom_components/hacs/coordinator.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Coordinator to trigger entity updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
|
||||
|
||||
|
||||
class HacsUpdateCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||
"""Dispatch updates to update entities."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize."""
|
||||
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
|
||||
|
||||
@callback
|
||||
def async_add_listener(
|
||||
self, update_callback: CALLBACK_TYPE, context: Any = None
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for data updates."""
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove update listener."""
|
||||
self._listeners.pop(remove_listener)
|
||||
|
||||
self._listeners[remove_listener] = (update_callback, context)
|
||||
|
||||
return remove_listener
|
||||
|
||||
@callback
|
||||
def async_update_listeners(self) -> None:
|
||||
"""Update all registered listeners."""
|
||||
for update_callback, _ in list(self._listeners.values()):
|
||||
update_callback()
|
||||
98
homeassistant/config/custom_components/hacs/data_client.py
Normal file
98
homeassistant/config/custom_components/hacs/data_client.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""HACS Data client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
import voluptuous as vol
|
||||
|
||||
from .exceptions import HacsException, HacsNotModifiedException
|
||||
from .utils.logger import LOGGER
|
||||
from .utils.validate import (
|
||||
VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA,
|
||||
VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA,
|
||||
VALIDATE_FETCHED_V2_REPO_DATA,
|
||||
)
|
||||
|
||||
CRITICAL_REMOVED_VALIDATORS = {
|
||||
"critical": VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA,
|
||||
"removed": VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA,
|
||||
}
|
||||
|
||||
|
||||
class HacsDataClient:
|
||||
"""HACS Data client."""
|
||||
|
||||
def __init__(self, session: ClientSession, client_name: str) -> None:
|
||||
"""Initialize."""
|
||||
self._client_name = client_name
|
||||
self._etags = {}
|
||||
self._session = session
|
||||
|
||||
async def _do_request(
|
||||
self,
|
||||
filename: str,
|
||||
section: str | None = None,
|
||||
) -> dict[str, dict[str, Any]] | list[str]:
|
||||
"""Do request."""
|
||||
endpoint = "/".join([v for v in [section, filename] if v is not None])
|
||||
try:
|
||||
response = await self._session.get(
|
||||
f"https://data-v2.hacs.xyz/{endpoint}",
|
||||
timeout=ClientTimeout(total=60),
|
||||
headers={
|
||||
"User-Agent": self._client_name,
|
||||
"If-None-Match": self._etags.get(endpoint, ""),
|
||||
},
|
||||
)
|
||||
if response.status == 304:
|
||||
raise HacsNotModifiedException() from None
|
||||
response.raise_for_status()
|
||||
except HacsNotModifiedException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
raise HacsException("Timeout of 60s reached") from None
|
||||
except Exception as exception:
|
||||
raise HacsException(f"Error fetching data from HACS: {exception}") from exception
|
||||
|
||||
self._etags[endpoint] = response.headers.get("etag")
|
||||
|
||||
return await response.json()
|
||||
|
||||
async def get_data(self, section: str | None, *, validate: bool) -> dict[str, dict[str, Any]]:
|
||||
"""Get data."""
|
||||
data = await self._do_request(filename="data.json", section=section)
|
||||
if not validate:
|
||||
return data
|
||||
|
||||
if section in VALIDATE_FETCHED_V2_REPO_DATA:
|
||||
validated = {}
|
||||
for key, repo_data in data.items():
|
||||
try:
|
||||
validated[key] = VALIDATE_FETCHED_V2_REPO_DATA[section](repo_data)
|
||||
except vol.Invalid as exception:
|
||||
LOGGER.info(
|
||||
"Got invalid data for %s (%s)", repo_data.get("full_name", key), exception
|
||||
)
|
||||
continue
|
||||
|
||||
return validated
|
||||
|
||||
if not (validator := CRITICAL_REMOVED_VALIDATORS.get(section)):
|
||||
raise ValueError(f"Do not know how to validate {section}")
|
||||
|
||||
validated = []
|
||||
for repo_data in data:
|
||||
try:
|
||||
validated.append(validator(repo_data))
|
||||
except vol.Invalid as exception:
|
||||
LOGGER.info("Got invalid data for %s (%s)", section, exception)
|
||||
continue
|
||||
|
||||
return validated
|
||||
|
||||
async def get_repositories(self, section: str) -> list[str]:
|
||||
"""Get repositories."""
|
||||
return await self._do_request(filename="repositories.json", section=section)
|
||||
80
homeassistant/config/custom_components/hacs/diagnostics.py
Normal file
80
homeassistant/config/custom_components/hacs/diagnostics.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Diagnostics support for HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogithubapi import GitHubException
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .base import HacsBase
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
hacs: HacsBase = hass.data[DOMAIN]
|
||||
|
||||
data = {
|
||||
"entry": entry.as_dict(),
|
||||
"hacs": {
|
||||
"stage": hacs.stage,
|
||||
"version": hacs.version,
|
||||
"disabled_reason": hacs.system.disabled_reason,
|
||||
"new": hacs.status.new,
|
||||
"startup": hacs.status.startup,
|
||||
"categories": hacs.common.categories,
|
||||
"renamed_repositories": hacs.common.renamed_repositories,
|
||||
"archived_repositories": hacs.common.archived_repositories,
|
||||
"ignored_repositories": hacs.common.ignored_repositories,
|
||||
"lovelace_mode": hacs.core.lovelace_mode,
|
||||
"configuration": {},
|
||||
},
|
||||
"custom_repositories": [
|
||||
repo.data.full_name
|
||||
for repo in hacs.repositories.list_all
|
||||
if not hacs.repositories.is_default(str(repo.data.id))
|
||||
],
|
||||
"repositories": [],
|
||||
}
|
||||
|
||||
for key in (
|
||||
"appdaemon",
|
||||
"country",
|
||||
"debug",
|
||||
"dev",
|
||||
"python_script",
|
||||
"release_limit",
|
||||
"theme",
|
||||
):
|
||||
data["hacs"]["configuration"][key] = getattr(hacs.configuration, key, None)
|
||||
|
||||
for repository in hacs.repositories.list_downloaded:
|
||||
data["repositories"].append(
|
||||
{
|
||||
"data": repository.data.to_json(),
|
||||
"integration_manifest": repository.integration_manifest,
|
||||
"repository_manifest": repository.repository_manifest.to_dict(),
|
||||
"ref": repository.ref,
|
||||
"paths": {
|
||||
"localpath": repository.localpath.replace(hacs.core.config_path, "/config"),
|
||||
"local": repository.content.path.local.replace(
|
||||
hacs.core.config_path, "/config"
|
||||
),
|
||||
"remote": repository.content.path.remote,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
rate_limit_response = await hacs.githubapi.rate_limit()
|
||||
data["rate_limit"] = rate_limit_response.data.as_dict
|
||||
except GitHubException as exception:
|
||||
data["rate_limit"] = str(exception)
|
||||
|
||||
return async_redact_data(data, ("token",))
|
||||
143
homeassistant/config/custom_components/hacs/entity.py
Normal file
143
homeassistant/config/custom_components/hacs/entity.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""HACS Base entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import BaseCoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, HACS_SYSTEM_ID, NAME_SHORT
|
||||
from .coordinator import HacsUpdateCoordinator
|
||||
from .enums import HacsDispatchEvent, HacsGitHubRepo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import HacsBase
|
||||
from .repositories.base import HacsRepository
|
||||
|
||||
|
||||
def system_info(hacs: HacsBase) -> dict:
|
||||
"""Return system info."""
|
||||
return {
|
||||
"identifiers": {(DOMAIN, HACS_SYSTEM_ID)},
|
||||
"name": NAME_SHORT,
|
||||
"manufacturer": "hacs.xyz",
|
||||
"model": "",
|
||||
"sw_version": str(hacs.version),
|
||||
"configuration_url": "homeassistant://hacs",
|
||||
"entry_type": DeviceEntryType.SERVICE,
|
||||
}
|
||||
|
||||
|
||||
class HacsBaseEntity(Entity):
|
||||
"""Base HACS entity."""
|
||||
|
||||
repository: HacsRepository | None = None
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hacs: HacsBase) -> None:
|
||||
"""Initialize."""
|
||||
self.hacs = hacs
|
||||
|
||||
|
||||
class HacsDispatcherEntity(HacsBaseEntity):
|
||||
"""Base HACS entity listening to dispatcher signals."""
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register for status events."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
self._update_and_write_state,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update(self) -> None:
|
||||
"""Update the sensor."""
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Manual updates of the sensor."""
|
||||
self._update()
|
||||
|
||||
@callback
|
||||
def _update_and_write_state(self, _: Any) -> None:
|
||||
"""Update the entity and write state."""
|
||||
self._update()
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class HacsSystemEntity(HacsDispatcherEntity):
|
||||
"""Base system entity."""
|
||||
|
||||
_attr_icon = "hacs:hacs"
|
||||
_attr_unique_id = HACS_SYSTEM_ID
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, any]:
|
||||
"""Return device information about HACS."""
|
||||
return system_info(self.hacs)
|
||||
|
||||
|
||||
class HacsRepositoryEntity(BaseCoordinatorEntity[HacsUpdateCoordinator], HacsBaseEntity):
|
||||
"""Base repository entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hacs: HacsBase,
|
||||
repository: HacsRepository,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
BaseCoordinatorEntity.__init__(self, hacs.coordinators[repository.data.category])
|
||||
HacsBaseEntity.__init__(self, hacs=hacs)
|
||||
self.repository = repository
|
||||
self._attr_unique_id = str(repository.data.id)
|
||||
self._repo_last_fetched = repository.data.last_fetched
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.hacs.repositories.is_downloaded(repository_id=str(self.repository.data.id))
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, any]:
|
||||
"""Return device information about HACS."""
|
||||
if self.repository.data.full_name == HacsGitHubRepo.INTEGRATION:
|
||||
return system_info(self.hacs)
|
||||
|
||||
def _manufacturer():
|
||||
if authors := self.repository.data.authors:
|
||||
return ", ".join(author.replace("@", "") for author in authors)
|
||||
return self.repository.data.full_name.split("/")[0]
|
||||
|
||||
return {
|
||||
"identifiers": {(DOMAIN, str(self.repository.data.id))},
|
||||
"name": self.repository.display_name,
|
||||
"model": self.repository.data.category,
|
||||
"manufacturer": _manufacturer(),
|
||||
"configuration_url": f"homeassistant://hacs/repository/{self.repository.data.id}",
|
||||
"entry_type": DeviceEntryType.SERVICE,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if (
|
||||
self._repo_last_fetched is not None
|
||||
and self.repository.data.last_fetched is not None
|
||||
and self._repo_last_fetched >= self.repository.data.last_fetched
|
||||
):
|
||||
return
|
||||
|
||||
self._repo_last_fetched = self.repository.data.last_fetched
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity.
|
||||
|
||||
Only used by the generic entity update service.
|
||||
"""
|
||||
71
homeassistant/config/custom_components/hacs/enums.py
Normal file
71
homeassistant/config/custom_components/hacs/enums.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Helper constants."""
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class HacsGitHubRepo(StrEnum):
|
||||
"""HacsGitHubRepo."""
|
||||
|
||||
DEFAULT = "hacs/default"
|
||||
INTEGRATION = "hacs/integration"
|
||||
|
||||
|
||||
class HacsCategory(StrEnum):
|
||||
APPDAEMON = "appdaemon"
|
||||
INTEGRATION = "integration"
|
||||
LOVELACE = "lovelace"
|
||||
PLUGIN = "plugin" # Kept for legacy purposes
|
||||
PYTHON_SCRIPT = "python_script"
|
||||
TEMPLATE = "template"
|
||||
THEME = "theme"
|
||||
REMOVED = "removed"
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
|
||||
class HacsDispatchEvent(StrEnum):
|
||||
"""HacsDispatchEvent."""
|
||||
|
||||
CONFIG = "hacs_dispatch_config"
|
||||
ERROR = "hacs_dispatch_error"
|
||||
RELOAD = "hacs_dispatch_reload"
|
||||
REPOSITORY = "hacs_dispatch_repository"
|
||||
REPOSITORY_DOWNLOAD_PROGRESS = "hacs_dispatch_repository_download_progress"
|
||||
STAGE = "hacs_dispatch_stage"
|
||||
STARTUP = "hacs_dispatch_startup"
|
||||
STATUS = "hacs_dispatch_status"
|
||||
|
||||
|
||||
class RepositoryFile(StrEnum):
|
||||
"""Repository file names."""
|
||||
|
||||
HACS_JSON = "hacs.json"
|
||||
MAINIFEST_JSON = "manifest.json"
|
||||
|
||||
|
||||
class LovelaceMode(StrEnum):
|
||||
"""Lovelace Modes."""
|
||||
|
||||
STORAGE = "storage"
|
||||
AUTO = "auto"
|
||||
AUTO_GEN = "auto-gen"
|
||||
YAML = "yaml"
|
||||
|
||||
|
||||
class HacsStage(StrEnum):
|
||||
SETUP = "setup"
|
||||
STARTUP = "startup"
|
||||
WAITING = "waiting"
|
||||
RUNNING = "running"
|
||||
BACKGROUND = "background"
|
||||
|
||||
|
||||
class HacsDisabledReason(StrEnum):
|
||||
RATE_LIMIT = "rate_limit"
|
||||
REMOVED = "removed"
|
||||
INVALID_TOKEN = "invalid_token"
|
||||
CONSTRAINS = "constrains"
|
||||
LOAD_HACS = "load_hacs"
|
||||
RESTORE = "restore"
|
||||
49
homeassistant/config/custom_components/hacs/exceptions.py
Normal file
49
homeassistant/config/custom_components/hacs/exceptions.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Custom Exceptions for HACS."""
|
||||
|
||||
|
||||
class HacsException(Exception):
|
||||
"""Super basic."""
|
||||
|
||||
|
||||
class HacsRepositoryArchivedException(HacsException):
|
||||
"""For repositories that are archived."""
|
||||
|
||||
|
||||
class HacsNotModifiedException(HacsException):
|
||||
"""For responses that are not modified."""
|
||||
|
||||
|
||||
class HacsExpectedException(HacsException):
|
||||
"""For stuff that are expected."""
|
||||
|
||||
|
||||
class HacsRepositoryExistException(HacsException):
|
||||
"""For repositories that are already exist."""
|
||||
|
||||
|
||||
class HacsExecutionStillInProgress(HacsException):
|
||||
"""Exception to raise if execution is still in progress."""
|
||||
|
||||
|
||||
class AddonRepositoryException(HacsException):
|
||||
"""Exception to raise when user tries to add add-on repository."""
|
||||
|
||||
exception_message = (
|
||||
"The repository does not seem to be a integration, "
|
||||
"but an add-on repository. HACS does not manage add-ons."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self.exception_message)
|
||||
|
||||
|
||||
class HomeAssistantCoreRepositoryException(HacsException):
|
||||
"""Exception to raise when user tries to add the home-assistant/core repository."""
|
||||
|
||||
exception_message = (
|
||||
"You can not add homeassistant/core, to use core integrations "
|
||||
"check the Home Assistant documentation for how to add them."
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(self.exception_message)
|
||||
67
homeassistant/config/custom_components/hacs/frontend.py
Normal file
67
homeassistant/config/custom_components/hacs/frontend.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Starting setup task: Frontend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.frontend import (
|
||||
add_extra_js_url,
|
||||
async_register_built_in_panel,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, URL_BASE
|
||||
from .hacs_frontend import VERSION as FE_VERSION, locate_dir
|
||||
from .utils.workarounds import async_register_static_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .base import HacsBase
|
||||
|
||||
|
||||
async def async_register_frontend(hass: HomeAssistant, hacs: HacsBase) -> None:
|
||||
"""Register the frontend."""
|
||||
|
||||
# Register frontend
|
||||
if hacs.configuration.dev and (frontend_path := os.getenv("HACS_FRONTEND_DIR")):
|
||||
hacs.log.warning(
|
||||
"<HacsFrontend> Frontend development mode enabled. Do not run in production!"
|
||||
)
|
||||
await async_register_static_path(
|
||||
hass, f"{URL_BASE}/frontend", f"{frontend_path}/hacs_frontend", cache_headers=False
|
||||
)
|
||||
hacs.frontend_version = "dev"
|
||||
else:
|
||||
await async_register_static_path(
|
||||
hass, f"{URL_BASE}/frontend", locate_dir(), cache_headers=False
|
||||
)
|
||||
hacs.frontend_version = FE_VERSION
|
||||
|
||||
# Custom iconset
|
||||
await async_register_static_path(
|
||||
hass, f"{URL_BASE}/iconset.js", str(hacs.integration_dir / "iconset.js")
|
||||
)
|
||||
add_extra_js_url(hass, f"{URL_BASE}/iconset.js")
|
||||
|
||||
# Add to sidepanel if needed
|
||||
if DOMAIN not in hass.data.get("frontend_panels", {}):
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
component_name="custom",
|
||||
sidebar_title=hacs.configuration.sidepanel_title,
|
||||
sidebar_icon=hacs.configuration.sidepanel_icon,
|
||||
frontend_url_path=DOMAIN,
|
||||
config={
|
||||
"_panel_custom": {
|
||||
"name": "hacs-frontend",
|
||||
"embed_iframe": True,
|
||||
"trust_external": False,
|
||||
"js_url": f"/hacsfiles/frontend/entrypoint.js?hacstag={hacs.frontend_version}",
|
||||
}
|
||||
},
|
||||
require_admin=True,
|
||||
)
|
||||
|
||||
# Setup plugin endpoint if needed
|
||||
await hacs.async_setup_frontend_endpoint_plugin()
|
||||
12
homeassistant/config/custom_components/hacs/icons.json
Normal file
12
homeassistant/config/custom_components/hacs/icons.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"switch": {
|
||||
"pre-release": {
|
||||
"state": {
|
||||
"on": "mdi:test-tube",
|
||||
"off": "mdi:test-tube-off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
homeassistant/config/custom_components/hacs/iconset.js
Normal file
21
homeassistant/config/custom_components/hacs/iconset.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const hacsIcons = {
|
||||
hacs: {
|
||||
path: "m 20.064849,22.306912 c -0.0319,0.369835 -0.280561,0.707789 -0.656773,0.918212 -0.280572,0.153036 -0.605773,0.229553 -0.950094,0.229553 -0.0765,0 -0.146661,-0.0064 -0.216801,-0.01275 -0.605774,-0.05739 -1.135016,-0.344329 -1.402827,-0.7588 l 0.784304,-0.516495 c 0.0893,0.146659 0.344331,0.312448 0.707793,0.34433 0.235931,0.02551 0.471852,-0.01913 0.637643,-0.108401 0.101998,-0.05101 0.172171,-0.127529 0.17854,-0.191295 0.0065,-0.08289 -0.0255,-0.369835 -0.733293,-0.439975 -1.013854,-0.09565 -1.645127,-0.688661 -1.568606,-1.460214 0.0319,-0.382589 0.280561,-0.714165 0.663153,-0.930965 0.331571,-0.172165 0.752423,-0.25506 1.166895,-0.210424 0.599382,0.05739 1.128635,0.344329 1.402816,0.7588 l -0.784304,0.510118 c -0.0893,-0.140282 -0.344331,-0.299694 -0.707782,-0.331576 -0.235932,-0.02551 -0.471863,0.01913 -0.637654,0.10202 -0.0956,0.05739 -0.165791,0.133906 -0.17216,0.191295 -0.0255,0.293317 0.465482,0.420847 0.726913,0.439976 v 0.0064 c 1.020234,0.09565 1.638757,0.66953 1.562237,1.460213 z m -7.466854,-0.988354 c 0,-1.192401 0.962855,-2.155249 2.15525,-2.155249 0.599393,0 1.179645,0.25506 1.594117,0.707789 l -0.695033,0.624895 c -0.235931,-0.25506 -0.561133,-0.401718 -0.899084,-0.401718 -0.675903,0 -1.217906,0.542 -1.217906,1.217906 0,0.66953 0.542003,1.217908 1.217906,1.217908 0.337951,0 0.663153,-0.140283 0.899084,-0.401718 l 0.695033,0.631271 c -0.414472,0.452729 -0.988355,0.707788 -1.594117,0.707788 -1.192395,0 -2.15525,-0.969224 -2.15525,-2.148872 z M 8.6573365,23.461054 10.353474,19.14418 h 0.624893 l 1.568618,4.316874 H 11.52037 L 11.265308,22.734136 H 9.964513 l -0.274192,0.726918 z m 1.6833885,-1.68339 h 0.580263 L 10.646796,21.012487 Z M 8.1089536,19.156932 v 4.297745 H 7.1461095 v -1.645131 h -1.606867 v 1.645131 H 4.5763876 v -4.297745 h 0.9628549 v 1.696143 h 1.606867 V 19.156932 Z M 20.115859,4.2997436 C 20.090359,4.159461 19.969198,4.0574375 19.822548,4.0574375 H 14.141102 10.506516 4.8250686 c -0.14665,0 -0.2678112,0.1020202 -0.2933108,0.2423061 L 3.690064,8.8461703 c -0.00651,0.01913 -0.00651,0.03826 -0.00651,0.057391 v 1.5239797 c 0,0.165789 0.133911,0.299694 0.2996911,0.299694 H 4.5762579 20.0711 20.664112 c 0.165781,0 0.299691,-0.133905 0.299691,-0.299694 V 8.8971848 c 0,-0.01913 0,-0.03826 -0.0065,-0.05739 z M 4.5763876,17.358767 c 0,0.184917 0.1466608,0.331577 0.3315819,0.331577 h 5.5985465 3.634586 0.924594 c 0.184911,0 0.331571,-0.14666 0.331571,-0.331577 v -4.744098 c 0,-0.184918 0.146661,-0.331577 0.331582,-0.331577 h 2.894913 c 0.184921,0 0.331582,0.146659 0.331582,0.331577 v 4.744098 c 0,0.184917 0.146661,0.331577 0.331571,0.331577 h 0.446363 c 0.18491,0 0.331571,-0.14666 0.331571,-0.331577 v -5.636804 c 0,-0.184918 -0.146661,-0.331577 -0.331571,-0.331577 H 4.9079695 c -0.1849211,0 -0.3315819,0.146659 -0.3315819,0.331577 z m 1.6578879,-4.852498 h 5.6495565 c 0.15303,0 0.280561,0.12753 0.280561,0.280564 v 3.513438 c 0,0.153036 -0.127531,0.280566 -0.280561,0.280566 H 6.2342755 c -0.1530412,0 -0.2805719,-0.12753 -0.2805719,-0.280566 v -3.513438 c 0,-0.159411 0.1275307,-0.280564 0.2805719,-0.280564 z M 19.790657,3.3879075 H 4.8569594 c -0.1530412,0 -0.2805718,-0.1275296 -0.2805718,-0.2805642 V 1.3665653 C 4.5763876,1.2135296 4.7039182,1.086 4.8569594,1.086 H 19.790657 c 0.153041,0 0.280572,0.1275296 0.280572,0.2805653 v 1.740778 c 0,0.1530346 -0.127531,0.2805642 -0.280572,0.2805642 z",
|
||||
keywords: ["hacs", "home assistant community store"],
|
||||
},
|
||||
};
|
||||
|
||||
window.customIcons = window.customIcons || {};
|
||||
window.customIconsets = window.customIconsets || {};
|
||||
|
||||
window.customIcons["hacs"] = {
|
||||
getIcon: async (iconName) => (
|
||||
{ path: hacsIcons[iconName]?.path }
|
||||
),
|
||||
getIconList: async () =>
|
||||
Object.entries(hacsIcons).map(([icon, content]) => ({
|
||||
name: icon,
|
||||
keywords: content.keywords,
|
||||
})
|
||||
)
|
||||
};
|
||||
26
homeassistant/config/custom_components/hacs/manifest.json
Normal file
26
homeassistant/config/custom_components/hacs/manifest.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"domain": "hacs",
|
||||
"name": "HACS",
|
||||
"after_dependencies": [
|
||||
"python_script"
|
||||
],
|
||||
"codeowners": [
|
||||
"@ludeeus"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [
|
||||
"http",
|
||||
"websocket_api",
|
||||
"frontend",
|
||||
"persistent_notification",
|
||||
"lovelace",
|
||||
"repairs"
|
||||
],
|
||||
"documentation": "https://hacs.xyz/docs/use/",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/hacs/integration/issues",
|
||||
"requirements": [
|
||||
"aiogithubapi>=22.10.1"
|
||||
],
|
||||
"version": "2.0.5"
|
||||
}
|
||||
58
homeassistant/config/custom_components/hacs/repairs.py
Normal file
58
homeassistant/config/custom_components/hacs/repairs.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Repairs platform for HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.hacs.base import HacsBase
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class RestartRequiredFixFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
def __init__(self, issue_id: str) -> None:
|
||||
self.issue_id = issue_id
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
|
||||
return await self.async_step_confirm_restart()
|
||||
|
||||
async def async_step_confirm_restart(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
await self.hass.services.async_call("homeassistant", "restart")
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
hacs: HacsBase = self.hass.data[DOMAIN]
|
||||
integration = hacs.repositories.get_by_id(self.issue_id.split("_")[2])
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm_restart",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders={"name": integration.display_name},
|
||||
)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None = None,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> RepairsFlow | None:
|
||||
"""Create flow."""
|
||||
if issue_id.startswith("restart_required"):
|
||||
return RestartRequiredFixFlow(issue_id)
|
||||
return None
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Initialize repositories."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..enums import HacsCategory
|
||||
from .appdaemon import HacsAppdaemonRepository
|
||||
from .base import HacsRepository
|
||||
from .integration import HacsIntegrationRepository
|
||||
from .plugin import HacsPluginRepository
|
||||
from .python_script import HacsPythonScriptRepository
|
||||
from .template import HacsTemplateRepository
|
||||
from .theme import HacsThemeRepository
|
||||
|
||||
REPOSITORY_CLASSES: dict[HacsCategory, HacsRepository] = {
|
||||
HacsCategory.THEME: HacsThemeRepository,
|
||||
HacsCategory.INTEGRATION: HacsIntegrationRepository,
|
||||
HacsCategory.PYTHON_SCRIPT: HacsPythonScriptRepository,
|
||||
HacsCategory.APPDAEMON: HacsAppdaemonRepository,
|
||||
HacsCategory.PLUGIN: HacsPluginRepository,
|
||||
HacsCategory.TEMPLATE: HacsTemplateRepository,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Class for appdaemon apps in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogithubapi import AIOGitHubAPIException
|
||||
|
||||
from ..enums import HacsCategory, HacsDispatchEvent
|
||||
from ..exceptions import HacsException
|
||||
from ..utils.decorator import concurrent
|
||||
from .base import HacsRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
class HacsAppdaemonRepository(HacsRepository):
|
||||
"""Appdaemon apps in HACS."""
|
||||
|
||||
def __init__(self, hacs: HacsBase, full_name: str):
|
||||
"""Initialize."""
|
||||
super().__init__(hacs=hacs)
|
||||
self.data.full_name = full_name
|
||||
self.data.full_name_lower = full_name.lower()
|
||||
self.data.category = HacsCategory.APPDAEMON
|
||||
self.content.path.local = self.localpath
|
||||
self.content.path.remote = "apps"
|
||||
|
||||
@property
|
||||
def localpath(self):
|
||||
"""Return localpath."""
|
||||
return f"{self.hacs.core.config_path}/appdaemon/apps/{self.data.name}"
|
||||
|
||||
async def validate_repository(self):
|
||||
"""Validate."""
|
||||
await self.common_validate()
|
||||
|
||||
# Custom step 1: Validate content.
|
||||
try:
|
||||
addir = await self.repository_object.get_contents("apps", self.ref)
|
||||
except AIOGitHubAPIException:
|
||||
raise HacsException(
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
) from None
|
||||
|
||||
if not isinstance(addir, list):
|
||||
self.validate.errors.append(f"{self.string} Repository structure not compliant")
|
||||
|
||||
self.content.path.remote = addir[0].path
|
||||
self.content.objects = await self.repository_object.get_contents(
|
||||
self.content.path.remote, self.ref
|
||||
)
|
||||
|
||||
# Handle potential errors
|
||||
if self.validate.errors:
|
||||
for error in self.validate.errors:
|
||||
if not self.hacs.status.startup:
|
||||
self.logger.error("%s %s", self.string, error)
|
||||
return self.validate.success
|
||||
|
||||
@concurrent(concurrenttasks=10, backoff_time=5)
|
||||
async def update_repository(self, ignore_issues=False, force=False):
|
||||
"""Update."""
|
||||
if not await self.common_update(ignore_issues, force) and not force:
|
||||
return
|
||||
|
||||
# Get appdaemon objects.
|
||||
if self.repository_manifest:
|
||||
if self.repository_manifest.content_in_root:
|
||||
self.content.path.remote = ""
|
||||
|
||||
if self.content.path.remote == "apps":
|
||||
addir = await self.repository_object.get_contents(self.content.path.remote, self.ref)
|
||||
self.content.path.remote = addir[0].path
|
||||
self.content.objects = await self.repository_object.get_contents(
|
||||
self.content.path.remote, self.ref
|
||||
)
|
||||
|
||||
# Set local path
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
{
|
||||
"id": 1337,
|
||||
"action": "update",
|
||||
"repository": self.data.full_name,
|
||||
"repository_id": self.data.id,
|
||||
},
|
||||
)
|
||||
1454
homeassistant/config/custom_components/hacs/repositories/base.py
Normal file
1454
homeassistant/config/custom_components/hacs/repositories/base.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
"""Class for integrations in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.loader import async_get_custom_components
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..enums import HacsCategory, HacsDispatchEvent, HacsGitHubRepo, RepositoryFile
|
||||
from ..exceptions import AddonRepositoryException, HacsException
|
||||
from ..utils.decode import decode_content
|
||||
from ..utils.decorator import concurrent
|
||||
from ..utils.filters import get_first_directory_in_directory
|
||||
from ..utils.json import json_loads
|
||||
from .base import HacsRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
class HacsIntegrationRepository(HacsRepository):
|
||||
"""Integrations in HACS."""
|
||||
|
||||
def __init__(self, hacs: HacsBase, full_name: str):
|
||||
"""Initialize."""
|
||||
super().__init__(hacs=hacs)
|
||||
self.data.full_name = full_name
|
||||
self.data.full_name_lower = full_name.lower()
|
||||
self.data.category = HacsCategory.INTEGRATION
|
||||
self.content.path.remote = "custom_components"
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
@property
|
||||
def localpath(self):
|
||||
"""Return localpath."""
|
||||
return f"{self.hacs.core.config_path}/custom_components/{self.data.domain}"
|
||||
|
||||
async def async_post_installation(self):
|
||||
"""Run post installation steps."""
|
||||
self.pending_restart = True
|
||||
if self.data.config_flow:
|
||||
if self.data.full_name != HacsGitHubRepo.INTEGRATION:
|
||||
await self.reload_custom_components()
|
||||
if self.data.first_install:
|
||||
self.pending_restart = False
|
||||
|
||||
if self.pending_restart:
|
||||
self.logger.debug("%s Creating restart_required issue", self.string)
|
||||
async_create_issue(
|
||||
hass=self.hacs.hass,
|
||||
domain=DOMAIN,
|
||||
issue_id=f"restart_required_{self.data.id}_{self.ref}",
|
||||
is_fixable=True,
|
||||
issue_domain=self.data.domain or DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="restart_required",
|
||||
translation_placeholders={
|
||||
"name": self.display_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_post_uninstall(self) -> None:
|
||||
"""Run post uninstall steps."""
|
||||
if self.data.config_flow:
|
||||
await self.reload_custom_components()
|
||||
else:
|
||||
self.pending_restart = True
|
||||
|
||||
async def validate_repository(self):
|
||||
"""Validate."""
|
||||
await self.common_validate()
|
||||
|
||||
# Custom step 1: Validate content.
|
||||
if self.repository_manifest.content_in_root:
|
||||
self.content.path.remote = ""
|
||||
|
||||
if self.content.path.remote == "custom_components":
|
||||
name = get_first_directory_in_directory(self.tree, "custom_components")
|
||||
if name is None:
|
||||
if (
|
||||
"repository.json" in self.treefiles
|
||||
or "repository.yaml" in self.treefiles
|
||||
or "repository.yml" in self.treefiles
|
||||
):
|
||||
raise AddonRepositoryException()
|
||||
raise HacsException(
|
||||
f"{self.string} Repository structure for {
|
||||
self.ref.replace('tags/', '')} is not compliant"
|
||||
)
|
||||
self.content.path.remote = f"custom_components/{name}"
|
||||
|
||||
# Get the content of manifest.json
|
||||
if manifest := await self.async_get_integration_manifest():
|
||||
try:
|
||||
self.integration_manifest = manifest
|
||||
self.data.authors = manifest.get("codeowners", [])
|
||||
self.data.domain = manifest["domain"]
|
||||
self.data.manifest_name = manifest.get("name")
|
||||
self.data.config_flow = manifest.get("config_flow", False)
|
||||
|
||||
except KeyError as exception:
|
||||
self.validate.errors.append(
|
||||
f"Missing expected key '{exception}' in {
|
||||
RepositoryFile.MAINIFEST_JSON}"
|
||||
)
|
||||
self.hacs.log.error(
|
||||
"Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON
|
||||
)
|
||||
|
||||
# Set local path
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
# Handle potential errors
|
||||
if self.validate.errors:
|
||||
for error in self.validate.errors:
|
||||
if not self.hacs.status.startup:
|
||||
self.logger.error("%s %s", self.string, error)
|
||||
return self.validate.success
|
||||
|
||||
@concurrent(concurrenttasks=10, backoff_time=5)
|
||||
async def update_repository(self, ignore_issues=False, force=False):
|
||||
"""Update."""
|
||||
if not await self.common_update(ignore_issues, force) and not force:
|
||||
return
|
||||
|
||||
if self.repository_manifest.content_in_root:
|
||||
self.content.path.remote = ""
|
||||
|
||||
if self.content.path.remote == "custom_components":
|
||||
name = get_first_directory_in_directory(self.tree, "custom_components")
|
||||
self.content.path.remote = f"custom_components/{name}"
|
||||
|
||||
# Get the content of manifest.json
|
||||
if manifest := await self.async_get_integration_manifest():
|
||||
try:
|
||||
self.integration_manifest = manifest
|
||||
self.data.authors = manifest.get("codeowners", [])
|
||||
self.data.domain = manifest["domain"]
|
||||
self.data.manifest_name = manifest.get("name")
|
||||
self.data.config_flow = manifest.get("config_flow", False)
|
||||
|
||||
except KeyError as exception:
|
||||
self.validate.errors.append(
|
||||
f"Missing expected key '{exception}' in {
|
||||
RepositoryFile.MAINIFEST_JSON}"
|
||||
)
|
||||
self.hacs.log.error(
|
||||
"Missing expected key '%s' in '%s'", exception, RepositoryFile.MAINIFEST_JSON
|
||||
)
|
||||
|
||||
# Set local path
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
{
|
||||
"id": 1337,
|
||||
"action": "update",
|
||||
"repository": self.data.full_name,
|
||||
"repository_id": self.data.id,
|
||||
},
|
||||
)
|
||||
|
||||
async def reload_custom_components(self):
|
||||
"""Reload custom_components (and config flows)in HA."""
|
||||
self.logger.info("Reloading custom_component cache")
|
||||
del self.hacs.hass.data["custom_components"]
|
||||
await async_get_custom_components(self.hacs.hass)
|
||||
self.logger.info("Custom_component cache reloaded")
|
||||
|
||||
async def async_get_integration_manifest(self, ref: str = None) -> dict[str, Any] | None:
|
||||
"""Get the content of the manifest.json file."""
|
||||
manifest_path = (
|
||||
"manifest.json"
|
||||
if self.repository_manifest.content_in_root
|
||||
else f"{self.content.path.remote}/{RepositoryFile.MAINIFEST_JSON}"
|
||||
)
|
||||
|
||||
if not manifest_path in (x.full_path for x in self.tree):
|
||||
raise HacsException(f"No {RepositoryFile.MAINIFEST_JSON} file found '{manifest_path}'")
|
||||
|
||||
response = await self.hacs.async_github_api_method(
|
||||
method=self.hacs.githubapi.repos.contents.get,
|
||||
repository=self.data.full_name,
|
||||
path=manifest_path,
|
||||
**{"params": {"ref": ref or self.version_to_download()}},
|
||||
)
|
||||
if response:
|
||||
return json_loads(decode_content(response.data.content))
|
||||
|
||||
async def get_integration_manifest(self, *, version: str, **kwargs) -> dict[str, Any] | None:
|
||||
"""Get the content of the manifest.json file."""
|
||||
manifest_path = (
|
||||
"manifest.json"
|
||||
if self.repository_manifest.content_in_root
|
||||
else f"{self.content.path.remote}/{RepositoryFile.MAINIFEST_JSON}"
|
||||
)
|
||||
|
||||
if manifest_path not in (x.full_path for x in self.tree):
|
||||
raise HacsException(f"No {RepositoryFile.MAINIFEST_JSON} file found '{manifest_path}'")
|
||||
|
||||
self.logger.debug("%s Getting manifest.json for version=%s", self.string, version)
|
||||
try:
|
||||
result = await self.hacs.async_download_file(
|
||||
f"https://raw.githubusercontent.com/{
|
||||
self.data.full_name}/{version}/{manifest_path}",
|
||||
nolog=True,
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
return json_loads(result)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return None
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Class for plugins in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..enums import HacsCategory, HacsDispatchEvent
|
||||
from ..exceptions import HacsException
|
||||
from ..utils.decorator import concurrent
|
||||
from ..utils.json import json_loads
|
||||
from .base import HacsRepository
|
||||
|
||||
HACSTAG_REPLACER = re.compile(r"\D+")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components.lovelace.resources import ResourceStorageCollection
|
||||
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
class HacsPluginRepository(HacsRepository):
|
||||
"""Plugins in HACS."""
|
||||
|
||||
def __init__(self, hacs: HacsBase, full_name: str):
|
||||
"""Initialize."""
|
||||
super().__init__(hacs=hacs)
|
||||
self.data.full_name = full_name
|
||||
self.data.full_name_lower = full_name.lower()
|
||||
self.data.file_name = None
|
||||
self.data.category = HacsCategory.PLUGIN
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
@property
|
||||
def localpath(self):
|
||||
"""Return localpath."""
|
||||
return f"{self.hacs.core.config_path}/www/community/{self.data.full_name.split('/')[-1]}"
|
||||
|
||||
async def validate_repository(self):
|
||||
"""Validate."""
|
||||
# Run common validation steps.
|
||||
await self.common_validate()
|
||||
|
||||
# Custom step 1: Validate content.
|
||||
self.update_filenames()
|
||||
|
||||
if self.content.path.remote is None:
|
||||
raise HacsException(
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
if self.content.path.remote == "release":
|
||||
self.content.single = True
|
||||
|
||||
# Handle potential errors
|
||||
if self.validate.errors:
|
||||
for error in self.validate.errors:
|
||||
if not self.hacs.status.startup:
|
||||
self.logger.error("%s %s", self.string, error)
|
||||
return self.validate.success
|
||||
|
||||
async def async_post_installation(self):
|
||||
"""Run post installation steps."""
|
||||
await self.hacs.async_setup_frontend_endpoint_plugin()
|
||||
await self.update_dashboard_resources()
|
||||
|
||||
async def async_post_uninstall(self):
|
||||
"""Run post uninstall steps."""
|
||||
await self.remove_dashboard_resources()
|
||||
|
||||
@concurrent(concurrenttasks=10, backoff_time=5)
|
||||
async def update_repository(self, ignore_issues=False, force=False):
|
||||
"""Update."""
|
||||
if not await self.common_update(ignore_issues, force) and not force:
|
||||
return
|
||||
|
||||
# Get plugin objects.
|
||||
self.update_filenames()
|
||||
|
||||
if self.content.path.remote is None:
|
||||
self.validate.errors.append(
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
if self.content.path.remote == "release":
|
||||
self.content.single = True
|
||||
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
{
|
||||
"id": 1337,
|
||||
"action": "update",
|
||||
"repository": self.data.full_name,
|
||||
"repository_id": self.data.id,
|
||||
},
|
||||
)
|
||||
|
||||
async def get_package_content(self):
|
||||
"""Get package content."""
|
||||
try:
|
||||
package = await self.repository_object.get_contents("package.json", self.ref)
|
||||
package = json_loads(package.content)
|
||||
|
||||
if package:
|
||||
self.data.authors = package["author"]
|
||||
except BaseException: # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
def update_filenames(self) -> None:
|
||||
"""Get the filename to target."""
|
||||
content_in_root = self.repository_manifest.content_in_root
|
||||
if specific_filename := self.repository_manifest.filename:
|
||||
valid_filenames = (specific_filename,)
|
||||
else:
|
||||
valid_filenames = (
|
||||
f"{self.data.name.replace('lovelace-', '')}.js",
|
||||
f"{self.data.name}.js",
|
||||
f"{self.data.name}.umd.js",
|
||||
f"{self.data.name}-bundle.js",
|
||||
)
|
||||
|
||||
if not content_in_root:
|
||||
if self.releases.objects:
|
||||
release = self.releases.objects[0]
|
||||
if release.assets:
|
||||
if assetnames := [
|
||||
filename
|
||||
for filename in valid_filenames
|
||||
for asset in release.assets
|
||||
if filename == asset.name
|
||||
]:
|
||||
self.data.file_name = assetnames[0]
|
||||
self.content.path.remote = "release"
|
||||
return
|
||||
|
||||
all_paths = {x.full_path for x in self.tree}
|
||||
for filename in valid_filenames:
|
||||
if filename in all_paths:
|
||||
self.data.file_name = filename
|
||||
self.content.path.remote = ""
|
||||
return
|
||||
if not content_in_root and f"dist/{filename}" in all_paths:
|
||||
self.data.file_name = filename.split("/")[-1]
|
||||
self.content.path.remote = "dist"
|
||||
return
|
||||
|
||||
def generate_dashboard_resource_hacstag(self) -> str:
|
||||
"""Get the HACS tag used by dashboard resources."""
|
||||
version = (
|
||||
self.display_installed_version
|
||||
or self.data.selected_tag
|
||||
or self.display_available_version
|
||||
)
|
||||
return f"{self.data.id}{HACSTAG_REPLACER.sub('', version)}"
|
||||
|
||||
def generate_dashboard_resource_namespace(self) -> str:
|
||||
"""Get the dashboard resource namespace."""
|
||||
return f"/hacsfiles/{self.data.full_name.split("/")[1]}"
|
||||
|
||||
def generate_dashboard_resource_url(self) -> str:
|
||||
"""Get the dashboard resource namespace."""
|
||||
filename = self.data.file_name
|
||||
if "/" in filename:
|
||||
self.logger.warning("%s have defined an invalid file name %s", self.string, filename)
|
||||
filename = filename.split("/")[-1]
|
||||
return (
|
||||
f"{self.generate_dashboard_resource_namespace()}/{filename}"
|
||||
f"?hacstag={self.generate_dashboard_resource_hacstag()}"
|
||||
)
|
||||
|
||||
def _get_resource_handler(self) -> ResourceStorageCollection | None:
|
||||
"""Get the resource handler."""
|
||||
resources: ResourceStorageCollection | None
|
||||
if not (hass_data := self.hacs.hass.data):
|
||||
self.logger.error("%s Can not access the hass data", self.string)
|
||||
return
|
||||
|
||||
if (lovelace_data := hass_data.get("lovelace")) is None:
|
||||
self.logger.warning("%s Can not access the lovelace integration data", self.string)
|
||||
return
|
||||
|
||||
if self.hacs.core.ha_version > "2025.1.99":
|
||||
# Changed to 2025.2.0
|
||||
# Changed in https://github.com/home-assistant/core/pull/136313
|
||||
resources = lovelace_data.resources
|
||||
else:
|
||||
resources = lovelace_data.get("resources")
|
||||
|
||||
if resources is None:
|
||||
self.logger.warning("%s Can not access the dashboard resources", self.string)
|
||||
return
|
||||
|
||||
if not hasattr(resources, "store") or resources.store is None:
|
||||
self.logger.info("%s YAML mode detected, can not update resources", self.string)
|
||||
return
|
||||
|
||||
if resources.store.key != "lovelace_resources" or resources.store.version != 1:
|
||||
self.logger.warning("%s Can not use the dashboard resources", self.string)
|
||||
return
|
||||
|
||||
return resources
|
||||
|
||||
async def update_dashboard_resources(self) -> None:
|
||||
"""Update dashboard resources."""
|
||||
if not (resources := self._get_resource_handler()):
|
||||
return
|
||||
|
||||
if not resources.loaded:
|
||||
await resources.async_load()
|
||||
|
||||
namespace = self.generate_dashboard_resource_namespace()
|
||||
url = self.generate_dashboard_resource_url()
|
||||
|
||||
for entry in resources.async_items():
|
||||
if (entry_url := entry["url"]).startswith(namespace):
|
||||
if entry_url != url:
|
||||
self.logger.info(
|
||||
"%s Updating existing dashboard resource from %s to %s",
|
||||
self.string,
|
||||
entry_url,
|
||||
url,
|
||||
)
|
||||
await resources.async_update_item(entry["id"], {"url": url})
|
||||
return
|
||||
|
||||
# Nothing was updated, add the resource
|
||||
self.logger.info("%s Adding dashboard resource %s", self.string, url)
|
||||
await resources.async_create_item({"res_type": "module", "url": url})
|
||||
|
||||
async def remove_dashboard_resources(self) -> None:
|
||||
"""Remove dashboard resources."""
|
||||
if not (resources := self._get_resource_handler()):
|
||||
return
|
||||
|
||||
if not resources.loaded:
|
||||
await resources.async_load()
|
||||
|
||||
namespace = self.generate_dashboard_resource_namespace()
|
||||
|
||||
for entry in resources.async_items():
|
||||
if entry["url"].startswith(namespace):
|
||||
self.logger.info("%s Removing dashboard resource %s", self.string, entry["url"])
|
||||
await resources.async_delete_item(entry["id"])
|
||||
return
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Class for python_scripts in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..enums import HacsCategory, HacsDispatchEvent
|
||||
from ..exceptions import HacsException
|
||||
from ..utils.decorator import concurrent
|
||||
from .base import HacsRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
class HacsPythonScriptRepository(HacsRepository):
|
||||
"""python_scripts in HACS."""
|
||||
|
||||
category = "python_script"
|
||||
|
||||
def __init__(self, hacs: HacsBase, full_name: str):
|
||||
"""Initialize."""
|
||||
super().__init__(hacs=hacs)
|
||||
self.data.full_name = full_name
|
||||
self.data.full_name_lower = full_name.lower()
|
||||
self.data.category = HacsCategory.PYTHON_SCRIPT
|
||||
self.content.path.remote = "python_scripts"
|
||||
self.content.path.local = self.localpath
|
||||
self.content.single = True
|
||||
|
||||
@property
|
||||
def localpath(self):
|
||||
"""Return localpath."""
|
||||
return f"{self.hacs.core.config_path}/python_scripts"
|
||||
|
||||
async def validate_repository(self):
|
||||
"""Validate."""
|
||||
# Run common validation steps.
|
||||
await self.common_validate()
|
||||
|
||||
# Custom step 1: Validate content.
|
||||
if self.repository_manifest.content_in_root:
|
||||
self.content.path.remote = ""
|
||||
|
||||
compliant = False
|
||||
for treefile in self.treefiles:
|
||||
if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".py"):
|
||||
compliant = True
|
||||
break
|
||||
if not compliant:
|
||||
raise HacsException(
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
# Handle potential errors
|
||||
if self.validate.errors:
|
||||
for error in self.validate.errors:
|
||||
if not self.hacs.status.startup:
|
||||
self.logger.error("%s %s", self.string, error)
|
||||
return self.validate.success
|
||||
|
||||
async def async_post_registration(self):
|
||||
"""Registration."""
|
||||
# Set name
|
||||
self.update_filenames()
|
||||
|
||||
if self.hacs.system.action:
|
||||
await self.hacs.validation.async_run_repository_checks(self)
|
||||
|
||||
@concurrent(concurrenttasks=10, backoff_time=5)
|
||||
async def update_repository(self, ignore_issues=False, force=False):
|
||||
"""Update."""
|
||||
if not await self.common_update(ignore_issues, force) and not force:
|
||||
return
|
||||
|
||||
# Get python_script objects.
|
||||
if self.repository_manifest.content_in_root:
|
||||
self.content.path.remote = ""
|
||||
|
||||
compliant = False
|
||||
for treefile in self.treefiles:
|
||||
if treefile.startswith(f"{self.content.path.remote}") and treefile.endswith(".py"):
|
||||
compliant = True
|
||||
break
|
||||
if not compliant:
|
||||
raise HacsException(
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
# Update name
|
||||
self.update_filenames()
|
||||
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
{
|
||||
"id": 1337,
|
||||
"action": "update",
|
||||
"repository": self.data.full_name,
|
||||
"repository_id": self.data.id,
|
||||
},
|
||||
)
|
||||
|
||||
def update_filenames(self) -> None:
|
||||
"""Get the filename to target."""
|
||||
for treefile in self.tree:
|
||||
if treefile.full_path.startswith(
|
||||
self.content.path.remote
|
||||
) and treefile.full_path.endswith(".py"):
|
||||
self.data.file_name = treefile.filename
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Class for themes in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from ..enums import HacsCategory, HacsDispatchEvent
|
||||
from ..exceptions import HacsException
|
||||
from ..utils.decorator import concurrent
|
||||
from .base import HacsRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
class HacsTemplateRepository(HacsRepository):
|
||||
"""Custom templates in HACS."""
|
||||
|
||||
def __init__(self, hacs: HacsBase, full_name: str):
|
||||
"""Initialize."""
|
||||
super().__init__(hacs=hacs)
|
||||
self.data.full_name = full_name
|
||||
self.data.full_name_lower = full_name.lower()
|
||||
self.data.category = HacsCategory.TEMPLATE
|
||||
self.content.path.remote = ""
|
||||
self.content.path.local = self.localpath
|
||||
self.content.single = True
|
||||
|
||||
@property
|
||||
def localpath(self):
|
||||
"""Return localpath."""
|
||||
return f"{self.hacs.core.config_path}/custom_templates"
|
||||
|
||||
async def async_post_installation(self):
|
||||
"""Run post installation steps."""
|
||||
await self._reload_custom_templates()
|
||||
|
||||
async def validate_repository(self):
|
||||
"""Validate."""
|
||||
# Run common validation steps.
|
||||
await self.common_validate()
|
||||
|
||||
# Custom step 1: Validate content.
|
||||
self.data.file_name = self.repository_manifest.filename
|
||||
|
||||
if (
|
||||
not self.data.file_name
|
||||
or "/" in self.data.file_name
|
||||
or not self.data.file_name.endswith(".jinja")
|
||||
or self.data.file_name not in self.treefiles
|
||||
):
|
||||
raise HacsException(
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
# Handle potential errors
|
||||
if self.validate.errors:
|
||||
for error in self.validate.errors:
|
||||
if not self.hacs.status.startup:
|
||||
self.logger.error("%s %s", self.string, error)
|
||||
return self.validate.success
|
||||
|
||||
async def async_post_registration(self):
|
||||
"""Registration."""
|
||||
# Set filenames
|
||||
self.data.file_name = self.repository_manifest.filename
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
if self.hacs.system.action:
|
||||
await self.hacs.validation.async_run_repository_checks(self)
|
||||
|
||||
async def async_post_uninstall(self) -> None:
|
||||
"""Run post uninstall steps."""
|
||||
await self._reload_custom_templates()
|
||||
|
||||
async def _reload_custom_templates(self) -> None:
|
||||
"""Reload custom templates."""
|
||||
self.logger.debug("%s Reloading custom templates", self.string)
|
||||
try:
|
||||
await self.hacs.hass.services.async_call("homeassistant", "reload_custom_templates", {})
|
||||
except HomeAssistantError as exception:
|
||||
self.logger.exception("%s %s", self.string, exception)
|
||||
|
||||
@concurrent(concurrenttasks=10, backoff_time=5)
|
||||
async def update_repository(self, ignore_issues=False, force=False):
|
||||
"""Update."""
|
||||
if not await self.common_update(ignore_issues, force) and not force:
|
||||
return
|
||||
|
||||
# Update filenames
|
||||
self.data.file_name = self.repository_manifest.filename
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
{
|
||||
"id": 1337,
|
||||
"action": "update",
|
||||
"repository": self.data.full_name,
|
||||
"repository_id": self.data.id,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Class for themes in HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from ..enums import HacsCategory, HacsDispatchEvent
|
||||
from ..exceptions import HacsException
|
||||
from ..utils.decorator import concurrent
|
||||
from .base import HacsRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
class HacsThemeRepository(HacsRepository):
|
||||
"""Themes in HACS."""
|
||||
|
||||
def __init__(self, hacs: HacsBase, full_name: str):
|
||||
"""Initialize."""
|
||||
super().__init__(hacs=hacs)
|
||||
self.data.full_name = full_name
|
||||
self.data.full_name_lower = full_name.lower()
|
||||
self.data.category = HacsCategory.THEME
|
||||
self.content.path.remote = "themes"
|
||||
self.content.path.local = self.localpath
|
||||
self.content.single = False
|
||||
|
||||
@property
|
||||
def localpath(self):
|
||||
"""Return localpath."""
|
||||
return f"{self.hacs.core.config_path}/themes/{self.data.file_name.replace('.yaml', '')}"
|
||||
|
||||
async def async_post_installation(self):
|
||||
"""Run post installation steps."""
|
||||
await self._reload_frontend_themes()
|
||||
|
||||
async def validate_repository(self):
|
||||
"""Validate."""
|
||||
# Run common validation steps.
|
||||
await self.common_validate()
|
||||
|
||||
# Custom step 1: Validate content.
|
||||
compliant = False
|
||||
for treefile in self.treefiles:
|
||||
if treefile.startswith("themes/") and treefile.endswith(".yaml"):
|
||||
compliant = True
|
||||
break
|
||||
if not compliant:
|
||||
raise HacsException(
|
||||
f"{self.string} Repository structure for {self.ref.replace('tags/','')} is not compliant"
|
||||
)
|
||||
|
||||
if self.repository_manifest.content_in_root:
|
||||
self.content.path.remote = ""
|
||||
|
||||
# Handle potential errors
|
||||
if self.validate.errors:
|
||||
for error in self.validate.errors:
|
||||
if not self.hacs.status.startup:
|
||||
self.logger.error("%s %s", self.string, error)
|
||||
return self.validate.success
|
||||
|
||||
async def async_post_registration(self):
|
||||
"""Registration."""
|
||||
# Set name
|
||||
self.update_filenames()
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
if self.hacs.system.action:
|
||||
await self.hacs.validation.async_run_repository_checks(self)
|
||||
|
||||
async def _reload_frontend_themes(self) -> None:
|
||||
"""Reload frontend themes."""
|
||||
self.logger.debug("%s Reloading frontend themes", self.string)
|
||||
try:
|
||||
await self.hacs.hass.services.async_call("frontend", "reload_themes", {})
|
||||
except HomeAssistantError as exception:
|
||||
self.logger.exception("%s %s", self.string, exception)
|
||||
|
||||
async def async_post_uninstall(self) -> None:
|
||||
"""Run post uninstall steps."""
|
||||
await self._reload_frontend_themes()
|
||||
|
||||
@concurrent(concurrenttasks=10, backoff_time=5)
|
||||
async def update_repository(self, ignore_issues=False, force=False):
|
||||
"""Update."""
|
||||
if not await self.common_update(ignore_issues, force) and not force:
|
||||
return
|
||||
|
||||
# Get theme objects.
|
||||
if self.repository_manifest.content_in_root:
|
||||
self.content.path.remote = ""
|
||||
|
||||
# Update name
|
||||
self.update_filenames()
|
||||
self.content.path.local = self.localpath
|
||||
|
||||
# Signal frontend to refresh
|
||||
if self.data.installed:
|
||||
self.hacs.async_dispatch(
|
||||
HacsDispatchEvent.REPOSITORY,
|
||||
{
|
||||
"id": 1337,
|
||||
"action": "update",
|
||||
"repository": self.data.full_name,
|
||||
"repository_id": self.data.id,
|
||||
},
|
||||
)
|
||||
|
||||
def update_filenames(self) -> None:
|
||||
"""Get the filename to target."""
|
||||
for treefile in self.tree:
|
||||
if treefile.full_path.startswith(
|
||||
self.content.path.remote
|
||||
) and treefile.full_path.endswith(".yaml"):
|
||||
self.data.file_name = treefile.filename
|
||||
73
homeassistant/config/custom_components/hacs/switch.py
Normal file
73
homeassistant/config/custom_components/hacs/switch.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Switch entities for HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .base import HacsBase
|
||||
from .const import DOMAIN
|
||||
from .entity import HacsRepositoryEntity
|
||||
from .repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Setup switch platform."""
|
||||
hacs: HacsBase = hass.data[DOMAIN]
|
||||
async_add_entities(
|
||||
HacsRepositoryPreReleaseSwitchEntity(hacs=hacs, repository=repository)
|
||||
for repository in hacs.repositories.list_downloaded
|
||||
)
|
||||
|
||||
|
||||
class HacsRepositoryPreReleaseSwitchEntity(HacsRepositoryEntity, SwitchEntity):
|
||||
"""Pre-release switch entities for repositories downloaded with HACS."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "pre-release"
|
||||
|
||||
def __init__(self, hacs: HacsBase, repository: HacsRepository) -> None:
|
||||
"""Initialize the repository pre-release switch."""
|
||||
super().__init__(hacs, repository)
|
||||
self._attr_entity_registry_enabled_default = self.repository.data.show_beta
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return if the pre-release option is enabled for the repository."""
|
||||
return self.repository.data.show_beta
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
await self._handle_change(value=True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
await self._handle_change(value=False)
|
||||
|
||||
async def _handle_change(self, value: bool) -> None:
|
||||
"""Handle attribute value changes."""
|
||||
self.repository.data.show_beta = value
|
||||
|
||||
# As this value is directly affecting what data points is in use by other entities
|
||||
# we need to update all entities to reflect the change
|
||||
# Do force an update of the entities we need to clear the last fetched data
|
||||
# since that is used to limit state updates
|
||||
# Once we have signaled the update we can restore the last fetched data
|
||||
_last_fetch = self.repository.data.last_fetched
|
||||
self.repository.data.last_fetched = None
|
||||
self.coordinator.async_update_listeners()
|
||||
self.repository.data.last_fetched = _last_fetch # Restore last fetched
|
||||
|
||||
# Write the HACS data and update the entity state
|
||||
await self.hacs.data.async_write()
|
||||
self.async_write_ha_state()
|
||||
52
homeassistant/config/custom_components/hacs/system_health.py
Normal file
52
homeassistant/config/custom_components/hacs/system_health.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Provide info to system health."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiogithubapi.common.const import BASE_API_URL
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .base import HacsBase
|
||||
from .const import DOMAIN
|
||||
|
||||
GITHUB_STATUS = "https://www.githubstatus.com/"
|
||||
CLOUDFLARE_STATUS = "https://www.cloudflarestatus.com/"
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegistration) -> None:
|
||||
"""Register system health callbacks."""
|
||||
register.domain = "Home Assistant Community Store"
|
||||
register.async_register_info(system_health_info, "/hacs")
|
||||
|
||||
|
||||
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Get info for the info page."""
|
||||
if DOMAIN not in hass.data:
|
||||
return {"Disabled": "HACS is not loaded, but HA still requests this information..."}
|
||||
|
||||
hacs: HacsBase = hass.data[DOMAIN]
|
||||
response = await hacs.githubapi.rate_limit()
|
||||
|
||||
data = {
|
||||
"GitHub API": system_health.async_check_can_reach_url(hass, BASE_API_URL, GITHUB_STATUS),
|
||||
"GitHub Content": system_health.async_check_can_reach_url(
|
||||
hass, "https://raw.githubusercontent.com/hacs/integration/main/hacs.json"
|
||||
),
|
||||
"GitHub Web": system_health.async_check_can_reach_url(
|
||||
hass, "https://github.com/", GITHUB_STATUS
|
||||
),
|
||||
"HACS Data": system_health.async_check_can_reach_url(
|
||||
hass, "https://data-v2.hacs.xyz/data.json", CLOUDFLARE_STATUS
|
||||
),
|
||||
"GitHub API Calls Remaining": response.data.resources.core.remaining,
|
||||
"Installed Version": hacs.version,
|
||||
"Stage": hacs.stage,
|
||||
"Available Repositories": len(hacs.repositories.list_all),
|
||||
"Downloaded Repositories": len(hacs.repositories.list_downloaded),
|
||||
}
|
||||
|
||||
if hacs.system.disabled:
|
||||
data["Disabled"] = hacs.system.disabled_reason
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"single_instance_allowed": "Only a single configuration of HACS is allowed.",
|
||||
"min_ha_version": "You need at least version {version} of Home Assistant to setup HACS.",
|
||||
"github": "Could not authenticate with GitHub, try again later.",
|
||||
"not_setup": "HACS is not setup.",
|
||||
"reauth_successful": "Reauthentication was successful."
|
||||
},
|
||||
"error": {
|
||||
"auth": "Personal Access Token is not correct",
|
||||
"acc": "You need to acknowledge all the statements before continuing"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"acc_logs": "I know how to access Home Assistant logs",
|
||||
"acc_addons": "I know that there are no add-ons in HACS",
|
||||
"acc_untested": "I know that everything inside HACS including HACS itself is custom and untested by Home Assistant",
|
||||
"acc_disable": "I know that if I get issues with Home Assistant I should disable all my custom_components"
|
||||
},
|
||||
"description": "Before you can setup HACS you need to acknowledge the following"
|
||||
},
|
||||
"device": {
|
||||
"title": "Waiting for device activation"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Reauthentication needed",
|
||||
"description": "You need to reauthenticate with GitHub."
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
"wait_for_device": "1. Open {url} \n2. Paste the following key to authorize HACS: \n```\n{code}\n```"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"not_setup": "HACS is not setup.",
|
||||
"pending_tasks": "There are pending tasks. Try again later.",
|
||||
"release_limit_value": "The release limit needs to be between 1 and 100."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"not_in_use": "Not in use with YAML",
|
||||
"country": "Filter with country code",
|
||||
"release_limit": "Number of releases to show",
|
||||
"debug": "Enable debug",
|
||||
"appdaemon": "Enable AppDaemon apps discovery & tracking",
|
||||
"sidepanel_icon": "Side panel icon",
|
||||
"sidepanel_title": "Side panel title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"restart_required": {
|
||||
"title": "Restart required",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm_restart": {
|
||||
"title": "Restart required",
|
||||
"description": "Restart of Home Assistant is required to finish download/update of {name}, click submit to restart now."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"removed": {
|
||||
"title": "Repository removed from HACS",
|
||||
"description": "Because {reason}, `{name}` has been removed from HACS. Please visit the [HACS Panel](/hacs/repository/{repositry_id}) to remove it."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"switch": {
|
||||
"pre-release": {
|
||||
"name": "Pre-release",
|
||||
"state": {
|
||||
"off": "No pre-releases",
|
||||
"on": "Pre-releases preferred"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
homeassistant/config/custom_components/hacs/types.py
Normal file
10
homeassistant/config/custom_components/hacs/types.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Custom HACS types."""
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class DownloadableContent(TypedDict):
|
||||
"""Downloadable content."""
|
||||
|
||||
url: str
|
||||
name: str
|
||||
158
homeassistant/config/custom_components/hacs/update.py
Normal file
158
homeassistant/config/custom_components/hacs/update.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Update entities for HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, HomeAssistantError, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .base import HacsBase
|
||||
from .const import DOMAIN
|
||||
from .entity import HacsRepositoryEntity
|
||||
from .enums import HacsCategory, HacsDispatchEvent
|
||||
from .exceptions import HacsException
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Setup update platform."""
|
||||
hacs: HacsBase = hass.data[DOMAIN]
|
||||
async_add_entities(
|
||||
HacsRepositoryUpdateEntity(hacs=hacs, repository=repository)
|
||||
for repository in hacs.repositories.list_downloaded
|
||||
)
|
||||
|
||||
|
||||
class HacsRepositoryUpdateEntity(HacsRepositoryEntity, UpdateEntity):
|
||||
"""Update entities for repositories downloaded with HACS."""
|
||||
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL
|
||||
| UpdateEntityFeature.SPECIFIC_VERSION
|
||||
| UpdateEntityFeature.PROGRESS
|
||||
| UpdateEntityFeature.RELEASE_NOTES
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Return the name."""
|
||||
return f"{self.repository.display_name} update"
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str:
|
||||
"""Return latest version of the entity."""
|
||||
return self.repository.display_available_version
|
||||
|
||||
@property
|
||||
def release_url(self) -> str:
|
||||
"""Return the URL of the release page."""
|
||||
if self.repository.display_version_or_commit == "commit":
|
||||
return f"https://github.com/{self.repository.data.full_name}"
|
||||
return f"https://github.com/{self.repository.data.full_name}/releases/{self.latest_version}"
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str:
|
||||
"""Return downloaded version of the entity."""
|
||||
return self.repository.display_installed_version
|
||||
|
||||
@property
|
||||
def release_summary(self) -> str | None:
|
||||
"""Return the release summary."""
|
||||
if self.repository.pending_restart:
|
||||
return "<ha-alert alert-type='error'>Restart of Home Assistant required</ha-alert>"
|
||||
return None
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the entity picture to use in the frontend."""
|
||||
if (
|
||||
self.repository.data.category != HacsCategory.INTEGRATION
|
||||
or self.repository.data.domain is None
|
||||
):
|
||||
return None
|
||||
|
||||
return f"https://brands.home-assistant.io/_/{self.repository.data.domain}/icon.png"
|
||||
|
||||
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
|
||||
"""Install an update."""
|
||||
to_download = version or self.latest_version
|
||||
if to_download == self.installed_version:
|
||||
raise HomeAssistantError(f"Version {self.installed_version} of {
|
||||
self.repository.data.full_name} is already downloaded")
|
||||
try:
|
||||
await self.repository.async_download_repository(ref=version or self.latest_version)
|
||||
except HacsException as exception:
|
||||
raise HomeAssistantError(exception) from exception
|
||||
|
||||
async def async_release_notes(self) -> str | None:
|
||||
"""Return the release notes."""
|
||||
if self.repository.pending_restart:
|
||||
return None
|
||||
|
||||
if self.latest_version not in self.repository.data.published_tags:
|
||||
releases = await self.repository.get_releases(
|
||||
prerelease=self.repository.data.show_beta,
|
||||
returnlimit=self.hacs.configuration.release_limit,
|
||||
)
|
||||
if releases:
|
||||
self.repository.data.releases = True
|
||||
self.repository.releases.objects = releases
|
||||
self.repository.data.published_tags = [x.tag_name for x in releases]
|
||||
self.repository.data.last_version = next(iter(self.repository.data.published_tags))
|
||||
|
||||
release_notes = ""
|
||||
# Compile release notes from installed version up to the latest
|
||||
if self.installed_version in self.repository.data.published_tags:
|
||||
for release in self.repository.releases.objects:
|
||||
if release.tag_name == self.installed_version:
|
||||
break
|
||||
release_notes += f"# {release.tag_name}"
|
||||
if release.tag_name != release.name:
|
||||
release_notes += f" - {release.name}"
|
||||
release_notes += f"\n\n{release.body}"
|
||||
release_notes += "\n\n---\n\n"
|
||||
elif any(self.repository.releases.objects):
|
||||
release_notes += self.repository.releases.objects[0].body
|
||||
|
||||
if self.repository.pending_update:
|
||||
if self.repository.data.category == HacsCategory.INTEGRATION:
|
||||
release_notes += (
|
||||
"\n\n<ha-alert alert-type='warning'>You need to restart"
|
||||
" Home Assistant manually after updating.</ha-alert>\n\n"
|
||||
)
|
||||
if self.repository.data.category == HacsCategory.PLUGIN:
|
||||
release_notes += (
|
||||
"\n\n<ha-alert alert-type='warning'>You need to manually"
|
||||
" clear the frontend cache after updating.</ha-alert>\n\n"
|
||||
)
|
||||
|
||||
return release_notes.replace("\n#", "\n\n#")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register for status events."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
HacsDispatchEvent.REPOSITORY_DOWNLOAD_PROGRESS,
|
||||
self._update_download_progress,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_download_progress(self, data: dict) -> None:
|
||||
"""Update the download progress."""
|
||||
if data["repository"] != self.repository.data.full_name:
|
||||
return
|
||||
self._update_in_progress(progress=data["progress"])
|
||||
|
||||
@callback
|
||||
def _update_in_progress(self, progress: int | bool) -> None:
|
||||
"""Update the download progress."""
|
||||
self._attr_in_progress = progress
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1 @@
|
||||
"""Initialize HACS utils."""
|
||||
110
homeassistant/config/custom_components/hacs/utils/backup.py
Normal file
110
homeassistant/config/custom_components/hacs/utils/backup.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Backup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from time import sleep
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .path import is_safe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
DEFAULT_BACKUP_PATH = f"{tempfile.gettempdir()}/hacs_backup/"
|
||||
|
||||
|
||||
class Backup:
|
||||
"""Backup."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hacs: HacsBase,
|
||||
local_path: str | None = None,
|
||||
backup_path: str = DEFAULT_BACKUP_PATH,
|
||||
repository: HacsRepository | None = None,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.hacs = hacs
|
||||
self.repository = repository
|
||||
self.local_path = local_path or repository.content.path.local
|
||||
self.backup_path = backup_path
|
||||
if repository:
|
||||
self.backup_path = (
|
||||
tempfile.gettempdir()
|
||||
+ f"/hacs_persistent_{repository.data.category}/"
|
||||
+ repository.data.name
|
||||
)
|
||||
self.backup_path_full = f"{self.backup_path}{self.local_path.split('/')[-1]}"
|
||||
|
||||
def _init_backup_dir(self) -> bool:
|
||||
"""Init backup dir."""
|
||||
if not os.path.exists(self.local_path):
|
||||
return False
|
||||
if not is_safe(self.hacs, self.local_path):
|
||||
return False
|
||||
if os.path.exists(self.backup_path):
|
||||
shutil.rmtree(self.backup_path)
|
||||
|
||||
# Wait for the folder to be removed
|
||||
while os.path.exists(self.backup_path):
|
||||
sleep(0.1)
|
||||
os.makedirs(self.backup_path, exist_ok=True)
|
||||
return True
|
||||
|
||||
def create(self) -> None:
|
||||
"""Create a backup in /tmp"""
|
||||
if not self._init_backup_dir():
|
||||
return
|
||||
|
||||
try:
|
||||
if os.path.isfile(self.local_path):
|
||||
shutil.copyfile(self.local_path, self.backup_path_full)
|
||||
os.remove(self.local_path)
|
||||
else:
|
||||
shutil.copytree(self.local_path, self.backup_path_full)
|
||||
shutil.rmtree(self.local_path)
|
||||
while os.path.exists(self.local_path):
|
||||
sleep(0.1)
|
||||
self.hacs.log.debug(
|
||||
"Backup for %s, created in %s",
|
||||
self.local_path,
|
||||
self.backup_path_full,
|
||||
)
|
||||
except (
|
||||
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
) as exception:
|
||||
self.hacs.log.warning("Could not create backup: %s", exception)
|
||||
|
||||
def restore(self) -> None:
|
||||
"""Restore from backup."""
|
||||
if not os.path.exists(self.backup_path_full):
|
||||
return
|
||||
|
||||
if os.path.isfile(self.backup_path_full):
|
||||
if os.path.exists(self.local_path):
|
||||
os.remove(self.local_path)
|
||||
shutil.copyfile(self.backup_path_full, self.local_path)
|
||||
else:
|
||||
if os.path.exists(self.local_path):
|
||||
shutil.rmtree(self.local_path)
|
||||
while os.path.exists(self.local_path):
|
||||
sleep(0.1)
|
||||
shutil.copytree(self.backup_path_full, self.local_path)
|
||||
self.hacs.log.debug("Restored %s, from backup %s", self.local_path, self.backup_path_full)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup backup files."""
|
||||
if not os.path.exists(self.backup_path):
|
||||
return
|
||||
|
||||
shutil.rmtree(self.backup_path)
|
||||
|
||||
# Wait for the folder to be removed
|
||||
while os.path.exists(self.backup_path):
|
||||
sleep(0.1)
|
||||
self.hacs.log.debug("Backup dir %s cleared", self.backup_path)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""HACS Configuration Schemas."""
|
||||
|
||||
# Configuration:
|
||||
SIDEPANEL_TITLE = "sidepanel_title"
|
||||
SIDEPANEL_ICON = "sidepanel_icon"
|
||||
APPDAEMON = "appdaemon"
|
||||
|
||||
# Options:
|
||||
COUNTRY = "country"
|
||||
323
homeassistant/config/custom_components/hacs/utils/data.py
Normal file
323
homeassistant/config/custom_components/hacs/utils/data.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""Data handler for HACS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from ..base import HacsBase
|
||||
from ..const import HACS_REPOSITORY_ID
|
||||
from ..enums import HacsDisabledReason, HacsDispatchEvent
|
||||
from ..repositories.base import TOPIC_FILTER, HacsManifest, HacsRepository
|
||||
from .logger import LOGGER
|
||||
from .path import is_safe
|
||||
from .store import async_load_from_store, async_save_to_store
|
||||
|
||||
EXPORTED_BASE_DATA = (
|
||||
("new", False),
|
||||
("full_name", ""),
|
||||
)
|
||||
|
||||
EXPORTED_REPOSITORY_DATA = EXPORTED_BASE_DATA + (
|
||||
("authors", []),
|
||||
("category", ""),
|
||||
("description", ""),
|
||||
("domain", None),
|
||||
("downloads", 0),
|
||||
("etag_repository", None),
|
||||
("hide", False),
|
||||
("last_updated", 0),
|
||||
("new", False),
|
||||
("stargazers_count", 0),
|
||||
("topics", []),
|
||||
)
|
||||
|
||||
EXPORTED_DOWNLOADED_REPOSITORY_DATA = EXPORTED_REPOSITORY_DATA + (
|
||||
("archived", False),
|
||||
("config_flow", False),
|
||||
("default_branch", None),
|
||||
("first_install", False),
|
||||
("installed_commit", None),
|
||||
("installed", False),
|
||||
("last_commit", None),
|
||||
("last_version", None),
|
||||
("manifest_name", None),
|
||||
("open_issues", 0),
|
||||
("prerelease", None),
|
||||
("published_tags", []),
|
||||
("releases", False),
|
||||
("selected_tag", None),
|
||||
("show_beta", False),
|
||||
)
|
||||
|
||||
|
||||
class HacsData:
|
||||
"""HacsData class."""
|
||||
|
||||
def __init__(self, hacs: HacsBase):
|
||||
"""Initialize."""
|
||||
self.logger = LOGGER
|
||||
self.hacs = hacs
|
||||
self.content = {}
|
||||
|
||||
async def async_force_write(self, _=None):
|
||||
"""Force write."""
|
||||
await self.async_write(force=True)
|
||||
|
||||
async def async_write(self, force: bool = False) -> None:
|
||||
"""Write content to the store files."""
|
||||
if not force and self.hacs.system.disabled:
|
||||
return
|
||||
|
||||
self.logger.debug("<HacsData async_write> Saving data")
|
||||
|
||||
# Hacs
|
||||
await async_save_to_store(
|
||||
self.hacs.hass,
|
||||
"hacs",
|
||||
{
|
||||
"archived_repositories": self.hacs.common.archived_repositories,
|
||||
"renamed_repositories": self.hacs.common.renamed_repositories,
|
||||
"ignored_repositories": self.hacs.common.ignored_repositories,
|
||||
},
|
||||
)
|
||||
await self._async_store_experimental_content_and_repos()
|
||||
await self._async_store_content_and_repos()
|
||||
|
||||
async def _async_store_content_and_repos(self, _=None): # bb: ignore
|
||||
"""Store the main repos file and each repo that is out of date."""
|
||||
# Repositories
|
||||
self.content = {}
|
||||
for repository in self.hacs.repositories.list_all:
|
||||
if repository.data.category in self.hacs.common.categories:
|
||||
self.async_store_repository_data(repository)
|
||||
|
||||
await async_save_to_store(self.hacs.hass, "repositories", self.content)
|
||||
for event in (HacsDispatchEvent.REPOSITORY, HacsDispatchEvent.CONFIG):
|
||||
self.hacs.async_dispatch(event, {})
|
||||
|
||||
async def _async_store_experimental_content_and_repos(self, _=None):
|
||||
"""Store the main repos file and each repo that is out of date."""
|
||||
# Repositories
|
||||
self.content = {}
|
||||
for repository in self.hacs.repositories.list_all:
|
||||
if repository.data.category in self.hacs.common.categories:
|
||||
self.async_store_experimental_repository_data(repository)
|
||||
|
||||
await async_save_to_store(self.hacs.hass, "data", {"repositories": self.content})
|
||||
|
||||
@callback
|
||||
def async_store_repository_data(self, repository: HacsRepository) -> dict:
|
||||
"""Store the repository data."""
|
||||
data = {"repository_manifest": repository.repository_manifest.manifest}
|
||||
|
||||
for key, default in (
|
||||
EXPORTED_DOWNLOADED_REPOSITORY_DATA
|
||||
if repository.data.installed
|
||||
else EXPORTED_REPOSITORY_DATA
|
||||
):
|
||||
if (value := getattr(repository.data, key, default)) != default:
|
||||
data[key] = value
|
||||
|
||||
if repository.data.installed_version:
|
||||
data["version_installed"] = repository.data.installed_version
|
||||
if repository.data.last_fetched:
|
||||
data["last_fetched"] = repository.data.last_fetched.timestamp()
|
||||
|
||||
self.content[str(repository.data.id)] = data
|
||||
|
||||
@callback
|
||||
def async_store_experimental_repository_data(self, repository: HacsRepository) -> None:
|
||||
"""Store the experimental repository data for non downloaded repositories."""
|
||||
data = {}
|
||||
self.content.setdefault(repository.data.category, [])
|
||||
|
||||
if repository.data.installed:
|
||||
data["repository_manifest"] = repository.repository_manifest.manifest
|
||||
for key, default in EXPORTED_DOWNLOADED_REPOSITORY_DATA:
|
||||
if (value := getattr(repository.data, key, default)) != default:
|
||||
data[key] = value
|
||||
|
||||
if repository.data.installed_version:
|
||||
data["version_installed"] = repository.data.installed_version
|
||||
if repository.data.last_fetched:
|
||||
data["last_fetched"] = repository.data.last_fetched.timestamp()
|
||||
else:
|
||||
for key, default in EXPORTED_BASE_DATA:
|
||||
if (value := getattr(repository.data, key, default)) != default:
|
||||
data[key] = value
|
||||
|
||||
self.content[repository.data.category].append({"id": str(repository.data.id), **data})
|
||||
|
||||
async def restore(self):
|
||||
"""Restore saved data."""
|
||||
self.hacs.status.new = False
|
||||
repositories = {}
|
||||
hacs = {}
|
||||
|
||||
try:
|
||||
hacs = await async_load_from_store(self.hacs.hass, "hacs") or {}
|
||||
except HomeAssistantError:
|
||||
pass
|
||||
|
||||
try:
|
||||
repositories = await async_load_from_store(self.hacs.hass, "repositories")
|
||||
if not repositories and (data := await async_load_from_store(self.hacs.hass, "data")):
|
||||
for category, entries in data.get("repositories", {}).items():
|
||||
for repository in entries:
|
||||
repositories[repository["id"]] = {"category": category, **repository}
|
||||
|
||||
except HomeAssistantError as exception:
|
||||
self.hacs.log.error(
|
||||
"Could not read %s, restore the file from a backup - %s",
|
||||
self.hacs.hass.config.path(".storage/hacs.data"),
|
||||
exception,
|
||||
)
|
||||
self.hacs.disable_hacs(HacsDisabledReason.RESTORE)
|
||||
return False
|
||||
|
||||
if not hacs and not repositories:
|
||||
# Assume new install
|
||||
self.hacs.status.new = True
|
||||
return True
|
||||
|
||||
self.logger.info("<HacsData restore> Restore started")
|
||||
|
||||
# Hacs
|
||||
self.hacs.common.archived_repositories = set()
|
||||
self.hacs.common.ignored_repositories = set()
|
||||
self.hacs.common.renamed_repositories = {}
|
||||
|
||||
# Clear out doubble renamed values
|
||||
renamed = hacs.get("renamed_repositories", {})
|
||||
for entry in renamed:
|
||||
value = renamed.get(entry)
|
||||
if value not in renamed:
|
||||
self.hacs.common.renamed_repositories[entry] = value
|
||||
|
||||
# Clear out doubble archived values
|
||||
for entry in hacs.get("archived_repositories", set()):
|
||||
if entry not in self.hacs.common.archived_repositories:
|
||||
self.hacs.common.archived_repositories.add(entry)
|
||||
|
||||
# Clear out doubble ignored values
|
||||
for entry in hacs.get("ignored_repositories", set()):
|
||||
if entry not in self.hacs.common.ignored_repositories:
|
||||
self.hacs.common.ignored_repositories.add(entry)
|
||||
|
||||
try:
|
||||
await self.register_unknown_repositories(repositories)
|
||||
|
||||
for entry, repo_data in repositories.items():
|
||||
if entry == "0":
|
||||
# Ignore repositories with ID 0
|
||||
self.logger.debug(
|
||||
"<HacsData restore> Found repository with ID %s - %s", entry, repo_data
|
||||
)
|
||||
continue
|
||||
self.async_restore_repository(entry, repo_data)
|
||||
|
||||
self.logger.info("<HacsData restore> Restore done")
|
||||
except (
|
||||
# lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
BaseException
|
||||
) as exception:
|
||||
self.logger.critical(
|
||||
"<HacsData restore> [%s] Restore Failed!", exception, exc_info=exception
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
async def register_unknown_repositories(
|
||||
self, repositories: dict[str, dict[str, Any]], category: str | None = None
|
||||
):
|
||||
"""Registry any unknown repositories."""
|
||||
for repo_idx, (entry, repo_data) in enumerate(repositories.items()):
|
||||
# async_register_repository is awaited in a loop
|
||||
# since its unlikely to ever suspend at startup
|
||||
if (
|
||||
entry == "0"
|
||||
or repo_data.get("category", category) is None
|
||||
or self.hacs.repositories.is_registered(repository_id=entry)
|
||||
):
|
||||
continue
|
||||
await self.hacs.async_register_repository(
|
||||
repository_full_name=repo_data["full_name"],
|
||||
category=repo_data.get("category", category),
|
||||
check=False,
|
||||
repository_id=entry,
|
||||
)
|
||||
if repo_idx % 100 == 0:
|
||||
# yield to avoid blocking the event loop
|
||||
await asyncio.sleep(0)
|
||||
|
||||
@callback
|
||||
def async_restore_repository(self, entry: str, repository_data: dict[str, Any]):
|
||||
"""Restore repository."""
|
||||
repository: HacsRepository | None = None
|
||||
if full_name := repository_data.get("full_name"):
|
||||
repository = self.hacs.repositories.get_by_full_name(full_name)
|
||||
if not repository:
|
||||
repository = self.hacs.repositories.get_by_id(entry)
|
||||
if not repository:
|
||||
return
|
||||
|
||||
try:
|
||||
self.hacs.repositories.set_repository_id(repository, entry)
|
||||
except ValueError as exception:
|
||||
self.logger.warning("<HacsData async_restore_repository> duplicate IDs %s", exception)
|
||||
return
|
||||
|
||||
# Restore repository attributes
|
||||
repository.data.authors = repository_data.get("authors", [])
|
||||
repository.data.description = repository_data.get("description", "")
|
||||
repository.data.downloads = repository_data.get("downloads", 0)
|
||||
repository.data.last_updated = repository_data.get("last_updated", 0)
|
||||
if self.hacs.system.generator:
|
||||
repository.data.etag_releases = repository_data.get("etag_releases")
|
||||
repository.data.open_issues = repository_data.get("open_issues", 0)
|
||||
repository.data.etag_repository = repository_data.get("etag_repository")
|
||||
repository.data.topics = [
|
||||
topic for topic in repository_data.get("topics", []) if topic not in TOPIC_FILTER
|
||||
]
|
||||
repository.data.domain = repository_data.get("domain")
|
||||
repository.data.stargazers_count = repository_data.get(
|
||||
"stargazers_count"
|
||||
) or repository_data.get("stars", 0)
|
||||
repository.releases.last_release = repository_data.get("last_release_tag")
|
||||
repository.data.releases = repository_data.get("releases", False)
|
||||
repository.data.installed = repository_data.get("installed", False)
|
||||
repository.data.new = repository_data.get("new", False)
|
||||
repository.data.selected_tag = repository_data.get("selected_tag")
|
||||
repository.data.show_beta = repository_data.get("show_beta", False)
|
||||
repository.data.last_version = repository_data.get("last_version")
|
||||
repository.data.prerelease = repository_data.get("prerelease")
|
||||
repository.data.last_commit = repository_data.get("last_commit")
|
||||
repository.data.installed_version = repository_data.get("version_installed")
|
||||
repository.data.installed_commit = repository_data.get("installed_commit")
|
||||
repository.data.manifest_name = repository_data.get("manifest_name")
|
||||
|
||||
if last_fetched := repository_data.get("last_fetched"):
|
||||
repository.data.last_fetched = datetime.fromtimestamp(last_fetched, UTC)
|
||||
|
||||
repository.repository_manifest = HacsManifest.from_dict(
|
||||
repository_data.get("manifest") or repository_data.get("repository_manifest") or {}
|
||||
)
|
||||
|
||||
if repository.data.prerelease == repository.data.last_version:
|
||||
repository.data.prerelease = None
|
||||
|
||||
if repository.localpath is not None and is_safe(self.hacs, repository.localpath):
|
||||
# Set local path
|
||||
repository.content.path.local = repository.localpath
|
||||
|
||||
if repository.data.installed:
|
||||
repository.data.first_install = False
|
||||
|
||||
if entry == HACS_REPOSITORY_ID:
|
||||
repository.data.installed_version = self.hacs.version
|
||||
repository.data.installed = True
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Util to decode content from the github API."""
|
||||
|
||||
from base64 import b64decode
|
||||
|
||||
|
||||
def decode_content(content: str) -> str:
|
||||
"""Decode content."""
|
||||
return b64decode(bytearray(content, "utf-8")).decode()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""HACS Decorators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Coroutine
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..const import DEFAULT_CONCURRENT_BACKOFF_TIME, DEFAULT_CONCURRENT_TASKS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
def concurrent(
|
||||
concurrenttasks: int = DEFAULT_CONCURRENT_TASKS,
|
||||
backoff_time: int = DEFAULT_CONCURRENT_BACKOFF_TIME,
|
||||
) -> Coroutine[Any, Any, None]:
|
||||
"""Return a modified function."""
|
||||
|
||||
max_concurrent = asyncio.Semaphore(concurrenttasks)
|
||||
|
||||
def inner_function(function) -> Coroutine[Any, Any, None]:
|
||||
@wraps(function)
|
||||
async def wrapper(*args, **kwargs) -> None:
|
||||
hacs: HacsBase = getattr(args[0], "hacs", None)
|
||||
|
||||
async with max_concurrent:
|
||||
result = await function(*args, **kwargs)
|
||||
if (
|
||||
hacs is None
|
||||
or hacs.queue is None
|
||||
or hacs.queue.has_pending_tasks
|
||||
or "update" not in function.__name__
|
||||
):
|
||||
await asyncio.sleep(backoff_time)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return inner_function
|
||||
@@ -0,0 +1,42 @@
|
||||
"""File system functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import TypeAlias
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
# From typeshed
|
||||
StrOrBytesPath: TypeAlias = str | bytes | os.PathLike[str] | os.PathLike[bytes]
|
||||
FileDescriptorOrPath: TypeAlias = int | StrOrBytesPath
|
||||
|
||||
|
||||
async def async_exists(hass: HomeAssistant, path: FileDescriptorOrPath) -> bool:
|
||||
"""Test whether a path exists."""
|
||||
return await hass.async_add_executor_job(os.path.exists, path)
|
||||
|
||||
|
||||
async def async_remove(
|
||||
hass: HomeAssistant, path: StrOrBytesPath, *, missing_ok: bool = False
|
||||
) -> None:
|
||||
"""Remove a path."""
|
||||
try:
|
||||
return await hass.async_add_executor_job(os.remove, path)
|
||||
except FileNotFoundError:
|
||||
if missing_ok:
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
async def async_remove_directory(
|
||||
hass: HomeAssistant, path: StrOrBytesPath, *, missing_ok: bool = False
|
||||
) -> None:
|
||||
"""Remove a directory."""
|
||||
try:
|
||||
return await hass.async_add_executor_job(shutil.rmtree, path)
|
||||
except FileNotFoundError:
|
||||
if missing_ok:
|
||||
return
|
||||
raise
|
||||
47
homeassistant/config/custom_components/hacs/utils/filters.py
Normal file
47
homeassistant/config/custom_components/hacs/utils/filters.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Filter functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def filter_content_return_one_of_type(
|
||||
content: list[str | Any],
|
||||
namestartswith: str,
|
||||
filterfiltype: str,
|
||||
attr: str = "name",
|
||||
) -> list[str]:
|
||||
"""Only match 1 of the filter."""
|
||||
contents = []
|
||||
filetypefound = False
|
||||
for filename in content:
|
||||
if isinstance(filename, str):
|
||||
if filename.startswith(namestartswith):
|
||||
if filename.endswith(f".{filterfiltype}"):
|
||||
if not filetypefound:
|
||||
contents.append(filename)
|
||||
filetypefound = True
|
||||
continue
|
||||
else:
|
||||
contents.append(filename)
|
||||
else:
|
||||
if getattr(filename, attr).startswith(namestartswith):
|
||||
if getattr(filename, attr).endswith(f".{filterfiltype}"):
|
||||
if not filetypefound:
|
||||
contents.append(filename)
|
||||
filetypefound = True
|
||||
continue
|
||||
else:
|
||||
contents.append(filename)
|
||||
return contents
|
||||
|
||||
|
||||
def get_first_directory_in_directory(content: list[str | Any], dirname: str) -> str | None:
|
||||
"""Return the first directory in dirname or None."""
|
||||
directory = None
|
||||
for path in content:
|
||||
if path.full_path.startswith(dirname) and path.full_path != dirname:
|
||||
if path.is_directory:
|
||||
directory = path.filename
|
||||
break
|
||||
return directory
|
||||
@@ -0,0 +1,19 @@
|
||||
"""GitHub GraphQL Queries."""
|
||||
|
||||
GET_REPOSITORY_RELEASES = """
|
||||
query ($owner: String!, $name: String!, $first: Int!) {
|
||||
rateLimit {
|
||||
cost
|
||||
}
|
||||
repository(owner: $owner, name: $name) {
|
||||
releases(first: $first, orderBy: {field: CREATED_AT, direction: DESC}) {
|
||||
nodes {
|
||||
tagName
|
||||
name
|
||||
isPrerelease
|
||||
publishedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
"""JSON utils."""
|
||||
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
__all__ = ["json_loads"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Custom logger for HACS."""
|
||||
|
||||
import logging
|
||||
|
||||
from ..const import PACKAGE_NAME
|
||||
|
||||
LOGGER: logging.Logger = logging.getLogger(PACKAGE_NAME)
|
||||
41
homeassistant/config/custom_components/hacs/utils/path.py
Normal file
41
homeassistant/config/custom_components/hacs/utils/path.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Path utils"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_safe_paths(
|
||||
config_path: str,
|
||||
appdaemon_path: str,
|
||||
plugin_path: str,
|
||||
python_script_path: str,
|
||||
theme_path: str,
|
||||
) -> set[str]:
|
||||
"""Get safe paths."""
|
||||
return {
|
||||
Path(f"{config_path}/{appdaemon_path}").as_posix(),
|
||||
Path(f"{config_path}/{plugin_path}").as_posix(),
|
||||
Path(f"{config_path}/{python_script_path}").as_posix(),
|
||||
Path(f"{config_path}/{theme_path}").as_posix(),
|
||||
Path(f"{config_path}/custom_components/").as_posix(),
|
||||
Path(f"{config_path}/custom_templates/").as_posix(),
|
||||
}
|
||||
|
||||
|
||||
def is_safe(hacs: HacsBase, path: str | Path) -> bool:
|
||||
"""Helper to check if path is safe to remove."""
|
||||
configuration = hacs.configuration
|
||||
return Path(path).as_posix() not in _get_safe_paths(
|
||||
hacs.core.config_path,
|
||||
configuration.appdaemon_path,
|
||||
configuration.plugin_path,
|
||||
configuration.python_script_path,
|
||||
configuration.theme_path,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""The QueueManager class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Coroutine
|
||||
import time
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..exceptions import HacsExecutionStillInProgress
|
||||
from .logger import LOGGER
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
|
||||
class QueueManager:
|
||||
"""The QueueManager class."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
self.hass = hass
|
||||
self.queue: list[Coroutine] = []
|
||||
self.running = False
|
||||
|
||||
@property
|
||||
def pending_tasks(self) -> int:
|
||||
"""Return a count of pending tasks in the queue."""
|
||||
return len(self.queue)
|
||||
|
||||
@property
|
||||
def has_pending_tasks(self) -> bool:
|
||||
"""Return a count of pending tasks in the queue."""
|
||||
return self.pending_tasks != 0
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the queue."""
|
||||
self.queue = []
|
||||
|
||||
def add(self, task: Coroutine) -> None:
|
||||
"""Add a task to the queue."""
|
||||
self.queue.append(task)
|
||||
|
||||
async def execute(self, number_of_tasks: int | None = None) -> None:
|
||||
"""Execute the tasks in the queue."""
|
||||
if self.running:
|
||||
_LOGGER.debug("<QueueManager> Execution is already running")
|
||||
raise HacsExecutionStillInProgress
|
||||
if len(self.queue) == 0:
|
||||
_LOGGER.debug("<QueueManager> The queue is empty")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
|
||||
_LOGGER.debug("<QueueManager> Checking out tasks to execute")
|
||||
local_queue = []
|
||||
|
||||
if number_of_tasks:
|
||||
for task in self.queue[:number_of_tasks]:
|
||||
local_queue.append(task)
|
||||
else:
|
||||
for task in self.queue:
|
||||
local_queue.append(task)
|
||||
|
||||
_LOGGER.debug("<QueueManager> Starting queue execution for %s tasks", len(local_queue))
|
||||
start = time.time()
|
||||
result = await asyncio.gather(*local_queue, return_exceptions=True)
|
||||
for entry in result:
|
||||
if isinstance(entry, Exception):
|
||||
_LOGGER.error("<QueueManager> %s", entry)
|
||||
end = time.time() - start
|
||||
|
||||
for task in local_queue:
|
||||
self.queue.remove(task)
|
||||
|
||||
_LOGGER.debug(
|
||||
"<QueueManager> Queue execution finished for %s tasks finished in %.2f seconds",
|
||||
len(local_queue),
|
||||
end,
|
||||
)
|
||||
if self.has_pending_tasks:
|
||||
_LOGGER.debug("<QueueManager> %s tasks remaining in the queue", len(self.queue))
|
||||
self.running = False
|
||||
17
homeassistant/config/custom_components/hacs/utils/regex.py
Normal file
17
homeassistant/config/custom_components/hacs/utils/regex.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Regex utils"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
RE_REPOSITORY = re.compile(
|
||||
r"(?:(?:.*github.com.)|^)([A-Za-z0-9-]+\/[\w.-]+?)(?:(?:\.git)?|(?:[^\w.-].*)?)$"
|
||||
)
|
||||
|
||||
|
||||
def extract_repository_from_url(url: str) -> str | None:
|
||||
"""Extract the owner/repo part form a URL."""
|
||||
match = re.match(RE_REPOSITORY, url)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1).lower()
|
||||
79
homeassistant/config/custom_components/hacs/utils/store.py
Normal file
79
homeassistant/config/custom_components/hacs/utils/store.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Storage handers."""
|
||||
|
||||
from homeassistant.helpers.json import JSONEncoder
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import json as json_util
|
||||
|
||||
from ..const import VERSION_STORAGE
|
||||
from ..exceptions import HacsException
|
||||
from .logger import LOGGER
|
||||
|
||||
_LOGGER = LOGGER
|
||||
|
||||
|
||||
class HACSStore(Store):
|
||||
"""A subclass of Store that allows multiple loads in the executor."""
|
||||
|
||||
def load(self):
|
||||
"""Load the data from disk if version matches."""
|
||||
try:
|
||||
data = json_util.load_json(self.path)
|
||||
except (
|
||||
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
) as exception:
|
||||
_LOGGER.critical(
|
||||
"Could not load '%s', restore it from a backup or delete the file: %s",
|
||||
self.path,
|
||||
exception,
|
||||
)
|
||||
raise HacsException(exception) from exception
|
||||
if data == {} or data["version"] != self.version:
|
||||
return None
|
||||
return data["data"]
|
||||
|
||||
|
||||
def get_store_key(key):
|
||||
"""Return the key to use with homeassistant.helpers.storage.Storage."""
|
||||
return key if "/" in key else f"hacs.{key}"
|
||||
|
||||
|
||||
def _get_store_for_key(hass, key, encoder):
|
||||
"""Create a Store object for the key."""
|
||||
return HACSStore(hass, VERSION_STORAGE, get_store_key(key), encoder=encoder, atomic_writes=True)
|
||||
|
||||
|
||||
def get_store_for_key(hass, key):
|
||||
"""Create a Store object for the key."""
|
||||
return _get_store_for_key(hass, key, JSONEncoder)
|
||||
|
||||
|
||||
async def async_load_from_store(hass, key):
|
||||
"""Load the retained data from store and return de-serialized data."""
|
||||
return await get_store_for_key(hass, key).async_load() or {}
|
||||
|
||||
|
||||
async def async_save_to_store(hass, key, data):
|
||||
"""Generate dynamic data to store and save it to the filesystem.
|
||||
|
||||
The data is only written if the content on the disk has changed
|
||||
by reading the existing content and comparing it.
|
||||
|
||||
If the data has changed this will generate two executor jobs
|
||||
|
||||
If the data has not changed this will generate one executor job
|
||||
"""
|
||||
current = await async_load_from_store(hass, key)
|
||||
if current is None or current != data:
|
||||
await get_store_for_key(hass, key).async_save(data)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"<HACSStore async_save_to_store> Did not store data for '%s'. Content did not change",
|
||||
get_store_key(key),
|
||||
)
|
||||
|
||||
|
||||
async def async_remove_store(hass, key):
|
||||
"""Remove a store element that should no longer be used."""
|
||||
if "/" not in key:
|
||||
return
|
||||
await get_store_for_key(hass, key).async_remove()
|
||||
30
homeassistant/config/custom_components/hacs/utils/url.py
Normal file
30
homeassistant/config/custom_components/hacs/utils/url.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Various URL utils for HACS."""
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
GIT_SHA = re.compile(r"^[a-fA-F0-9]{40}$")
|
||||
|
||||
|
||||
def github_release_asset(
|
||||
*,
|
||||
repository: str,
|
||||
version: str,
|
||||
filename: str,
|
||||
**_,
|
||||
) -> str:
|
||||
"""Generate a download URL for a release asset."""
|
||||
return f"https://github.com/{repository}/releases/download/{version}/{filename}"
|
||||
|
||||
|
||||
def github_archive(
|
||||
*,
|
||||
repository: str,
|
||||
version: str,
|
||||
variant: Literal["heads", "tags"] = "heads",
|
||||
**_,
|
||||
) -> str:
|
||||
"""Generate a download URL for a repository zip."""
|
||||
if GIT_SHA.match(version):
|
||||
return f"https://github.com/{repository}/archive/{version}.zip"
|
||||
return f"https://github.com/{repository}/archive/refs/{variant}/{version}.zip"
|
||||
215
homeassistant/config/custom_components/hacs/utils/validate.py
Normal file
215
homeassistant/config/custom_components/hacs/utils/validate.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""Validation utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from awesomeversion import AwesomeVersion
|
||||
from homeassistant.helpers.config_validation import url as url_validator
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import LOCALE
|
||||
|
||||
|
||||
@dataclass
|
||||
class Validate:
|
||||
"""Validate."""
|
||||
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""Return bool if the validation was a success."""
|
||||
return len(self.errors) == 0
|
||||
|
||||
|
||||
def _country_validator(values) -> list[str]:
|
||||
"""Custom country validator."""
|
||||
countries = []
|
||||
if isinstance(values, str):
|
||||
countries.append(values.upper())
|
||||
elif isinstance(values, list):
|
||||
for value in values:
|
||||
countries.append(value.upper())
|
||||
else:
|
||||
raise vol.Invalid(f"Value '{values}' is not a string or list.", path=["country"])
|
||||
|
||||
for country in countries:
|
||||
if country not in LOCALE:
|
||||
raise vol.Invalid(f"Value '{country}' is not in {LOCALE}.", path=["country"])
|
||||
|
||||
return countries
|
||||
|
||||
|
||||
HACS_MANIFEST_JSON_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("content_in_root"): bool,
|
||||
vol.Optional("country"): _country_validator,
|
||||
vol.Optional("filename"): str,
|
||||
vol.Optional("hacs"): str,
|
||||
vol.Optional("hide_default_branch"): bool,
|
||||
vol.Optional("homeassistant"): str,
|
||||
vol.Optional("persistent_directory"): str,
|
||||
vol.Optional("render_readme"): bool,
|
||||
vol.Optional("zip_release"): bool,
|
||||
vol.Required("name"): str,
|
||||
},
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
|
||||
INTEGRATION_MANIFEST_JSON_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("codeowners"): list,
|
||||
vol.Required("documentation"): url_validator,
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("issue_tracker"): url_validator,
|
||||
vol.Required("name"): str,
|
||||
vol.Required("version"): vol.Coerce(AwesomeVersion),
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def validate_repo_data(schema: dict[str, Any], extra: int) -> Callable[[Any], Any]:
|
||||
"""Return a validator for repo data.
|
||||
|
||||
This is used instead of vol.All to always try both the repo schema and
|
||||
and the validate_version validator.
|
||||
"""
|
||||
_schema = vol.Schema(schema, extra=extra)
|
||||
|
||||
def validate_repo_data(data: Any) -> Any:
|
||||
"""Validate integration repo data."""
|
||||
schema_errors: vol.MultipleInvalid | None = None
|
||||
try:
|
||||
_schema(data)
|
||||
except vol.MultipleInvalid as err:
|
||||
schema_errors = err
|
||||
try:
|
||||
validate_version(data)
|
||||
except vol.Invalid as err:
|
||||
if schema_errors:
|
||||
schema_errors.add(err)
|
||||
else:
|
||||
raise
|
||||
if schema_errors:
|
||||
raise schema_errors
|
||||
return data
|
||||
|
||||
return validate_repo_data
|
||||
|
||||
|
||||
def validate_version(data: Any) -> Any:
|
||||
"""Ensure at least one of last_commit or last_version is present."""
|
||||
if "last_commit" not in data and "last_version" not in data:
|
||||
raise vol.Invalid("Expected at least one of [`last_commit`, `last_version`], got none")
|
||||
return data
|
||||
|
||||
|
||||
V2_COMMON_DATA_JSON_SCHEMA = {
|
||||
vol.Required("description"): vol.Any(str, None),
|
||||
vol.Optional("downloads"): int,
|
||||
vol.Optional("etag_releases"): str,
|
||||
vol.Required("etag_repository"): str,
|
||||
vol.Required("full_name"): str,
|
||||
vol.Optional("last_commit"): str,
|
||||
vol.Required("last_fetched"): vol.Any(int, float),
|
||||
vol.Required("last_updated"): str,
|
||||
vol.Optional("last_version"): str,
|
||||
vol.Optional("prerelease"): str,
|
||||
vol.Required("manifest"): {
|
||||
vol.Optional("country"): vol.Any([str], False),
|
||||
vol.Optional("name"): str,
|
||||
},
|
||||
vol.Optional("open_issues"): int,
|
||||
vol.Optional("stargazers_count"): int,
|
||||
vol.Optional("topics"): [str],
|
||||
}
|
||||
|
||||
V2_INTEGRATION_DATA_JSON_SCHEMA = {
|
||||
**V2_COMMON_DATA_JSON_SCHEMA,
|
||||
vol.Required("domain"): str,
|
||||
vol.Required("manifest_name"): str,
|
||||
}
|
||||
|
||||
_V2_REPO_SCHEMAS = {
|
||||
"appdaemon": V2_COMMON_DATA_JSON_SCHEMA,
|
||||
"integration": V2_INTEGRATION_DATA_JSON_SCHEMA,
|
||||
"plugin": V2_COMMON_DATA_JSON_SCHEMA,
|
||||
"python_script": V2_COMMON_DATA_JSON_SCHEMA,
|
||||
"template": V2_COMMON_DATA_JSON_SCHEMA,
|
||||
"theme": V2_COMMON_DATA_JSON_SCHEMA,
|
||||
}
|
||||
|
||||
# Used when validating repos in the hacs integration, discards extra keys
|
||||
VALIDATE_FETCHED_V2_REPO_DATA = {
|
||||
category: validate_repo_data(schema, vol.REMOVE_EXTRA)
|
||||
for category, schema in _V2_REPO_SCHEMAS.items()
|
||||
}
|
||||
|
||||
# Used when validating repos when generating data, fails on extra keys
|
||||
VALIDATE_GENERATED_V2_REPO_DATA = {
|
||||
category: vol.Schema({str: validate_repo_data(schema, vol.PREVENT_EXTRA)})
|
||||
for category, schema in _V2_REPO_SCHEMAS.items()
|
||||
}
|
||||
|
||||
V2_CRITICAL_REPO_DATA_SCHEMA = {
|
||||
vol.Required("link"): str,
|
||||
vol.Required("reason"): str,
|
||||
vol.Required("repository"): str,
|
||||
}
|
||||
|
||||
# Used when validating critical repos in the hacs integration, discards extra keys
|
||||
VALIDATE_FETCHED_V2_CRITICAL_REPO_SCHEMA = vol.Schema(
|
||||
V2_CRITICAL_REPO_DATA_SCHEMA,
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
# Used when validating critical repos when generating data, fails on extra keys
|
||||
VALIDATE_GENERATED_V2_CRITICAL_REPO_SCHEMA = vol.Schema(
|
||||
[
|
||||
vol.Schema(
|
||||
V2_CRITICAL_REPO_DATA_SCHEMA,
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
V2_REMOVED_REPO_DATA_SCHEMA = {
|
||||
vol.Optional("link"): str,
|
||||
vol.Optional("reason"): str,
|
||||
vol.Required("removal_type"): vol.In(
|
||||
[
|
||||
"Integration is missing a version, and is abandoned.",
|
||||
"Remove",
|
||||
"archived",
|
||||
"blacklist",
|
||||
"critical",
|
||||
"deprecated",
|
||||
"removal",
|
||||
"remove",
|
||||
"removed",
|
||||
"replaced",
|
||||
"repository",
|
||||
]
|
||||
),
|
||||
vol.Required("repository"): str,
|
||||
}
|
||||
|
||||
# Used when validating removed repos in the hacs integration, discards extra keys
|
||||
VALIDATE_FETCHED_V2_REMOVED_REPO_SCHEMA = vol.Schema(
|
||||
V2_REMOVED_REPO_DATA_SCHEMA,
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
# Used when validating removed repos when generating data, fails on extra keys
|
||||
VALIDATE_GENERATED_V2_REMOVED_REPO_SCHEMA = vol.Schema(
|
||||
[
|
||||
vol.Schema(
|
||||
V2_REMOVED_REPO_DATA_SCHEMA,
|
||||
extra=vol.PREVENT_EXTRA,
|
||||
)
|
||||
]
|
||||
)
|
||||
36
homeassistant/config/custom_components/hacs/utils/version.py
Normal file
36
homeassistant/config/custom_components/hacs/utils/version.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Version utils."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from awesomeversion import (
|
||||
AwesomeVersion,
|
||||
AwesomeVersionException,
|
||||
AwesomeVersionStrategy,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def version_left_higher_then_right(left: str, right: str) -> bool | None:
|
||||
"""Return a bool if source is newer than target, will also be true if identical."""
|
||||
try:
|
||||
left_version = AwesomeVersion(left)
|
||||
right_version = AwesomeVersion(right)
|
||||
if (
|
||||
left_version.strategy != AwesomeVersionStrategy.UNKNOWN
|
||||
and right_version.strategy != AwesomeVersionStrategy.UNKNOWN
|
||||
):
|
||||
return left_version > right_version
|
||||
except (AwesomeVersionException, AttributeError, KeyError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def version_left_higher_or_equal_then_right(left: str, right: str) -> bool:
|
||||
"""Return a bool if source is newer than target, will also be true if identical."""
|
||||
if left == right:
|
||||
return True
|
||||
|
||||
return version_left_higher_then_right(left, right)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Workarounds."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
DOMAIN_OVERRIDES = {
|
||||
# https://github.com/hacs/integration/issues/2465
|
||||
"custom-components/sensor.custom_aftership": "custom_aftership"
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
from homeassistant.components.http import StaticPathConfig
|
||||
|
||||
async def async_register_static_path(
|
||||
hass: HomeAssistant,
|
||||
url_path: str,
|
||||
path: str,
|
||||
cache_headers: bool = True,
|
||||
) -> None:
|
||||
"""Register a static path with the HTTP component."""
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(url_path, path, cache_headers)]
|
||||
)
|
||||
except ImportError:
|
||||
|
||||
async def async_register_static_path(
|
||||
hass: HomeAssistant,
|
||||
url_path: str,
|
||||
path: str,
|
||||
cache_headers: bool = True,
|
||||
) -> None:
|
||||
"""Register a static path with the HTTP component.
|
||||
|
||||
Legacy: Can be removed when min version is 2024.7
|
||||
https://developers.home-assistant.io/blog/2024/06/18/async_register_static_paths/
|
||||
"""
|
||||
hass.http.register_static_path(url_path, path, cache_headers)
|
||||
@@ -0,0 +1,30 @@
|
||||
# Repository validation
|
||||
|
||||
This is where the validation rules that run against the various repository categories live.
|
||||
|
||||
## Structure
|
||||
|
||||
- There is one file pr. rule.
|
||||
- All rule needs tests to verify every possible outcome for the rule.
|
||||
- It's better with multiple files than a big rule.
|
||||
- All rules uses `ActionValidationBase` as the base class.
|
||||
- Only use `validate` or `async_validate` methods to define validation rules.
|
||||
- If a rule should fail, raise `ValidationException` with the failure message.
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
from .base import (
|
||||
ActionValidationBase,
|
||||
ValidationBase,
|
||||
ValidationException,
|
||||
)
|
||||
|
||||
class SuperAwesomeRepository(ActionValidationBase):
|
||||
category = "integration"
|
||||
|
||||
async def async_validate(self):
|
||||
if self.repository != "super-awesome":
|
||||
raise ValidationException("The repository is not super-awesome")
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
"""Initialize validation."""
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-archived"
|
||||
allow_fork = False
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if self.repository.data.archived:
|
||||
raise ValidationException("The repository is archived")
|
||||
54
homeassistant/config/custom_components/hacs/validate/base.py
Normal file
54
homeassistant/config/custom_components/hacs/validate/base.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Base class for validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..exceptions import HacsException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..enums import HacsCategory
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
class ValidationException(HacsException):
|
||||
"""Raise when there is a validation issue."""
|
||||
|
||||
|
||||
class ActionValidationBase:
|
||||
"""Base class for action validation."""
|
||||
|
||||
categories: tuple[HacsCategory, ...] = ()
|
||||
allow_fork: bool = True
|
||||
more_info: str = "https://hacs.xyz/docs/publish/action"
|
||||
|
||||
def __init__(self, repository: HacsRepository) -> None:
|
||||
self.hacs = repository.hacs
|
||||
self.repository = repository
|
||||
self.failed = False
|
||||
|
||||
@property
|
||||
def slug(self) -> str:
|
||||
"""Return the check slug."""
|
||||
return self.__class__.__module__.rsplit(".", maxsplit=1)[-1]
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
|
||||
async def execute_validation(self, *_: Any, **__: Any) -> None:
|
||||
"""Execute the task defined in subclass."""
|
||||
self.failed = False
|
||||
|
||||
try:
|
||||
await self.async_validate()
|
||||
except ValidationException as exception:
|
||||
self.failed = True
|
||||
self.hacs.log.error(
|
||||
"<Validation %s> failed: %s (More info: %s )",
|
||||
self.slug,
|
||||
exception,
|
||||
self.more_info,
|
||||
)
|
||||
|
||||
else:
|
||||
self.hacs.log.info("<Validation %s> completed", self.slug)
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.hacs.enums import HacsCategory
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
URL = "https://brands.home-assistant.io/domains.json"
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-brands"
|
||||
categories = (HacsCategory.INTEGRATION,)
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
|
||||
response = await self.hacs.session.get(URL)
|
||||
content = await response.json()
|
||||
|
||||
if self.repository.data.domain not in content["custom"]:
|
||||
raise ValidationException(
|
||||
"The repository has not been added as a custom domain to the brands repo"
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
|
||||
allow_fork = False
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if not self.repository.data.description:
|
||||
raise ValidationException("The repository has no description")
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from voluptuous.error import Invalid
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from ..enums import HacsCategory, RepositoryFile
|
||||
from ..repositories.base import HacsManifest, HacsRepository
|
||||
from ..utils.validate import HACS_MANIFEST_JSON_SCHEMA
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-hacs-manifest"
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if RepositoryFile.HACS_JSON not in [x.filename for x in self.repository.tree]:
|
||||
raise ValidationException(f"The repository has no '{RepositoryFile.HACS_JSON}' file")
|
||||
|
||||
content = await self.repository.async_get_hacs_json(self.repository.ref)
|
||||
try:
|
||||
hacsjson = HacsManifest.from_dict(HACS_MANIFEST_JSON_SCHEMA(content))
|
||||
except Invalid as exception:
|
||||
raise ValidationException(humanize_error(content, exception)) from exception
|
||||
|
||||
if self.repository.data.category == HacsCategory.INTEGRATION:
|
||||
if hacsjson.zip_release and not hacsjson.filename:
|
||||
raise ValidationException("zip_release is True, but filename is not set")
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..enums import HacsCategory
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
IGNORED = ["-shield", "img.shields.io", "buymeacoffee.com"]
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
categories = (HacsCategory.PLUGIN, HacsCategory.THEME)
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-images"
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
info = await self.repository.async_get_info_file_contents(version=self.repository.ref)
|
||||
for line in info.split("\n"):
|
||||
if "<img" in line or "![" in line:
|
||||
if [ignore for ignore in IGNORED if ignore in line]:
|
||||
continue
|
||||
return
|
||||
raise ValidationException("The repository does not have images in the Readme file")
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-info"
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
filenames = [x.filename.lower() for x in self.repository.tree]
|
||||
if "readme" in filenames:
|
||||
pass
|
||||
elif "readme.md" in filenames:
|
||||
pass
|
||||
elif "info" in filenames:
|
||||
pass
|
||||
elif "info.md" in filenames:
|
||||
pass
|
||||
else:
|
||||
raise ValidationException("The repository has no information file")
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from voluptuous.error import Invalid
|
||||
|
||||
from ..enums import HacsCategory, RepositoryFile
|
||||
from ..utils.validate import INTEGRATION_MANIFEST_JSON_SCHEMA
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
from ..repositories.integration import HacsIntegrationRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
repository: HacsIntegrationRepository
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-manifest"
|
||||
categories = (HacsCategory.INTEGRATION,)
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if RepositoryFile.MAINIFEST_JSON not in [x.filename for x in self.repository.tree]:
|
||||
raise ValidationException(
|
||||
f"The repository has no '{RepositoryFile.MAINIFEST_JSON}' file"
|
||||
)
|
||||
|
||||
content = await self.repository.get_integration_manifest(version=self.repository.ref)
|
||||
try:
|
||||
INTEGRATION_MANIFEST_JSON_SCHEMA(content)
|
||||
except Invalid as exception:
|
||||
raise ValidationException(exception) from exception
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
|
||||
allow_fork = False
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if not self.repository.data.has_issues:
|
||||
raise ValidationException("The repository does not have issues enabled")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Hacs validation manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from importlib import import_module
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..base import HacsBase
|
||||
from ..repositories.base import HacsRepository
|
||||
from .base import ActionValidationBase
|
||||
|
||||
|
||||
class ValidationManager:
|
||||
"""Hacs validation manager."""
|
||||
|
||||
def __init__(self, hacs: HacsBase, hass: HomeAssistant) -> None:
|
||||
"""Initialize the setup manager class."""
|
||||
self.hacs = hacs
|
||||
self.hass = hass
|
||||
self._validators: dict[str, ActionValidationBase] = {}
|
||||
|
||||
@property
|
||||
def validators(self) -> list[ActionValidationBase]:
|
||||
"""Return all list of all tasks."""
|
||||
return list(self._validators.values())
|
||||
|
||||
async def async_load(self, repository: HacsRepository) -> None:
|
||||
"""Load all tasks."""
|
||||
self._validators = {}
|
||||
validator_files = Path(__file__).parent
|
||||
validator_modules = (
|
||||
module.stem
|
||||
for module in validator_files.glob("*.py")
|
||||
if module.name not in ("base.py", "__init__.py", "manager.py")
|
||||
)
|
||||
|
||||
async def _load_module(module: str) -> None:
|
||||
task_module = import_module(f"{__package__}.{module}")
|
||||
if task := await task_module.async_setup_validator(repository=repository):
|
||||
self._validators[task.slug] = task
|
||||
|
||||
await asyncio.gather(*[_load_module(task) for task in validator_modules])
|
||||
|
||||
async def async_run_repository_checks(self, repository: HacsRepository) -> None:
|
||||
"""Run all validators for a repository."""
|
||||
if not self.hacs.system.action:
|
||||
return
|
||||
|
||||
await self.async_load(repository)
|
||||
|
||||
is_pull_from_fork = (
|
||||
not os.getenv("INPUT_REPOSITORY")
|
||||
and os.getenv("GITHUB_REPOSITORY") != repository.data.full_name
|
||||
)
|
||||
|
||||
validators = [
|
||||
validator
|
||||
for validator in self.validators or []
|
||||
if (
|
||||
(not validator.categories or repository.data.category in validator.categories)
|
||||
and validator.slug not in os.getenv("INPUT_IGNORE", "").split(" ")
|
||||
and (not is_pull_from_fork or validator.allow_fork)
|
||||
)
|
||||
]
|
||||
|
||||
await asyncio.gather(*[validator.execute_validation() for validator in validators])
|
||||
|
||||
total = len(validators)
|
||||
failed = len([x for x in validators if x.failed])
|
||||
|
||||
if failed != 0:
|
||||
repository.logger.error("%s %s/%s checks failed", repository.string, failed, total)
|
||||
exit(1)
|
||||
else:
|
||||
repository.logger.info("%s All (%s) checks passed", repository.string, total)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import ActionValidationBase, ValidationException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..repositories.base import HacsRepository
|
||||
|
||||
|
||||
async def async_setup_validator(repository: HacsRepository) -> Validator:
|
||||
"""Set up this validator."""
|
||||
return Validator(repository=repository)
|
||||
|
||||
|
||||
class Validator(ActionValidationBase):
|
||||
"""Validate the repository."""
|
||||
|
||||
more_info = "https://hacs.xyz/docs/publish/include#check-repository"
|
||||
allow_fork = False
|
||||
|
||||
async def async_validate(self) -> None:
|
||||
"""Validate the repository."""
|
||||
if not self.repository.data.topics:
|
||||
raise ValidationException("The repository has no valid topics")
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Register_commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import DOMAIN
|
||||
from .critical import hacs_critical_acknowledge, hacs_critical_list
|
||||
from .repositories import (
|
||||
hacs_repositories_add,
|
||||
hacs_repositories_clear_new,
|
||||
hacs_repositories_list,
|
||||
hacs_repositories_remove,
|
||||
hacs_repositories_removed,
|
||||
)
|
||||
from .repository import (
|
||||
hacs_repository_beta,
|
||||
hacs_repository_download,
|
||||
hacs_repository_ignore,
|
||||
hacs_repository_info,
|
||||
hacs_repository_refresh,
|
||||
hacs_repository_release_notes,
|
||||
hacs_repository_releases,
|
||||
hacs_repository_remove,
|
||||
hacs_repository_state,
|
||||
hacs_repository_version,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_websocket_commands(hass: HomeAssistant) -> None:
|
||||
"""Register_commands."""
|
||||
websocket_api.async_register_command(hass, hacs_info)
|
||||
websocket_api.async_register_command(hass, hacs_subscribe)
|
||||
|
||||
websocket_api.async_register_command(hass, hacs_repository_info)
|
||||
websocket_api.async_register_command(hass, hacs_repository_download)
|
||||
websocket_api.async_register_command(hass, hacs_repository_ignore)
|
||||
websocket_api.async_register_command(hass, hacs_repository_state)
|
||||
websocket_api.async_register_command(hass, hacs_repository_version)
|
||||
websocket_api.async_register_command(hass, hacs_repository_beta)
|
||||
websocket_api.async_register_command(hass, hacs_repository_refresh)
|
||||
websocket_api.async_register_command(hass, hacs_repository_release_notes)
|
||||
websocket_api.async_register_command(hass, hacs_repository_remove)
|
||||
|
||||
websocket_api.async_register_command(hass, hacs_critical_acknowledge)
|
||||
websocket_api.async_register_command(hass, hacs_critical_list)
|
||||
|
||||
websocket_api.async_register_command(hass, hacs_repositories_list)
|
||||
websocket_api.async_register_command(hass, hacs_repositories_add)
|
||||
websocket_api.async_register_command(hass, hacs_repositories_clear_new)
|
||||
websocket_api.async_register_command(hass, hacs_repositories_removed)
|
||||
websocket_api.async_register_command(hass, hacs_repositories_remove)
|
||||
websocket_api.async_register_command(hass, hacs_repository_releases)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/subscribe",
|
||||
vol.Required("signal"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_subscribe(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
) -> None:
|
||||
"""Handle websocket subscriptions."""
|
||||
|
||||
@callback
|
||||
def forward_messages(data: dict | None = None) -> None:
|
||||
"""Forward events to websocket."""
|
||||
connection.send_message(websocket_api.event_message(msg["id"], data))
|
||||
|
||||
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
|
||||
hass,
|
||||
msg["signal"],
|
||||
forward_messages,
|
||||
)
|
||||
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/info",
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_info(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return information about HACS."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
connection.send_message(
|
||||
websocket_api.result_message(
|
||||
msg["id"],
|
||||
{
|
||||
"categories": hacs.common.categories,
|
||||
"country": hacs.configuration.country,
|
||||
"debug": hacs.configuration.debug,
|
||||
"dev": hacs.configuration.dev,
|
||||
"disabled_reason": hacs.system.disabled_reason,
|
||||
"has_pending_tasks": hacs.queue.has_pending_tasks,
|
||||
"lovelace_mode": hacs.core.lovelace_mode,
|
||||
"stage": hacs.stage,
|
||||
"startup": hacs.status.startup,
|
||||
"version": hacs.version,
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Register info websocket commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
|
||||
from ..utils.store import async_load_from_store, async_save_to_store
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/critical/list",
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_critical_list(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""List critical repositories."""
|
||||
connection.send_message(
|
||||
websocket_api.result_message(
|
||||
msg["id"],
|
||||
(await async_load_from_store(hass, "critical") or []),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/critical/acknowledge",
|
||||
vol.Optional("repository"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_critical_acknowledge(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Acknowledge critical repository."""
|
||||
repository = msg["repository"]
|
||||
|
||||
critical = await async_load_from_store(hass, "critical")
|
||||
for repo in critical:
|
||||
if repository == repo["repository"]:
|
||||
repo["acknowledged"] = True
|
||||
await async_save_to_store(hass, "critical", critical)
|
||||
connection.send_message(websocket_api.result_message(msg["id"], critical))
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Register info websocket commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.hacs.utils import regex
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..enums import HacsDispatchEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repositories/list",
|
||||
vol.Optional("categories"): [str],
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repositories_list(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""List repositories."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
connection.send_message(
|
||||
websocket_api.result_message(
|
||||
msg["id"],
|
||||
[
|
||||
{
|
||||
"authors": repo.data.authors,
|
||||
"available_version": repo.display_available_version,
|
||||
"installed_version": repo.display_installed_version,
|
||||
"config_flow": repo.data.config_flow,
|
||||
"can_download": repo.can_download,
|
||||
"category": repo.data.category,
|
||||
"country": repo.repository_manifest.country,
|
||||
"custom": not hacs.repositories.is_default(str(repo.data.id)),
|
||||
"description": repo.data.description,
|
||||
"domain": repo.data.domain,
|
||||
"downloads": repo.data.downloads,
|
||||
"file_name": repo.data.file_name,
|
||||
"full_name": repo.data.full_name,
|
||||
"hide": repo.data.hide,
|
||||
"homeassistant": repo.repository_manifest.homeassistant,
|
||||
"id": repo.data.id,
|
||||
"installed": repo.data.installed,
|
||||
"last_updated": repo.data.last_updated,
|
||||
"local_path": repo.content.path.local,
|
||||
"name": repo.display_name,
|
||||
"new": repo.data.new,
|
||||
"pending_upgrade": repo.pending_update,
|
||||
"stars": repo.data.stargazers_count,
|
||||
"state": repo.state,
|
||||
"status": repo.display_status,
|
||||
"topics": repo.data.topics,
|
||||
}
|
||||
for repo in hacs.repositories.list_all
|
||||
if repo.data.category in msg.get("categories", hacs.common.categories)
|
||||
and not repo.ignored_by_country_configuration
|
||||
and repo.data.last_fetched
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repositories/clear_new",
|
||||
vol.Optional("categories"): cv.ensure_list,
|
||||
vol.Optional("repository"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repositories_clear_new(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Clear new repositories for specific categories."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
|
||||
if repo := msg.get("repository"):
|
||||
repository = hacs.repositories.get_by_id(repo)
|
||||
repository.data.new = False
|
||||
|
||||
else:
|
||||
for repo in hacs.repositories.list_all:
|
||||
if repo.data.new and repo.data.category in msg.get("categories", []):
|
||||
hacs.log.debug(
|
||||
"Clearing new flag from '%s'",
|
||||
repo.data.full_name,
|
||||
)
|
||||
repo.data.new = False
|
||||
hacs.async_dispatch(HacsDispatchEvent.REPOSITORY, {})
|
||||
await hacs.data.async_write()
|
||||
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repositories/removed",
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repositories_removed(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get information about removed repositories."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
content = []
|
||||
for repo in hacs.repositories.list_removed:
|
||||
if repo.repository not in hacs.common.ignored_repositories:
|
||||
content.append(repo.to_json())
|
||||
connection.send_message(websocket_api.result_message(msg["id"], content))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repositories/add",
|
||||
vol.Required("repository"): cv.string,
|
||||
vol.Required("category"): vol.Lower,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repositories_add(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Add custom repositoriy."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository = regex.extract_repository_from_url(msg["repository"])
|
||||
category = msg["category"]
|
||||
|
||||
if repository is None:
|
||||
return
|
||||
|
||||
if repository in hacs.common.skip:
|
||||
hacs.common.skip.remove(repository)
|
||||
|
||||
if renamed := hacs.common.renamed_repositories.get(repository):
|
||||
repository = renamed
|
||||
|
||||
if category not in hacs.common.categories:
|
||||
hacs.log.error("%s is not a valid category for %s", category, repository)
|
||||
|
||||
elif not hacs.repositories.get_by_full_name(repository):
|
||||
try:
|
||||
await hacs.async_register_repository(
|
||||
repository_full_name=repository,
|
||||
category=category,
|
||||
)
|
||||
|
||||
except (
|
||||
BaseException # lgtm [py/catch-base-exception] pylint: disable=broad-except
|
||||
) as exception:
|
||||
hacs.async_dispatch(
|
||||
HacsDispatchEvent.ERROR,
|
||||
{
|
||||
"action": "add_repository",
|
||||
"exception": str(sys.exc_info()[0].__name__),
|
||||
"message": str(exception),
|
||||
},
|
||||
)
|
||||
|
||||
else:
|
||||
hacs.async_dispatch(
|
||||
HacsDispatchEvent.ERROR,
|
||||
{
|
||||
"action": "add_repository",
|
||||
"message": f"Repository '{repository}' exists in the store.",
|
||||
},
|
||||
)
|
||||
|
||||
connection.send_message(websocket_api.result_message(msg["id"], {}))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repositories/remove",
|
||||
vol.Required("repository"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repositories_remove(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Remove custom repositoriy."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository = hacs.repositories.get_by_id(msg["repository"])
|
||||
|
||||
repository.remove()
|
||||
await hacs.data.async_write()
|
||||
|
||||
connection.send_message(websocket_api.result_message(msg["id"], {}))
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Register info websocket commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..enums import HacsDispatchEvent
|
||||
from ..exceptions import HacsException
|
||||
from ..utils.version import version_left_higher_then_right
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..base import HacsBase
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repository/info",
|
||||
vol.Required("repository_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repository_info(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return information about a repository."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository_id = msg["repository_id"]
|
||||
repository = hacs.repositories.get_by_id(repository_id)
|
||||
if repository is None:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"repository_not_found",
|
||||
f"Repository with ID ({repository_id}) not found",
|
||||
)
|
||||
return
|
||||
|
||||
if not repository.updated_info:
|
||||
try:
|
||||
await repository.update_repository(ignore_issues=True, force=True)
|
||||
except Exception as exception: # pylint: disable=broad-except
|
||||
repository.logger.error("%s %s", repository.string, exception)
|
||||
repository.updated_info = True
|
||||
|
||||
if repository.data.new:
|
||||
repository.data.new = False
|
||||
await hacs.data.async_write()
|
||||
|
||||
connection.send_message(
|
||||
websocket_api.result_message(
|
||||
msg["id"],
|
||||
{
|
||||
"additional_info": repository.additional_info,
|
||||
"authors": repository.data.authors,
|
||||
"available_version": repository.display_available_version,
|
||||
"beta": repository.data.show_beta,
|
||||
"can_download": repository.can_download,
|
||||
"category": repository.data.category,
|
||||
"config_flow": repository.data.config_flow,
|
||||
"country": repository.repository_manifest.country,
|
||||
"custom": not hacs.repositories.is_default(str(repository.data.id)),
|
||||
"default_branch": repository.data.default_branch,
|
||||
"description": repository.data.description,
|
||||
"domain": repository.data.domain,
|
||||
"downloads": repository.data.downloads,
|
||||
"file_name": repository.data.file_name,
|
||||
"full_name": repository.data.full_name,
|
||||
"hide_default_branch": repository.repository_manifest.hide_default_branch,
|
||||
"homeassistant": repository.repository_manifest.homeassistant,
|
||||
"id": repository.data.id,
|
||||
"installed_version": repository.display_installed_version,
|
||||
"installed": repository.data.installed,
|
||||
"issues": repository.data.open_issues,
|
||||
"last_updated": repository.data.last_updated,
|
||||
"local_path": repository.content.path.local,
|
||||
"name": repository.display_name,
|
||||
"new": False,
|
||||
"pending_upgrade": repository.pending_update,
|
||||
"releases": repository.data.published_tags,
|
||||
"ref": repository.ref,
|
||||
"selected_tag": repository.data.selected_tag,
|
||||
"stars": repository.data.stargazers_count,
|
||||
"state": repository.state,
|
||||
"status": repository.display_status,
|
||||
"topics": repository.data.topics,
|
||||
"version_or_commit": repository.display_version_or_commit,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repository/ignore",
|
||||
vol.Required("repository"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repository_ignore(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Ignore a repository."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository_id = msg["repository"]
|
||||
hacs.log.info("Ignoring %s", repository_id)
|
||||
repository = hacs.repositories.get_by_id(repository_id)
|
||||
if repository is None:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"repository_not_found",
|
||||
f"Repository with ID ({repository_id}) not found",
|
||||
)
|
||||
return
|
||||
|
||||
hacs.common.ignored_repositories.add(repository.data.full_name)
|
||||
|
||||
await hacs.data.async_write()
|
||||
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repository/state",
|
||||
vol.Required("repository"): cv.string,
|
||||
vol.Required("state"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repository_state(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set the state of a repository"""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository = hacs.repositories.get_by_id(msg["repository"])
|
||||
|
||||
repository.state = msg["state"]
|
||||
|
||||
await hacs.data.async_write()
|
||||
connection.send_message(websocket_api.result_message(msg["id"], {}))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repository/version",
|
||||
vol.Required("repository"): cv.string,
|
||||
vol.Required("version"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repository_version(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set the version of a repository"""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository = hacs.repositories.get_by_id(msg["repository"])
|
||||
|
||||
if msg["version"] == repository.data.default_branch:
|
||||
repository.data.selected_tag = None
|
||||
else:
|
||||
repository.data.selected_tag = msg["version"]
|
||||
|
||||
await repository.update_repository(force=True)
|
||||
repository.state = None
|
||||
|
||||
await hacs.data.async_write()
|
||||
connection.send_message(websocket_api.result_message(msg["id"], {}))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repository/beta",
|
||||
vol.Required("repository"): cv.string,
|
||||
vol.Required("show_beta"): cv.boolean,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repository_beta(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Show or hide beta versions of a repository"""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository = hacs.repositories.get_by_id(msg["repository"])
|
||||
|
||||
repository.data.show_beta = msg["show_beta"]
|
||||
|
||||
await repository.update_repository(force=True)
|
||||
repository.state = None
|
||||
|
||||
await hacs.data.async_write()
|
||||
connection.send_message(websocket_api.result_message(msg["id"], {}))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repository/download",
|
||||
vol.Required("repository"): cv.string,
|
||||
vol.Optional("version"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repository_download(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set the version of a repository"""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository = hacs.repositories.get_by_id(msg["repository"])
|
||||
|
||||
try:
|
||||
was_installed = repository.data.installed
|
||||
await repository.async_download_repository(ref=msg.get("version"))
|
||||
if not was_installed:
|
||||
hacs.async_dispatch(HacsDispatchEvent.RELOAD, {"force": True})
|
||||
await hacs.async_recreate_entities()
|
||||
|
||||
await hacs.data.async_write()
|
||||
connection.send_message(websocket_api.result_message(msg["id"], {}))
|
||||
except HacsException as exception:
|
||||
repository.logger.error("%s %s", repository.string, exception)
|
||||
connection.send_error(msg["id"], "error", str(exception))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repository/remove",
|
||||
vol.Required("repository"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repository_remove(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Remove a repository."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository = hacs.repositories.get_by_id(msg["repository"])
|
||||
|
||||
repository.data.new = False
|
||||
try:
|
||||
await repository.update_repository(ignore_issues=True, force=True)
|
||||
except Exception as exception: # pylint: disable=broad-except
|
||||
repository.logger.error("%s %s", repository.string, exception)
|
||||
await repository.uninstall()
|
||||
|
||||
await hacs.data.async_write()
|
||||
connection.send_message(websocket_api.result_message(msg["id"], {}))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repository/refresh",
|
||||
vol.Required("repository"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repository_refresh(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Refresh a repository."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository = hacs.repositories.get_by_id(msg["repository"])
|
||||
|
||||
await repository.update_repository(ignore_issues=True, force=True)
|
||||
await hacs.data.async_write()
|
||||
# Update state of update entity
|
||||
hacs.coordinators[repository.data.category].async_update_listeners()
|
||||
|
||||
connection.send_message(websocket_api.result_message(msg["id"], {}))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repository/release_notes",
|
||||
vol.Required("repository"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repository_release_notes(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return release notes."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository = hacs.repositories.get_by_id(msg["repository"])
|
||||
|
||||
connection.send_message(
|
||||
websocket_api.result_message(
|
||||
msg["id"],
|
||||
[
|
||||
{
|
||||
"name": x.name,
|
||||
"body": x.body,
|
||||
"tag": x.tag_name,
|
||||
}
|
||||
for x in repository.releases.objects
|
||||
if not repository.data.installed_version
|
||||
or version_left_higher_then_right(x.tag_name, repository.data.installed_version)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "hacs/repository/releases",
|
||||
vol.Required("repository_id"): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def hacs_repository_releases(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return releases."""
|
||||
hacs: HacsBase = hass.data.get(DOMAIN)
|
||||
repository = hacs.repositories.get_by_id(msg["repository_id"])
|
||||
try:
|
||||
releases = await repository.async_get_releases()
|
||||
except Exception as exception:
|
||||
hacs.log.exception(exception)
|
||||
connection.send_error(msg["id"], "unknown", str(exception))
|
||||
return
|
||||
|
||||
connection.send_message(
|
||||
websocket_api.result_message(
|
||||
msg["id"],
|
||||
[
|
||||
{
|
||||
"name": release.name,
|
||||
"tag": release.tag_name,
|
||||
"published_at": release.published_at,
|
||||
"prerelease": release.prerelease,
|
||||
}
|
||||
for release in releases
|
||||
],
|
||||
)
|
||||
)
|
||||
378
homeassistant/config/custom_components/localtuya/__init__.py
Normal file
378
homeassistant/config/custom_components/localtuya/__init__.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""The LocalTuya integration."""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DEVICES,
|
||||
CONF_ENTITIES,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_REGION,
|
||||
CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
SERVICE_RELOAD,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
|
||||
from .cloud_api import TuyaCloudApi
|
||||
from .common import TuyaDevice, async_config_entry_by_device_id
|
||||
from .config_flow import ENTRIES_VERSION, config_schema
|
||||
from .const import (
|
||||
ATTR_UPDATED_AT,
|
||||
CONF_NO_CLOUD,
|
||||
CONF_PRODUCT_KEY,
|
||||
CONF_USER_ID,
|
||||
DATA_CLOUD,
|
||||
DATA_DISCOVERY,
|
||||
DOMAIN,
|
||||
TUYA_DEVICES,
|
||||
)
|
||||
from .discovery import TuyaDiscovery
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UNSUB_LISTENER = "unsub_listener"
|
||||
|
||||
RECONNECT_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
CONFIG_SCHEMA = config_schema()
|
||||
|
||||
CONF_DP = "dp"
|
||||
CONF_VALUE = "value"
|
||||
|
||||
SERVICE_SET_DP = "set_dp"
|
||||
SERVICE_SET_DP_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE_ID): cv.string,
|
||||
vol.Required(CONF_DP): int,
|
||||
vol.Required(CONF_VALUE): object,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict):
|
||||
"""Set up the LocalTuya integration component."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][TUYA_DEVICES] = {}
|
||||
|
||||
device_cache = {}
|
||||
|
||||
async def _handle_reload(service):
|
||||
"""Handle reload service call."""
|
||||
_LOGGER.info("Service %s.reload called: reloading integration", DOMAIN)
|
||||
|
||||
current_entries = hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
reload_tasks = [
|
||||
hass.config_entries.async_reload(entry.entry_id)
|
||||
for entry in current_entries
|
||||
]
|
||||
|
||||
await asyncio.gather(*reload_tasks)
|
||||
|
||||
async def _handle_set_dp(event):
|
||||
"""Handle set_dp service call."""
|
||||
dev_id = event.data[CONF_DEVICE_ID]
|
||||
if dev_id not in hass.data[DOMAIN][TUYA_DEVICES]:
|
||||
raise HomeAssistantError("unknown device id")
|
||||
|
||||
device = hass.data[DOMAIN][TUYA_DEVICES][dev_id]
|
||||
if not device.connected:
|
||||
raise HomeAssistantError("not connected to device")
|
||||
|
||||
await device.set_dp(event.data[CONF_VALUE], event.data[CONF_DP])
|
||||
|
||||
def _device_discovered(device):
|
||||
"""Update address of device if it has changed."""
|
||||
device_ip = device["ip"]
|
||||
device_id = device["gwId"]
|
||||
product_key = device["productKey"]
|
||||
|
||||
# If device is not in cache, check if a config entry exists
|
||||
entry = async_config_entry_by_device_id(hass, device_id)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
if device_id not in device_cache:
|
||||
if entry and device_id in entry.data[CONF_DEVICES]:
|
||||
# Save address from config entry in cache to trigger
|
||||
# potential update below
|
||||
host_ip = entry.data[CONF_DEVICES][device_id][CONF_HOST]
|
||||
device_cache[device_id] = host_ip
|
||||
|
||||
if device_id not in device_cache:
|
||||
return
|
||||
|
||||
dev_entry = entry.data[CONF_DEVICES][device_id]
|
||||
|
||||
new_data = entry.data.copy()
|
||||
updated = False
|
||||
|
||||
if device_cache[device_id] != device_ip:
|
||||
updated = True
|
||||
new_data[CONF_DEVICES][device_id][CONF_HOST] = device_ip
|
||||
device_cache[device_id] = device_ip
|
||||
|
||||
if dev_entry.get(CONF_PRODUCT_KEY) != product_key:
|
||||
updated = True
|
||||
new_data[CONF_DEVICES][device_id][CONF_PRODUCT_KEY] = product_key
|
||||
|
||||
# Update settings if something changed, otherwise try to connect. Updating
|
||||
# settings triggers a reload of the config entry, which tears down the device
|
||||
# so no need to connect in that case.
|
||||
if updated:
|
||||
_LOGGER.debug(
|
||||
"Updating keys for device %s: %s %s", device_id, device_ip, product_key
|
||||
)
|
||||
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
|
||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
||||
|
||||
elif device_id in hass.data[DOMAIN][TUYA_DEVICES]:
|
||||
_LOGGER.debug("Device %s found with IP %s", device_id, device_ip)
|
||||
|
||||
device = hass.data[DOMAIN][TUYA_DEVICES].get(device_id)
|
||||
if not device:
|
||||
_LOGGER.warning(f"Could not find device for device_id {device_id}")
|
||||
elif not device.connected:
|
||||
device.async_connect()
|
||||
|
||||
|
||||
def _shutdown(event):
|
||||
"""Clean up resources when shutting down."""
|
||||
discovery.close()
|
||||
|
||||
async def _async_reconnect(now):
|
||||
"""Try connecting to devices not already connected to."""
|
||||
for device_id, device in hass.data[DOMAIN][TUYA_DEVICES].items():
|
||||
if not device.connected:
|
||||
device.async_connect()
|
||||
|
||||
async_track_time_interval(hass, _async_reconnect, RECONNECT_INTERVAL)
|
||||
|
||||
async_register_admin_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_RELOAD,
|
||||
_handle_reload,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_DP, _handle_set_dp, schema=SERVICE_SET_DP_SCHEMA
|
||||
)
|
||||
|
||||
discovery = TuyaDiscovery(_device_discovered)
|
||||
try:
|
||||
await discovery.start()
|
||||
hass.data[DOMAIN][DATA_DISCOVERY] = discovery
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("failed to set up discovery")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(hass, config_entry: ConfigEntry):
|
||||
"""Migrate old entries merging all of them in one."""
|
||||
new_version = ENTRIES_VERSION
|
||||
stored_entries = hass.config_entries.async_entries(DOMAIN)
|
||||
if config_entry.version == 1:
|
||||
_LOGGER.debug("Migrating config entry from version %s", config_entry.version)
|
||||
|
||||
if config_entry.entry_id == stored_entries[0].entry_id:
|
||||
_LOGGER.debug(
|
||||
"Migrating the first config entry (%s)", config_entry.entry_id
|
||||
)
|
||||
new_data = {}
|
||||
new_data[CONF_REGION] = "eu"
|
||||
new_data[CONF_CLIENT_ID] = ""
|
||||
new_data[CONF_CLIENT_SECRET] = ""
|
||||
new_data[CONF_USER_ID] = ""
|
||||
new_data[CONF_USERNAME] = DOMAIN
|
||||
new_data[CONF_NO_CLOUD] = True
|
||||
new_data[CONF_DEVICES] = {
|
||||
config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy()
|
||||
}
|
||||
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
|
||||
config_entry.version = new_version
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, title=DOMAIN, data=new_data
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Merging the config entry %s into the main one", config_entry.entry_id
|
||||
)
|
||||
new_data = stored_entries[0].data.copy()
|
||||
new_data[CONF_DEVICES].update(
|
||||
{config_entry.data[CONF_DEVICE_ID]: config_entry.data.copy()}
|
||||
)
|
||||
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
|
||||
hass.config_entries.async_update_entry(stored_entries[0], data=new_data)
|
||||
await hass.config_entries.async_remove(config_entry.entry_id)
|
||||
|
||||
_LOGGER.info(
|
||||
"Entry %s successfully migrated to version %s.",
|
||||
config_entry.entry_id,
|
||||
new_version,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Set up LocalTuya integration from a config entry."""
|
||||
if entry.version < ENTRIES_VERSION:
|
||||
_LOGGER.debug(
|
||||
"Skipping setup for entry %s since its version (%s) is old",
|
||||
entry.entry_id,
|
||||
entry.version,
|
||||
)
|
||||
return
|
||||
|
||||
region = entry.data[CONF_REGION]
|
||||
client_id = entry.data[CONF_CLIENT_ID]
|
||||
secret = entry.data[CONF_CLIENT_SECRET]
|
||||
user_id = entry.data[CONF_USER_ID]
|
||||
tuya_api = TuyaCloudApi(hass, region, client_id, secret, user_id)
|
||||
no_cloud = True
|
||||
if CONF_NO_CLOUD in entry.data:
|
||||
no_cloud = entry.data.get(CONF_NO_CLOUD)
|
||||
if no_cloud:
|
||||
_LOGGER.info("Cloud API account not configured.")
|
||||
# wait 1 second to make sure possible migration has finished
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
res = await tuya_api.async_get_access_token()
|
||||
if res != "ok":
|
||||
_LOGGER.error("Cloud API connection failed: %s", res)
|
||||
else:
|
||||
_LOGGER.info("Cloud API connection succeeded.")
|
||||
res = await tuya_api.async_get_devices_list()
|
||||
hass.data[DOMAIN][DATA_CLOUD] = tuya_api
|
||||
|
||||
platforms = set()
|
||||
for dev_id in entry.data[CONF_DEVICES].keys():
|
||||
entities = entry.data[CONF_DEVICES][dev_id][CONF_ENTITIES]
|
||||
platforms = platforms.union(
|
||||
set(entity[CONF_PLATFORM] for entity in entities)
|
||||
)
|
||||
hass.data[DOMAIN][TUYA_DEVICES][dev_id] = TuyaDevice(hass, entry, dev_id)
|
||||
|
||||
# Setup all platforms at once, letting HA handling each platform and avoiding
|
||||
# potential integration restarts while elements are still initialising.
|
||||
await hass.config_entries.async_forward_entry_setups(entry, platforms)
|
||||
|
||||
async def setup_entities(device_ids):
|
||||
for dev_id in device_ids:
|
||||
hass.data[DOMAIN][TUYA_DEVICES][dev_id].async_connect()
|
||||
|
||||
await async_remove_orphan_entities(hass, entry)
|
||||
|
||||
hass.async_create_task(setup_entities(entry.data[CONF_DEVICES].keys()))
|
||||
|
||||
unsub_listener = entry.add_update_listener(update_listener)
|
||||
hass.data[DOMAIN][entry.entry_id] = {UNSUB_LISTENER: unsub_listener}
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Unload a config entry."""
|
||||
platforms = {}
|
||||
|
||||
for dev_id, dev_entry in entry.data[CONF_DEVICES].items():
|
||||
for entity in dev_entry[CONF_ENTITIES]:
|
||||
platforms[entity[CONF_PLATFORM]] = True
|
||||
|
||||
unload_ok = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, component)
|
||||
for component in platforms
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id][UNSUB_LISTENER]()
|
||||
for dev_id, device in hass.data[DOMAIN][TUYA_DEVICES].items():
|
||||
if device.connected:
|
||||
await device.close()
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN][TUYA_DEVICES] = {}
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def update_listener(hass, config_entry):
|
||||
"""Update listener."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
dev_id = list(device_entry.identifiers)[0][1].split("_")[-1]
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
entities = {
|
||||
ent.unique_id: ent.entity_id
|
||||
for ent in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id)
|
||||
if dev_id in ent.unique_id
|
||||
}
|
||||
for entity_id in entities.values():
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
if dev_id not in config_entry.data[CONF_DEVICES]:
|
||||
_LOGGER.info(
|
||||
"Device %s not found in config entry: finalizing device removal", dev_id
|
||||
)
|
||||
return True
|
||||
|
||||
await hass.data[DOMAIN][TUYA_DEVICES][dev_id].close()
|
||||
|
||||
new_data = config_entry.data.copy()
|
||||
new_data[CONF_DEVICES].pop(dev_id)
|
||||
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data=new_data,
|
||||
)
|
||||
|
||||
_LOGGER.info("Device %s removed.", dev_id)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_orphan_entities(hass, entry):
|
||||
"""Remove entities associated with config entry that has been removed."""
|
||||
return
|
||||
ent_reg = er.async_get(hass)
|
||||
entities = {
|
||||
ent.unique_id: ent.entity_id
|
||||
for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id)
|
||||
}
|
||||
_LOGGER.info("ENTITIES ORPHAN %s", entities)
|
||||
return
|
||||
|
||||
for entity in entry.data[CONF_ENTITIES]:
|
||||
if entity[CONF_ID] in entities:
|
||||
del entities[entity[CONF_ID]]
|
||||
|
||||
for entity_id in entities.values():
|
||||
ent_reg.async_remove(entity_id)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Platform to present any Tuya DP as a binary sensor."""
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DEVICE_CLASSES_SCHEMA,
|
||||
DOMAIN,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE_CLASS
|
||||
|
||||
from .common import LocalTuyaEntity, async_setup_entry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_STATE_ON = "state_on"
|
||||
CONF_STATE_OFF = "state_off"
|
||||
|
||||
|
||||
def flow_schema(dps):
|
||||
"""Return schema used in config flow."""
|
||||
return {
|
||||
vol.Required(CONF_STATE_ON, default="True"): str,
|
||||
vol.Required(CONF_STATE_OFF, default="False"): str,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
}
|
||||
|
||||
|
||||
class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity):
|
||||
"""Representation of a Tuya binary sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device,
|
||||
config_entry,
|
||||
sensorid,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the Tuya binary sensor."""
|
||||
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
|
||||
self._is_on = False
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return sensor state."""
|
||||
return self._is_on
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this device."""
|
||||
return self._config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
def status_updated(self):
|
||||
"""Device status was updated."""
|
||||
super().status_updated()
|
||||
|
||||
state = str(self.dps(self._dp_id)).lower()
|
||||
if state == self._config[CONF_STATE_ON].lower():
|
||||
self._is_on = True
|
||||
elif state == self._config[CONF_STATE_OFF].lower():
|
||||
self._is_on = False
|
||||
else:
|
||||
self.warning(
|
||||
"State for entity %s did not match state patterns", self.entity_id
|
||||
)
|
||||
|
||||
# No need to restore state for a sensor
|
||||
async def restore_state_when_connected(self):
|
||||
"""Do nothing for a sensor."""
|
||||
return
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema
|
||||
)
|
||||
522
homeassistant/config/custom_components/localtuya/climate.py
Normal file
522
homeassistant/config/custom_components/localtuya/climate.py
Normal file
@@ -0,0 +1,522 @@
|
||||
"""Platform to locally control Tuya-based climate devices."""
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.climate import (
|
||||
DEFAULT_MAX_TEMP,
|
||||
DEFAULT_MIN_TEMP,
|
||||
DOMAIN,
|
||||
ClimateEntity,
|
||||
)
|
||||
from homeassistant.components.climate.const import (
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
PRESET_AWAY,
|
||||
PRESET_ECO,
|
||||
PRESET_HOME,
|
||||
PRESET_NONE,
|
||||
ClimateEntityFeature,
|
||||
FAN_AUTO,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
FAN_HIGH,
|
||||
FAN_TOP,
|
||||
SWING_ON,
|
||||
SWING_OFF,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_TEMPERATURE,
|
||||
CONF_TEMPERATURE_UNIT,
|
||||
PRECISION_HALVES,
|
||||
PRECISION_TENTHS,
|
||||
PRECISION_WHOLE,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
|
||||
from .common import LocalTuyaEntity, async_setup_entry
|
||||
from .const import (
|
||||
CONF_CURRENT_TEMPERATURE_DP,
|
||||
CONF_TEMP_MAX,
|
||||
CONF_TEMP_MIN,
|
||||
CONF_ECO_DP,
|
||||
CONF_ECO_VALUE,
|
||||
CONF_HEURISTIC_ACTION,
|
||||
CONF_HVAC_ACTION_DP,
|
||||
CONF_HVAC_ACTION_SET,
|
||||
CONF_HVAC_MODE_DP,
|
||||
CONF_HVAC_MODE_SET,
|
||||
CONF_MAX_TEMP_DP,
|
||||
CONF_MIN_TEMP_DP,
|
||||
CONF_PRECISION,
|
||||
CONF_PRESET_DP,
|
||||
CONF_PRESET_SET,
|
||||
CONF_TARGET_PRECISION,
|
||||
CONF_TARGET_TEMPERATURE_DP,
|
||||
CONF_TEMPERATURE_STEP,
|
||||
CONF_HVAC_FAN_MODE_DP,
|
||||
CONF_HVAC_FAN_MODE_SET,
|
||||
CONF_HVAC_SWING_MODE_DP,
|
||||
CONF_HVAC_SWING_MODE_SET,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
HVAC_MODE_SETS = {
|
||||
"manual/auto": {
|
||||
HVACMode.HEAT: "manual",
|
||||
HVACMode.AUTO: "auto",
|
||||
},
|
||||
"Manual/Auto": {
|
||||
HVACMode.HEAT: "Manual",
|
||||
HVACMode.AUTO: "Auto",
|
||||
},
|
||||
"MANUAL/AUTO": {
|
||||
HVACMode.HEAT: "MANUAL",
|
||||
HVACMode.AUTO: "AUTO",
|
||||
},
|
||||
"Manual/Program": {
|
||||
HVACMode.HEAT: "Manual",
|
||||
HVACMode.AUTO: "Program",
|
||||
},
|
||||
"m/p": {
|
||||
HVACMode.HEAT: "m",
|
||||
HVACMode.AUTO: "p",
|
||||
},
|
||||
"True/False": {
|
||||
HVACMode.HEAT: True,
|
||||
},
|
||||
"Auto/Cold/Dry/Wind/Hot": {
|
||||
HVACMode.HEAT: "hot",
|
||||
HVACMode.FAN_ONLY: "wind",
|
||||
HVACMode.DRY: "wet",
|
||||
HVACMode.COOL: "cold",
|
||||
HVACMode.AUTO: "auto",
|
||||
},
|
||||
"Cold/Dehumidify/Hot": {
|
||||
HVACMode.HEAT: "hot",
|
||||
HVACMode.DRY: "dehumidify",
|
||||
HVACMode.COOL: "cold",
|
||||
},
|
||||
"1/0": {
|
||||
HVACMode.HEAT: "1",
|
||||
HVACMode.AUTO: "0",
|
||||
},
|
||||
}
|
||||
HVAC_ACTION_SETS = {
|
||||
"True/False": {
|
||||
HVACAction.HEATING: True,
|
||||
HVACAction.IDLE: False,
|
||||
},
|
||||
"open/close": {
|
||||
HVACAction.HEATING: "open",
|
||||
HVACAction.IDLE: "close",
|
||||
},
|
||||
"heating/no_heating": {
|
||||
HVACAction.HEATING: "heating",
|
||||
HVACAction.IDLE: "no_heating",
|
||||
},
|
||||
"Heat/Warming": {
|
||||
HVACAction.HEATING: "Heat",
|
||||
HVACAction.IDLE: "Warming",
|
||||
},
|
||||
"heating/warming": {
|
||||
HVACAction.HEATING: "heating",
|
||||
HVACAction.IDLE: "warming",
|
||||
},
|
||||
}
|
||||
HVAC_FAN_MODE_SETS = {
|
||||
"Auto/Low/Middle/High/Strong": {
|
||||
FAN_AUTO: "auto",
|
||||
FAN_LOW: "low",
|
||||
FAN_MEDIUM: "middle",
|
||||
FAN_HIGH: "high",
|
||||
FAN_TOP: "strong",
|
||||
}
|
||||
}
|
||||
HVAC_SWING_MODE_SETS = {
|
||||
"True/False": {
|
||||
SWING_ON: True,
|
||||
SWING_OFF: False,
|
||||
}
|
||||
}
|
||||
PRESET_SETS = {
|
||||
"Manual/Holiday/Program": {
|
||||
PRESET_AWAY: "Holiday",
|
||||
PRESET_HOME: "Program",
|
||||
PRESET_NONE: "Manual",
|
||||
},
|
||||
"smart/holiday/hold": {
|
||||
PRESET_AWAY: "holiday",
|
||||
PRESET_HOME: "smart",
|
||||
PRESET_NONE: "hold",
|
||||
},
|
||||
}
|
||||
|
||||
TEMPERATURE_CELSIUS = "celsius"
|
||||
TEMPERATURE_FAHRENHEIT = "fahrenheit"
|
||||
DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS
|
||||
DEFAULT_PRECISION = PRECISION_TENTHS
|
||||
DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES
|
||||
# Empirically tested to work for AVATTO thermostat
|
||||
MODE_WAIT = 0.1
|
||||
|
||||
|
||||
def flow_schema(dps):
|
||||
"""Return schema used in config flow."""
|
||||
return {
|
||||
vol.Optional(CONF_TARGET_TEMPERATURE_DP): vol.In(dps),
|
||||
vol.Optional(CONF_CURRENT_TEMPERATURE_DP): vol.In(dps),
|
||||
vol.Optional(CONF_TEMPERATURE_STEP, default=PRECISION_WHOLE): vol.In(
|
||||
[PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
|
||||
),
|
||||
vol.Optional(CONF_TEMP_MIN, default=DEFAULT_MIN_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_TEMP_MAX, default=DEFAULT_MAX_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_MAX_TEMP_DP): vol.In(dps),
|
||||
vol.Optional(CONF_MIN_TEMP_DP): vol.In(dps),
|
||||
vol.Optional(CONF_PRECISION, default=PRECISION_WHOLE): vol.In(
|
||||
[PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
|
||||
),
|
||||
vol.Optional(CONF_HVAC_MODE_DP): vol.In(dps),
|
||||
vol.Optional(CONF_HVAC_MODE_SET): vol.In(list(HVAC_MODE_SETS.keys())),
|
||||
vol.Optional(CONF_HVAC_FAN_MODE_DP): vol.In(dps),
|
||||
vol.Optional(CONF_HVAC_FAN_MODE_SET): vol.In(list(HVAC_FAN_MODE_SETS.keys())),
|
||||
vol.Optional(CONF_HVAC_ACTION_DP): vol.In(dps),
|
||||
vol.Optional(CONF_HVAC_ACTION_SET): vol.In(list(HVAC_ACTION_SETS.keys())),
|
||||
vol.Optional(CONF_ECO_DP): vol.In(dps),
|
||||
vol.Optional(CONF_ECO_VALUE): str,
|
||||
vol.Optional(CONF_PRESET_DP): vol.In(dps),
|
||||
vol.Optional(CONF_PRESET_SET): vol.In(list(PRESET_SETS.keys())),
|
||||
vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(
|
||||
[TEMPERATURE_CELSIUS, TEMPERATURE_FAHRENHEIT]
|
||||
),
|
||||
vol.Optional(CONF_TARGET_PRECISION, default=PRECISION_WHOLE): vol.In(
|
||||
[PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
|
||||
),
|
||||
vol.Optional(CONF_HEURISTIC_ACTION): bool,
|
||||
}
|
||||
|
||||
|
||||
class LocaltuyaClimate(LocalTuyaEntity, ClimateEntity):
|
||||
"""Tuya climate device."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device,
|
||||
config_entry,
|
||||
switchid,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize a new LocaltuyaClimate."""
|
||||
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
|
||||
self._state = None
|
||||
self._target_temperature = None
|
||||
self._current_temperature = None
|
||||
self._hvac_mode = None
|
||||
self._fan_mode = None
|
||||
self._swing_mode = None
|
||||
self._preset_mode = None
|
||||
self._hvac_action = None
|
||||
self._precision = self._config.get(CONF_PRECISION, DEFAULT_PRECISION)
|
||||
self._target_precision = self._config.get(
|
||||
CONF_TARGET_PRECISION, self._precision
|
||||
)
|
||||
self._conf_hvac_mode_dp = self._config.get(CONF_HVAC_MODE_DP)
|
||||
self._conf_hvac_mode_set = HVAC_MODE_SETS.get(
|
||||
self._config.get(CONF_HVAC_MODE_SET), {}
|
||||
)
|
||||
self._conf_hvac_fan_mode_dp = self._config.get(CONF_HVAC_FAN_MODE_DP)
|
||||
self._conf_hvac_fan_mode_set = HVAC_FAN_MODE_SETS.get(
|
||||
self._config.get(CONF_HVAC_FAN_MODE_SET), {}
|
||||
)
|
||||
self._conf_hvac_swing_mode_dp = self._config.get(CONF_HVAC_SWING_MODE_DP)
|
||||
self._conf_hvac_swing_mode_set = HVAC_SWING_MODE_SETS.get(
|
||||
self._config.get(CONF_HVAC_SWING_MODE_SET), {}
|
||||
)
|
||||
self._conf_preset_dp = self._config.get(CONF_PRESET_DP)
|
||||
self._conf_preset_set = PRESET_SETS.get(self._config.get(CONF_PRESET_SET), {})
|
||||
self._conf_hvac_action_dp = self._config.get(CONF_HVAC_ACTION_DP)
|
||||
self._conf_hvac_action_set = HVAC_ACTION_SETS.get(
|
||||
self._config.get(CONF_HVAC_ACTION_SET), {}
|
||||
)
|
||||
self._conf_eco_dp = self._config.get(CONF_ECO_DP)
|
||||
self._conf_eco_value = self._config.get(CONF_ECO_VALUE, "ECO")
|
||||
self._has_presets = self.has_config(CONF_ECO_DP) or self.has_config(
|
||||
CONF_PRESET_DP
|
||||
)
|
||||
_LOGGER.debug("Initialized climate [%s]", self.name)
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
supported_features = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
if self.has_config(CONF_TARGET_TEMPERATURE_DP):
|
||||
supported_features = supported_features | ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
if self.has_config(CONF_MAX_TEMP_DP):
|
||||
supported_features = supported_features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
if self.has_config(CONF_PRESET_DP) or self.has_config(CONF_ECO_DP):
|
||||
supported_features = supported_features | ClimateEntityFeature.PRESET_MODE
|
||||
if self.has_config(CONF_HVAC_FAN_MODE_DP) and self.has_config(CONF_HVAC_FAN_MODE_SET):
|
||||
supported_features = supported_features | ClimateEntityFeature.FAN_MODE
|
||||
if self.has_config(CONF_HVAC_SWING_MODE_DP):
|
||||
supported_features = supported_features | ClimateEntityFeature.SWING_MODE
|
||||
return supported_features
|
||||
|
||||
@property
|
||||
def precision(self):
|
||||
"""Return the precision of the system."""
|
||||
return self._precision
|
||||
|
||||
@property
|
||||
def target_precision(self):
|
||||
"""Return the precision of the target."""
|
||||
return self._target_precision
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement used by the platform."""
|
||||
if (
|
||||
self._config.get(CONF_TEMPERATURE_UNIT, DEFAULT_TEMPERATURE_UNIT)
|
||||
== TEMPERATURE_FAHRENHEIT
|
||||
):
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def hvac_mode(self):
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
return self._hvac_mode
|
||||
|
||||
@property
|
||||
def hvac_modes(self):
|
||||
"""Return the list of available operation modes."""
|
||||
if not self.has_config(CONF_HVAC_MODE_DP):
|
||||
return None
|
||||
return list(self._conf_hvac_mode_set) + [HVACMode.OFF]
|
||||
|
||||
@property
|
||||
def hvac_action(self):
|
||||
"""Return the current running hvac operation if supported.
|
||||
|
||||
Need to be one of CURRENT_HVAC_*.
|
||||
"""
|
||||
if self._config.get(CONF_HEURISTIC_ACTION, False):
|
||||
if self._hvac_mode == HVACMode.HEAT:
|
||||
if self._current_temperature < (
|
||||
self._target_temperature - self._precision
|
||||
):
|
||||
self._hvac_action = HVACAction.HEATING
|
||||
if self._current_temperature == (
|
||||
self._target_temperature - self._precision
|
||||
):
|
||||
if self._hvac_action == HVACAction.HEATING:
|
||||
self._hvac_action = HVACAction.HEATING
|
||||
if self._hvac_action == HVACAction.IDLE:
|
||||
self._hvac_action = HVACAction.IDLE
|
||||
if (
|
||||
self._current_temperature + self._precision
|
||||
) > self._target_temperature:
|
||||
self._hvac_action = HVACAction.IDLE
|
||||
return self._hvac_action
|
||||
return self._hvac_action
|
||||
|
||||
@property
|
||||
def preset_mode(self):
|
||||
"""Return current preset."""
|
||||
return self._preset_mode
|
||||
|
||||
@property
|
||||
def preset_modes(self):
|
||||
"""Return the list of available presets modes."""
|
||||
if not self._has_presets:
|
||||
return None
|
||||
presets = list(self._conf_preset_set)
|
||||
if self._conf_eco_dp:
|
||||
presets.append(PRESET_ECO)
|
||||
return presets
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._current_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._target_temperature
|
||||
|
||||
@property
|
||||
def target_temperature_step(self):
|
||||
"""Return the supported step of target temperature."""
|
||||
return self._config.get(CONF_TEMPERATURE_STEP, DEFAULT_TEMPERATURE_STEP)
|
||||
|
||||
@property
|
||||
def fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
return self._fan_mode
|
||||
|
||||
@property
|
||||
def fan_modes(self):
|
||||
"""Return the list of available fan modes."""
|
||||
if not self.has_config(CONF_HVAC_FAN_MODE_DP):
|
||||
return None
|
||||
return list(self._conf_hvac_fan_mode_set)
|
||||
|
||||
@property
|
||||
def swing_mode(self):
|
||||
"""Return the swing setting."""
|
||||
return self._swing_mode
|
||||
|
||||
@property
|
||||
def swing_modes(self):
|
||||
"""Return the list of available swing modes."""
|
||||
if not self.has_config(CONF_HVAC_SWING_MODE_DP):
|
||||
return None
|
||||
return list(self._conf_hvac_swing_mode_set)
|
||||
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
if ATTR_TEMPERATURE in kwargs and self.has_config(CONF_TARGET_TEMPERATURE_DP):
|
||||
temperature = round(kwargs[ATTR_TEMPERATURE] / self._target_precision)
|
||||
await self._device.set_dp(
|
||||
temperature, self._config[CONF_TARGET_TEMPERATURE_DP]
|
||||
)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode):
|
||||
"""Set new target fan mode."""
|
||||
if self._conf_hvac_fan_mode_dp is None:
|
||||
_LOGGER.error("Fan speed unsupported (no DP)")
|
||||
return
|
||||
if fan_mode not in self._conf_hvac_fan_mode_set:
|
||||
_LOGGER.error("Unsupported fan_mode: %s" % fan_mode)
|
||||
return
|
||||
await self._device.set_dp(
|
||||
self._conf_hvac_fan_mode_set[fan_mode], self._conf_hvac_fan_mode_dp
|
||||
)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode):
|
||||
"""Set new target operation mode."""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self._device.set_dp(False, self._dp_id)
|
||||
return
|
||||
if not self._state and self._conf_hvac_mode_dp != self._dp_id:
|
||||
await self._device.set_dp(True, self._dp_id)
|
||||
# Some thermostats need a small wait before sending another update
|
||||
await asyncio.sleep(MODE_WAIT)
|
||||
await self._device.set_dp(
|
||||
self._conf_hvac_mode_set[hvac_mode], self._conf_hvac_mode_dp
|
||||
)
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode):
|
||||
"""Set new target swing operation."""
|
||||
if self._conf_hvac_swing_mode_dp is None:
|
||||
_LOGGER.error("Swing mode unsupported (no DP)")
|
||||
return
|
||||
if swing_mode not in self._conf_hvac_swing_mode_set:
|
||||
_LOGGER.error("Unsupported swing_mode: %s" % swing_mode)
|
||||
return
|
||||
await self._device.set_dp(
|
||||
self._conf_hvac_swing_mode_set[swing_mode], self._conf_hvac_swing_mode_dp
|
||||
)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the entity on."""
|
||||
await self._device.set_dp(True, self._dp_id)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the entity off."""
|
||||
await self._device.set_dp(False, self._dp_id)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode):
|
||||
"""Set new target preset mode."""
|
||||
if preset_mode == PRESET_ECO:
|
||||
await self._device.set_dp(self._conf_eco_value, self._conf_eco_dp)
|
||||
return
|
||||
await self._device.set_dp(
|
||||
self._conf_preset_set[preset_mode], self._conf_preset_dp
|
||||
)
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
if self.has_config(CONF_MIN_TEMP_DP):
|
||||
return self.dps_conf(CONF_MIN_TEMP_DP)
|
||||
return self._config[CONF_TEMP_MIN]
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature."""
|
||||
if self.has_config(CONF_MAX_TEMP_DP):
|
||||
return self.dps_conf(CONF_MAX_TEMP_DP)
|
||||
return self._config[CONF_TEMP_MAX]
|
||||
|
||||
def status_updated(self):
|
||||
"""Device status was updated."""
|
||||
self._state = self.dps(self._dp_id)
|
||||
|
||||
if self.has_config(CONF_TARGET_TEMPERATURE_DP):
|
||||
self._target_temperature = (
|
||||
self.dps_conf(CONF_TARGET_TEMPERATURE_DP) * self._target_precision
|
||||
)
|
||||
|
||||
if self.has_config(CONF_CURRENT_TEMPERATURE_DP):
|
||||
self._current_temperature = (
|
||||
self.dps_conf(CONF_CURRENT_TEMPERATURE_DP) * self._precision
|
||||
)
|
||||
|
||||
if self._has_presets:
|
||||
if (
|
||||
self.has_config(CONF_ECO_DP)
|
||||
and self.dps_conf(CONF_ECO_DP) == self._conf_eco_value
|
||||
):
|
||||
self._preset_mode = PRESET_ECO
|
||||
else:
|
||||
for preset, value in self._conf_preset_set.items(): # todo remove
|
||||
if self.dps_conf(CONF_PRESET_DP) == value:
|
||||
self._preset_mode = preset
|
||||
break
|
||||
else:
|
||||
self._preset_mode = PRESET_NONE
|
||||
|
||||
# Update the HVAC status
|
||||
if self.has_config(CONF_HVAC_MODE_DP):
|
||||
if not self._state:
|
||||
self._hvac_mode = HVACMode.OFF
|
||||
else:
|
||||
for mode, value in self._conf_hvac_mode_set.items():
|
||||
if self.dps_conf(CONF_HVAC_MODE_DP) == value:
|
||||
self._hvac_mode = mode
|
||||
break
|
||||
else:
|
||||
# in case hvac mode and preset share the same dp
|
||||
self._hvac_mode = HVACMode.AUTO
|
||||
|
||||
# Update the fan status
|
||||
if self.has_config(CONF_HVAC_FAN_MODE_DP):
|
||||
for mode, value in self._conf_hvac_fan_mode_set.items():
|
||||
if self.dps_conf(CONF_HVAC_FAN_MODE_DP) == value:
|
||||
self._fan_mode = mode
|
||||
break
|
||||
else:
|
||||
# in case fan mode and preset share the same dp
|
||||
_LOGGER.debug("Unknown fan mode %s" % self.dps_conf(CONF_HVAC_FAN_MODE_DP))
|
||||
self._fan_mode = FAN_AUTO
|
||||
|
||||
# Update the swing status
|
||||
if self.has_config(CONF_HVAC_SWING_MODE_DP):
|
||||
for mode, value in self._conf_hvac_swing_mode_set.items():
|
||||
if self.dps_conf(CONF_HVAC_SWING_MODE_DP) == value:
|
||||
self._swing_mode = mode
|
||||
break
|
||||
else:
|
||||
_LOGGER.debug("Unknown swing mode %s" % self.dps_conf(CONF_HVAC_SWING_MODE_DP))
|
||||
self._swing_mode = SWING_OFF
|
||||
|
||||
# Update the current action
|
||||
for action, value in self._conf_hvac_action_set.items():
|
||||
if self.dps_conf(CONF_HVAC_ACTION_DP) == value:
|
||||
self._hvac_action = action
|
||||
|
||||
|
||||
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaClimate, flow_schema)
|
||||
139
homeassistant/config/custom_components/localtuya/cloud_api.py
Normal file
139
homeassistant/config/custom_components/localtuya/cloud_api.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Class to perform requests to Tuya Cloud APIs."""
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Signature algorithm.
|
||||
def calc_sign(msg, key):
|
||||
"""Calculate signature for request."""
|
||||
sign = (
|
||||
hmac.new(
|
||||
msg=bytes(msg, "latin-1"),
|
||||
key=bytes(key, "latin-1"),
|
||||
digestmod=hashlib.sha256,
|
||||
)
|
||||
.hexdigest()
|
||||
.upper()
|
||||
)
|
||||
return sign
|
||||
|
||||
|
||||
class TuyaCloudApi:
|
||||
"""Class to send API calls."""
|
||||
|
||||
def __init__(self, hass, region_code, client_id, secret, user_id):
|
||||
"""Initialize the class."""
|
||||
self._hass = hass
|
||||
self._base_url = f"https://openapi.tuya{region_code}.com"
|
||||
self._client_id = client_id
|
||||
self._secret = secret
|
||||
self._user_id = user_id
|
||||
self._access_token = ""
|
||||
self.device_list = {}
|
||||
|
||||
def generate_payload(self, method, timestamp, url, headers, body=None):
|
||||
"""Generate signed payload for requests."""
|
||||
payload = self._client_id + self._access_token + timestamp
|
||||
|
||||
payload += method + "\n"
|
||||
# Content-SHA256
|
||||
payload += hashlib.sha256(bytes((body or "").encode("utf-8"))).hexdigest()
|
||||
payload += (
|
||||
"\n"
|
||||
+ "".join(
|
||||
[
|
||||
"%s:%s\n" % (key, headers[key]) # Headers
|
||||
for key in headers.get("Signature-Headers", "").split(":")
|
||||
if key in headers
|
||||
]
|
||||
)
|
||||
+ "\n/"
|
||||
+ url.split("//", 1)[-1].split("/", 1)[-1] # Url
|
||||
)
|
||||
# _LOGGER.debug("PAYLOAD: %s", payload)
|
||||
return payload
|
||||
|
||||
async def async_make_request(self, method, url, body=None, headers={}):
|
||||
"""Perform requests."""
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
payload = self.generate_payload(method, timestamp, url, headers, body)
|
||||
default_par = {
|
||||
"client_id": self._client_id,
|
||||
"access_token": self._access_token,
|
||||
"sign": calc_sign(payload, self._secret),
|
||||
"t": timestamp,
|
||||
"sign_method": "HMAC-SHA256",
|
||||
}
|
||||
full_url = self._base_url + url
|
||||
# _LOGGER.debug("\n" + method + ": [%s]", full_url)
|
||||
|
||||
if method == "GET":
|
||||
func = functools.partial(
|
||||
requests.get, full_url, headers=dict(default_par, **headers)
|
||||
)
|
||||
elif method == "POST":
|
||||
func = functools.partial(
|
||||
requests.post,
|
||||
full_url,
|
||||
headers=dict(default_par, **headers),
|
||||
data=json.dumps(body),
|
||||
)
|
||||
# _LOGGER.debug("BODY: [%s]", body)
|
||||
elif method == "PUT":
|
||||
func = functools.partial(
|
||||
requests.put,
|
||||
full_url,
|
||||
headers=dict(default_par, **headers),
|
||||
data=json.dumps(body),
|
||||
)
|
||||
|
||||
resp = await self._hass.async_add_executor_job(func)
|
||||
# r = json.dumps(r.json(), indent=2, ensure_ascii=False) # Beautify the format
|
||||
return resp
|
||||
|
||||
async def async_get_access_token(self):
|
||||
"""Obtain a valid access token."""
|
||||
try:
|
||||
resp = await self.async_make_request("GET", "/v1.0/token?grant_type=1")
|
||||
except requests.exceptions.ConnectionError:
|
||||
return "Request failed, status ConnectionError"
|
||||
|
||||
if not resp.ok:
|
||||
return "Request failed, status " + str(resp.status)
|
||||
|
||||
r_json = resp.json()
|
||||
if not r_json["success"]:
|
||||
return f"Error {r_json['code']}: {r_json['msg']}"
|
||||
|
||||
self._access_token = resp.json()["result"]["access_token"]
|
||||
return "ok"
|
||||
|
||||
async def async_get_devices_list(self):
|
||||
"""Obtain the list of devices associated to a user."""
|
||||
resp = await self.async_make_request(
|
||||
"GET", url=f"/v1.0/users/{self._user_id}/devices"
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
return "Request failed, status " + str(resp.status)
|
||||
|
||||
r_json = resp.json()
|
||||
if not r_json["success"]:
|
||||
# _LOGGER.debug(
|
||||
# "Request failed, reply is %s",
|
||||
# json.dumps(r_json, indent=2, ensure_ascii=False)
|
||||
# )
|
||||
return f"Error {r_json['code']}: {r_json['msg']}"
|
||||
|
||||
self.device_list = {dev["id"]: dev for dev in r_json["result"]}
|
||||
# _LOGGER.debug("DEV_LIST: %s", self.device_list)
|
||||
|
||||
return "ok"
|
||||
607
homeassistant/config/custom_components/localtuya/common.py
Normal file
607
homeassistant/config/custom_components/localtuya/common.py
Normal file
@@ -0,0 +1,607 @@
|
||||
"""Code shared between all platforms."""
|
||||
import asyncio
|
||||
import json.decoder
|
||||
import logging
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DEVICES,
|
||||
CONF_ENTITIES,
|
||||
CONF_FRIENDLY_NAME,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_SCAN_INTERVAL,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from . import pytuya
|
||||
from .const import (
|
||||
ATTR_STATE,
|
||||
ATTR_UPDATED_AT,
|
||||
CONF_DEFAULT_VALUE,
|
||||
CONF_ENABLE_DEBUG,
|
||||
CONF_LOCAL_KEY,
|
||||
CONF_MODEL,
|
||||
CONF_PASSIVE_ENTITY,
|
||||
CONF_PROTOCOL_VERSION,
|
||||
CONF_RESET_DPIDS,
|
||||
CONF_RESTORE_ON_RECONNECT,
|
||||
DATA_CLOUD,
|
||||
DOMAIN,
|
||||
TUYA_DEVICES,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def prepare_setup_entities(hass, config_entry, platform):
|
||||
"""Prepare ro setup entities for a platform."""
|
||||
entities_to_setup = [
|
||||
entity
|
||||
for entity in config_entry.data[CONF_ENTITIES]
|
||||
if entity[CONF_PLATFORM] == platform
|
||||
]
|
||||
if not entities_to_setup:
|
||||
return None, None
|
||||
|
||||
tuyainterface = []
|
||||
|
||||
return tuyainterface, entities_to_setup
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
domain, entity_class, flow_schema, hass, config_entry, async_add_entities
|
||||
):
|
||||
"""Set up a Tuya platform based on a config entry.
|
||||
|
||||
This is a generic method and each platform should lock domain and
|
||||
entity_class with functools.partial.
|
||||
"""
|
||||
entities = []
|
||||
|
||||
for dev_id in config_entry.data[CONF_DEVICES]:
|
||||
# entities_to_setup = prepare_setup_entities(
|
||||
# hass, config_entry.data[dev_id], domain
|
||||
# )
|
||||
dev_entry = config_entry.data[CONF_DEVICES][dev_id]
|
||||
entities_to_setup = [
|
||||
entity
|
||||
for entity in dev_entry[CONF_ENTITIES]
|
||||
if entity[CONF_PLATFORM] == domain
|
||||
]
|
||||
|
||||
if entities_to_setup:
|
||||
|
||||
tuyainterface = hass.data[DOMAIN][TUYA_DEVICES][dev_id]
|
||||
|
||||
dps_config_fields = list(get_dps_for_platform(flow_schema))
|
||||
|
||||
for entity_config in entities_to_setup:
|
||||
# Add DPS used by this platform to the request list
|
||||
for dp_conf in dps_config_fields:
|
||||
if dp_conf in entity_config:
|
||||
tuyainterface.dps_to_request[entity_config[dp_conf]] = None
|
||||
|
||||
entities.append(
|
||||
entity_class(
|
||||
tuyainterface,
|
||||
dev_entry,
|
||||
entity_config[CONF_ID],
|
||||
)
|
||||
)
|
||||
# Once the entities have been created, add to the TuyaDevice instance
|
||||
tuyainterface.add_entities(entities)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
def get_dps_for_platform(flow_schema):
|
||||
"""Return config keys for all platform keys that depends on a datapoint."""
|
||||
for key, value in flow_schema(None).items():
|
||||
if hasattr(value, "container") and value.container is None:
|
||||
yield key.schema
|
||||
|
||||
|
||||
def get_entity_config(config_entry, dp_id):
|
||||
"""Return entity config for a given DPS id."""
|
||||
for entity in config_entry[CONF_ENTITIES]:
|
||||
if entity[CONF_ID] == dp_id:
|
||||
return entity
|
||||
raise Exception(f"missing entity config for id {dp_id}")
|
||||
|
||||
|
||||
@callback
|
||||
def async_config_entry_by_device_id(hass, device_id):
|
||||
"""Look up config entry by device id."""
|
||||
current_entries = hass.config_entries.async_entries(DOMAIN)
|
||||
for entry in current_entries:
|
||||
if device_id in entry.data.get(CONF_DEVICES, []):
|
||||
return entry
|
||||
else:
|
||||
_LOGGER.debug(f"Missing device configuration for device_id {device_id}")
|
||||
return None
|
||||
|
||||
|
||||
class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
|
||||
"""Cache wrapper for pytuya.TuyaInterface."""
|
||||
|
||||
def __init__(self, hass, config_entry, dev_id):
|
||||
"""Initialize the cache."""
|
||||
super().__init__()
|
||||
self._hass = hass
|
||||
self._config_entry = config_entry
|
||||
self._dev_config_entry = config_entry.data[CONF_DEVICES][dev_id].copy()
|
||||
self._interface = None
|
||||
self._status = {}
|
||||
self.dps_to_request = {}
|
||||
self._is_closing = False
|
||||
self._connect_task = None
|
||||
self._disconnect_task = None
|
||||
self._unsub_interval = None
|
||||
self._entities = []
|
||||
self._local_key = self._dev_config_entry[CONF_LOCAL_KEY]
|
||||
self._default_reset_dpids = None
|
||||
if CONF_RESET_DPIDS in self._dev_config_entry:
|
||||
reset_ids_str = self._dev_config_entry[CONF_RESET_DPIDS].split(",")
|
||||
|
||||
self._default_reset_dpids = []
|
||||
for reset_id in reset_ids_str:
|
||||
self._default_reset_dpids.append(int(reset_id.strip()))
|
||||
|
||||
self.set_logger(_LOGGER, self._dev_config_entry[CONF_DEVICE_ID])
|
||||
|
||||
# This has to be done in case the device type is type_0d
|
||||
for entity in self._dev_config_entry[CONF_ENTITIES]:
|
||||
self.dps_to_request[entity[CONF_ID]] = None
|
||||
|
||||
def add_entities(self, entities):
|
||||
"""Set the entities associated with this device."""
|
||||
self._entities.extend(entities)
|
||||
|
||||
@property
|
||||
def is_connecting(self):
|
||||
"""Return whether device is currently connecting."""
|
||||
return self._connect_task is not None
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""Return if connected to device."""
|
||||
return self._interface is not None
|
||||
|
||||
def async_connect(self):
|
||||
"""Connect to device if not already connected."""
|
||||
# self.info("async_connect: %d %r %r", self._is_closing, self._connect_task, self._interface)
|
||||
if not self._is_closing and self._connect_task is None and not self._interface:
|
||||
self._connect_task = asyncio.create_task(self._make_connection())
|
||||
|
||||
async def _make_connection(self):
|
||||
"""Subscribe localtuya entity events."""
|
||||
self.info("Trying to connect to %s...", self._dev_config_entry[CONF_HOST])
|
||||
|
||||
try:
|
||||
self._interface = await pytuya.connect(
|
||||
self._dev_config_entry[CONF_HOST],
|
||||
self._dev_config_entry[CONF_DEVICE_ID],
|
||||
self._local_key,
|
||||
float(self._dev_config_entry[CONF_PROTOCOL_VERSION]),
|
||||
self._dev_config_entry.get(CONF_ENABLE_DEBUG, False),
|
||||
self,
|
||||
)
|
||||
self._interface.add_dps_to_request(self.dps_to_request)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
self.warning(
|
||||
f"Failed to connect to {self._dev_config_entry[CONF_HOST]}: %s", ex
|
||||
)
|
||||
if self._interface is not None:
|
||||
await self._interface.close()
|
||||
self._interface = None
|
||||
|
||||
if self._interface is not None:
|
||||
try:
|
||||
try:
|
||||
self.debug("Retrieving initial state")
|
||||
status = await self._interface.status()
|
||||
if status is None:
|
||||
raise Exception("Failed to retrieve status")
|
||||
|
||||
self._interface.start_heartbeat()
|
||||
self.status_updated(status)
|
||||
|
||||
except Exception as ex:
|
||||
if (self._default_reset_dpids is not None) and (
|
||||
len(self._default_reset_dpids) > 0
|
||||
):
|
||||
self.debug(
|
||||
"Initial state update failed, trying reset command "
|
||||
+ "for DP IDs: %s",
|
||||
self._default_reset_dpids,
|
||||
)
|
||||
await self._interface.reset(self._default_reset_dpids)
|
||||
|
||||
self.debug("Update completed, retrying initial state")
|
||||
status = await self._interface.status()
|
||||
if status is None or not status:
|
||||
raise Exception("Failed to retrieve status") from ex
|
||||
|
||||
self._interface.start_heartbeat()
|
||||
self.status_updated(status)
|
||||
else:
|
||||
self.error("Initial state update failed, giving up: %r", ex)
|
||||
if self._interface is not None:
|
||||
await self._interface.close()
|
||||
self._interface = None
|
||||
|
||||
except (UnicodeDecodeError, json.decoder.JSONDecodeError) as ex:
|
||||
self.warning("Initial state update failed (%s), trying key update", ex)
|
||||
await self.update_local_key()
|
||||
|
||||
if self._interface is not None:
|
||||
await self._interface.close()
|
||||
self._interface = None
|
||||
|
||||
if self._interface is not None:
|
||||
# Attempt to restore status for all entities that need to first set
|
||||
# the DPS value before the device will respond with status.
|
||||
for entity in self._entities:
|
||||
await entity.restore_state_when_connected()
|
||||
|
||||
def _new_entity_handler(entity_id):
|
||||
self.debug(
|
||||
"New entity %s was added to %s",
|
||||
entity_id,
|
||||
self._dev_config_entry[CONF_HOST],
|
||||
)
|
||||
self._dispatch_status()
|
||||
|
||||
signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}"
|
||||
self._disconnect_task = async_dispatcher_connect(
|
||||
self._hass, signal, _new_entity_handler
|
||||
)
|
||||
|
||||
if (
|
||||
CONF_SCAN_INTERVAL in self._dev_config_entry
|
||||
and int(self._dev_config_entry[CONF_SCAN_INTERVAL]) > 0
|
||||
):
|
||||
self._unsub_interval = async_track_time_interval(
|
||||
self._hass,
|
||||
self._async_refresh,
|
||||
timedelta(seconds=int(self._dev_config_entry[CONF_SCAN_INTERVAL])),
|
||||
)
|
||||
|
||||
self.info(f"Successfully connected to {self._dev_config_entry[CONF_HOST]}")
|
||||
|
||||
self._connect_task = None
|
||||
|
||||
async def update_local_key(self):
|
||||
"""Retrieve updated local_key from Cloud API and update the config_entry."""
|
||||
dev_id = self._dev_config_entry[CONF_DEVICE_ID]
|
||||
await self._hass.data[DOMAIN][DATA_CLOUD].async_get_devices_list()
|
||||
cloud_devs = self._hass.data[DOMAIN][DATA_CLOUD].device_list
|
||||
if dev_id in cloud_devs:
|
||||
self._local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
|
||||
new_data = self._config_entry.data.copy()
|
||||
new_data[CONF_DEVICES][dev_id][CONF_LOCAL_KEY] = self._local_key
|
||||
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
|
||||
self._hass.config_entries.async_update_entry(
|
||||
self._config_entry,
|
||||
data=new_data,
|
||||
)
|
||||
self.info("local_key updated for device %s.", dev_id)
|
||||
|
||||
async def _async_refresh(self, _now):
|
||||
if self._interface is not None:
|
||||
await self._interface.update_dps()
|
||||
|
||||
async def close(self):
|
||||
"""Close connection and stop re-connect loop."""
|
||||
self._is_closing = True
|
||||
if self._connect_task is not None:
|
||||
self._connect_task.cancel()
|
||||
await self._connect_task
|
||||
if self._interface is not None:
|
||||
await self._interface.close()
|
||||
if self._disconnect_task is not None:
|
||||
self._disconnect_task()
|
||||
self.info(
|
||||
"Closed connection with device %s.",
|
||||
self._dev_config_entry[CONF_FRIENDLY_NAME],
|
||||
)
|
||||
|
||||
async def set_dp(self, state, dp_index):
|
||||
"""Change value of a DP of the Tuya device."""
|
||||
if self._interface is not None:
|
||||
try:
|
||||
await self._interface.set_dp(state, dp_index)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
self.exception("Failed to set DP %d to %s", dp_index, str(state))
|
||||
else:
|
||||
self.error(
|
||||
"Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME]
|
||||
)
|
||||
|
||||
async def set_dps(self, states):
|
||||
"""Change value of a DPs of the Tuya device."""
|
||||
if self._interface is not None:
|
||||
try:
|
||||
await self._interface.set_dps(states)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
self.exception("Failed to set DPs %r", states)
|
||||
else:
|
||||
self.error(
|
||||
"Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME]
|
||||
)
|
||||
|
||||
@callback
|
||||
def status_updated(self, status):
|
||||
"""Device updated status."""
|
||||
self._status.update(status)
|
||||
self._dispatch_status()
|
||||
|
||||
def _dispatch_status(self):
|
||||
signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}"
|
||||
async_dispatcher_send(self._hass, signal, self._status)
|
||||
|
||||
@callback
|
||||
def disconnected(self):
|
||||
"""Device disconnected."""
|
||||
signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}"
|
||||
async_dispatcher_send(self._hass, signal, None)
|
||||
if self._unsub_interval is not None:
|
||||
self._unsub_interval()
|
||||
self._unsub_interval = None
|
||||
self._interface = None
|
||||
|
||||
if self._connect_task is not None:
|
||||
self._connect_task.cancel()
|
||||
self._connect_task = None
|
||||
self.warning("Disconnected - waiting for discovery broadcast")
|
||||
|
||||
|
||||
class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):
|
||||
"""Representation of a Tuya entity."""
|
||||
|
||||
def __init__(self, device, config_entry, dp_id, logger, **kwargs):
|
||||
"""Initialize the Tuya entity."""
|
||||
super().__init__()
|
||||
self._device = device
|
||||
self._dev_config_entry = config_entry
|
||||
self._config = get_entity_config(config_entry, dp_id)
|
||||
self._dp_id = dp_id
|
||||
self._status = {}
|
||||
self._state = None
|
||||
self._last_state = None
|
||||
|
||||
# Default value is available to be provided by Platform entities if required
|
||||
self._default_value = self._config.get(CONF_DEFAULT_VALUE)
|
||||
|
||||
# Determine whether is a passive entity
|
||||
self._is_passive_entity = self._config.get(CONF_PASSIVE_ENTITY) or False
|
||||
|
||||
""" Restore on connect setting is available to be provided by Platform entities
|
||||
if required"""
|
||||
self._restore_on_reconnect = (
|
||||
self._config.get(CONF_RESTORE_ON_RECONNECT) or False
|
||||
)
|
||||
self.set_logger(logger, self._dev_config_entry[CONF_DEVICE_ID])
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Subscribe localtuya events."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self.debug("Adding %s with configuration: %s", self.entity_id, self._config)
|
||||
|
||||
state = await self.async_get_last_state()
|
||||
if state:
|
||||
self.status_restored(state)
|
||||
|
||||
def _update_handler(status):
|
||||
"""Update entity state when status was updated."""
|
||||
if status is None:
|
||||
status = {}
|
||||
if self._status != status:
|
||||
self._status = status.copy()
|
||||
if status:
|
||||
self.status_updated()
|
||||
|
||||
# Update HA
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}"
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, signal, _update_handler)
|
||||
)
|
||||
|
||||
signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}"
|
||||
async_dispatcher_send(self.hass, signal, self.entity_id)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return entity specific state attributes to be saved.
|
||||
|
||||
These attributes are then available for restore when the
|
||||
entity is restored at startup.
|
||||
"""
|
||||
attributes = {}
|
||||
if self._state is not None:
|
||||
attributes[ATTR_STATE] = self._state
|
||||
elif self._last_state is not None:
|
||||
attributes[ATTR_STATE] = self._last_state
|
||||
|
||||
self.debug("Entity %s - Additional attributes: %s", self.name, attributes)
|
||||
return attributes
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return device information for the device registry."""
|
||||
model = self._dev_config_entry.get(CONF_MODEL, "Tuya generic")
|
||||
return {
|
||||
"identifiers": {
|
||||
# Serial numbers are unique identifiers within a specific domain
|
||||
(DOMAIN, f"local_{self._dev_config_entry[CONF_DEVICE_ID]}")
|
||||
},
|
||||
"name": self._dev_config_entry[CONF_FRIENDLY_NAME],
|
||||
"manufacturer": "Tuya",
|
||||
"model": f"{model} ({self._dev_config_entry[CONF_DEVICE_ID]})",
|
||||
"sw_version": self._dev_config_entry[CONF_PROTOCOL_VERSION],
|
||||
}
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Get name of Tuya entity."""
|
||||
return self._config[CONF_FRIENDLY_NAME]
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return if platform should poll for updates."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return unique device identifier."""
|
||||
return f"local_{self._dev_config_entry[CONF_DEVICE_ID]}_{self._dp_id}"
|
||||
|
||||
def has_config(self, attr):
|
||||
"""Return if a config parameter has a valid value."""
|
||||
value = self._config.get(attr, "-1")
|
||||
return value is not None and value != "-1"
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return if device is available or not."""
|
||||
return str(self._dp_id) in self._status
|
||||
|
||||
def dps(self, dp_index):
|
||||
"""Return cached value for DPS index."""
|
||||
value = self._status.get(str(dp_index))
|
||||
if value is None:
|
||||
self.warning(
|
||||
"Entity %s is requesting unknown DPS index %s",
|
||||
self.entity_id,
|
||||
dp_index,
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
def dps_conf(self, conf_item):
|
||||
"""Return value of datapoint for user specified config item.
|
||||
|
||||
This method looks up which DP a certain config item uses based on
|
||||
user configuration and returns its value.
|
||||
"""
|
||||
dp_index = self._config.get(conf_item)
|
||||
if dp_index is None:
|
||||
self.warning(
|
||||
"Entity %s is requesting unset index for option %s",
|
||||
self.entity_id,
|
||||
conf_item,
|
||||
)
|
||||
return self.dps(dp_index)
|
||||
|
||||
def status_updated(self):
|
||||
"""Device status was updated.
|
||||
|
||||
Override in subclasses and update entity specific state.
|
||||
"""
|
||||
state = self.dps(self._dp_id)
|
||||
self._state = state
|
||||
|
||||
# Keep record in last_state as long as not during connection/re-connection,
|
||||
# as last state will be used to restore the previous state
|
||||
if (state is not None) and (not self._device.is_connecting):
|
||||
self._last_state = state
|
||||
|
||||
def status_restored(self, stored_state):
|
||||
"""Device status was restored.
|
||||
|
||||
Override in subclasses and update entity specific state.
|
||||
"""
|
||||
raw_state = stored_state.attributes.get(ATTR_STATE)
|
||||
if raw_state is not None:
|
||||
self._last_state = raw_state
|
||||
self.debug(
|
||||
"Restoring state for entity: %s - state: %s",
|
||||
self.name,
|
||||
str(self._last_state),
|
||||
)
|
||||
|
||||
def default_value(self):
|
||||
"""Return default value of this entity.
|
||||
|
||||
Override in subclasses to specify the default value for the entity.
|
||||
"""
|
||||
# Check if default value has been set - if not, default to the entity defaults.
|
||||
if self._default_value is None:
|
||||
self._default_value = self.entity_default_value()
|
||||
|
||||
return self._default_value
|
||||
|
||||
def entity_default_value(self): # pylint: disable=no-self-use
|
||||
"""Return default value of the entity type.
|
||||
|
||||
Override in subclasses to specify the default value for the entity.
|
||||
"""
|
||||
return 0
|
||||
|
||||
@property
|
||||
def restore_on_reconnect(self):
|
||||
"""Return whether the last state should be restored on a reconnect.
|
||||
|
||||
Useful where the device loses settings if powered off
|
||||
"""
|
||||
return self._restore_on_reconnect
|
||||
|
||||
async def restore_state_when_connected(self):
|
||||
"""Restore if restore_on_reconnect is set, or if no status has been yet found.
|
||||
|
||||
Which indicates a DPS that needs to be set before it starts returning
|
||||
status.
|
||||
"""
|
||||
if (not self.restore_on_reconnect) and (
|
||||
(str(self._dp_id) in self._status) or (not self._is_passive_entity)
|
||||
):
|
||||
self.debug(
|
||||
"Entity %s (DP %d) - Not restoring as restore on reconnect is "
|
||||
+ "disabled for this entity and the entity has an initial status "
|
||||
+ "or it is not a passive entity",
|
||||
self.name,
|
||||
self._dp_id,
|
||||
)
|
||||
return
|
||||
|
||||
self.debug("Attempting to restore state for entity: %s", self.name)
|
||||
# Attempt to restore the current state - in case reset.
|
||||
restore_state = self._state
|
||||
|
||||
# If no state stored in the entity currently, go from last saved state
|
||||
if (restore_state == STATE_UNKNOWN) | (restore_state is None):
|
||||
self.debug("No current state for entity")
|
||||
restore_state = self._last_state
|
||||
|
||||
# If no current or saved state, then use the default value
|
||||
if restore_state is None:
|
||||
if self._is_passive_entity:
|
||||
self.debug("No last restored state - using default")
|
||||
restore_state = self.default_value()
|
||||
else:
|
||||
self.debug("Not a passive entity and no state found - aborting restore")
|
||||
return
|
||||
|
||||
self.debug(
|
||||
"Entity %s (DP %d) - Restoring state: %s",
|
||||
self.name,
|
||||
self._dp_id,
|
||||
str(restore_state),
|
||||
)
|
||||
|
||||
# Manually initialise
|
||||
await self._device.set_dp(restore_state, self._dp_id)
|
||||
819
homeassistant/config/custom_components/localtuya/config_flow.py
Normal file
819
homeassistant/config/custom_components/localtuya/config_flow.py
Normal file
@@ -0,0 +1,819 @@
|
||||
"""Config flow for LocalTuya integration integration."""
|
||||
import errno
|
||||
import logging
|
||||
import time
|
||||
from importlib import import_module
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries, core, exceptions
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DEVICES,
|
||||
CONF_ENTITIES,
|
||||
CONF_FRIENDLY_NAME,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PLATFORM,
|
||||
CONF_REGION,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .cloud_api import TuyaCloudApi
|
||||
from .common import pytuya
|
||||
from .const import (
|
||||
ATTR_UPDATED_AT,
|
||||
CONF_ACTION,
|
||||
CONF_ADD_DEVICE,
|
||||
CONF_DPS_STRINGS,
|
||||
CONF_EDIT_DEVICE,
|
||||
CONF_ENABLE_DEBUG,
|
||||
CONF_LOCAL_KEY,
|
||||
CONF_MANUAL_DPS,
|
||||
CONF_MODEL,
|
||||
CONF_NO_CLOUD,
|
||||
CONF_PRODUCT_NAME,
|
||||
CONF_PROTOCOL_VERSION,
|
||||
CONF_RESET_DPIDS,
|
||||
CONF_SETUP_CLOUD,
|
||||
CONF_USER_ID,
|
||||
CONF_ENABLE_ADD_ENTITIES,
|
||||
DATA_CLOUD,
|
||||
DATA_DISCOVERY,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .discovery import discover
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ENTRIES_VERSION = 2
|
||||
|
||||
PLATFORM_TO_ADD = "platform_to_add"
|
||||
NO_ADDITIONAL_ENTITIES = "no_additional_entities"
|
||||
SELECTED_DEVICE = "selected_device"
|
||||
|
||||
CUSTOM_DEVICE = "..."
|
||||
|
||||
CONF_ACTIONS = {
|
||||
CONF_ADD_DEVICE: "Add a new device",
|
||||
CONF_EDIT_DEVICE: "Edit a device",
|
||||
CONF_SETUP_CLOUD: "Reconfigure Cloud API account",
|
||||
}
|
||||
|
||||
CONFIGURE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACTION, default=CONF_ADD_DEVICE): vol.In(CONF_ACTIONS),
|
||||
}
|
||||
)
|
||||
|
||||
CLOUD_SETUP_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_REGION, default="eu"): vol.In(["eu", "us", "cn", "in"]),
|
||||
vol.Optional(CONF_CLIENT_ID): cv.string,
|
||||
vol.Optional(CONF_CLIENT_SECRET): cv.string,
|
||||
vol.Optional(CONF_USER_ID): cv.string,
|
||||
vol.Optional(CONF_USERNAME, default=DOMAIN): cv.string,
|
||||
vol.Required(CONF_NO_CLOUD, default=False): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
DEVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_FRIENDLY_NAME): cv.string,
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_DEVICE_ID): cv.string,
|
||||
vol.Required(CONF_LOCAL_KEY): cv.string,
|
||||
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(
|
||||
["3.1", "3.2", "3.3", "3.4"]
|
||||
),
|
||||
vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
|
||||
vol.Optional(CONF_SCAN_INTERVAL): int,
|
||||
vol.Optional(CONF_MANUAL_DPS): cv.string,
|
||||
vol.Optional(CONF_RESET_DPIDS): str,
|
||||
}
|
||||
)
|
||||
|
||||
PICK_ENTITY_SCHEMA = vol.Schema(
|
||||
{vol.Required(PLATFORM_TO_ADD, default="switch"): vol.In(PLATFORMS)}
|
||||
)
|
||||
|
||||
|
||||
def devices_schema(discovered_devices, cloud_devices_list, add_custom_device=True):
|
||||
"""Create schema for devices step."""
|
||||
devices = {}
|
||||
for dev_id, dev_host in discovered_devices.items():
|
||||
dev_name = dev_id
|
||||
if dev_id in cloud_devices_list.keys():
|
||||
dev_name = cloud_devices_list[dev_id][CONF_NAME]
|
||||
devices[dev_id] = f"{dev_name} ({dev_host})"
|
||||
|
||||
if add_custom_device:
|
||||
devices.update({CUSTOM_DEVICE: CUSTOM_DEVICE})
|
||||
|
||||
# devices.update(
|
||||
# {
|
||||
# ent.data[CONF_DEVICE_ID]: ent.data[CONF_FRIENDLY_NAME]
|
||||
# for ent in entries
|
||||
# }
|
||||
# )
|
||||
return vol.Schema({vol.Required(SELECTED_DEVICE): vol.In(devices)})
|
||||
|
||||
|
||||
def options_schema(entities):
|
||||
"""Create schema for options."""
|
||||
entity_names = [
|
||||
f"{entity[CONF_ID]}: {entity[CONF_FRIENDLY_NAME]}" for entity in entities
|
||||
]
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_FRIENDLY_NAME): cv.string,
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_LOCAL_KEY): cv.string,
|
||||
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(
|
||||
["3.1", "3.2", "3.3", "3.4"]
|
||||
),
|
||||
vol.Required(CONF_ENABLE_DEBUG, default=False): bool,
|
||||
vol.Optional(CONF_SCAN_INTERVAL): int,
|
||||
vol.Optional(CONF_MANUAL_DPS): cv.string,
|
||||
vol.Optional(CONF_RESET_DPIDS): cv.string,
|
||||
vol.Required(
|
||||
CONF_ENTITIES, description={"suggested_value": entity_names}
|
||||
): cv.multi_select(entity_names),
|
||||
vol.Required(CONF_ENABLE_ADD_ENTITIES, default=False): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def schema_defaults(schema, dps_list=None, **defaults):
|
||||
"""Create a new schema with default values filled in."""
|
||||
copy = schema.extend({})
|
||||
for field, field_type in copy.schema.items():
|
||||
if isinstance(field_type, vol.In):
|
||||
value = None
|
||||
for dps in dps_list or []:
|
||||
if dps.startswith(f"{defaults.get(field)} "):
|
||||
value = dps
|
||||
break
|
||||
|
||||
if value in field_type.container:
|
||||
field.default = vol.default_factory(value)
|
||||
continue
|
||||
|
||||
if field.schema in defaults:
|
||||
field.default = vol.default_factory(defaults[field])
|
||||
return copy
|
||||
|
||||
|
||||
def dps_string_list(dps_data):
|
||||
"""Return list of friendly DPS values."""
|
||||
return [f"{id} (value: {value})" for id, value in dps_data.items()]
|
||||
|
||||
|
||||
def gen_dps_strings():
|
||||
"""Generate list of DPS values."""
|
||||
return [f"{dp} (value: ?)" for dp in range(1, 256)]
|
||||
|
||||
|
||||
def platform_schema(platform, dps_strings, allow_id=True, yaml=False):
|
||||
"""Generate input validation schema for a platform."""
|
||||
schema = {}
|
||||
if yaml:
|
||||
# In YAML mode we force the specified platform to match flow schema
|
||||
schema[vol.Required(CONF_PLATFORM)] = vol.In([platform])
|
||||
if allow_id:
|
||||
schema[vol.Required(CONF_ID)] = vol.In(dps_strings)
|
||||
schema[vol.Required(CONF_FRIENDLY_NAME)] = str
|
||||
return vol.Schema(schema).extend(flow_schema(platform, dps_strings))
|
||||
|
||||
|
||||
def flow_schema(platform, dps_strings):
|
||||
"""Return flow schema for a specific platform."""
|
||||
integration_module = ".".join(__name__.split(".")[:-1])
|
||||
return import_module("." + platform, integration_module).flow_schema(dps_strings)
|
||||
|
||||
|
||||
def strip_dps_values(user_input, dps_strings):
|
||||
"""Remove values and keep only index for DPS config items."""
|
||||
stripped = {}
|
||||
for field, value in user_input.items():
|
||||
if value in dps_strings:
|
||||
stripped[field] = int(user_input[field].split(" ")[0])
|
||||
else:
|
||||
stripped[field] = user_input[field]
|
||||
return stripped
|
||||
|
||||
|
||||
def config_schema():
|
||||
"""Build schema used for setting up component."""
|
||||
entity_schemas = [
|
||||
platform_schema(platform, range(1, 256), yaml=True) for platform in PLATFORMS
|
||||
]
|
||||
return vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
DEVICE_SCHEMA.extend(
|
||||
{vol.Required(CONF_ENTITIES): [vol.Any(*entity_schemas)]}
|
||||
)
|
||||
],
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: core.HomeAssistant, data):
|
||||
"""Validate the user input allows us to connect."""
|
||||
detected_dps = {}
|
||||
|
||||
interface = None
|
||||
|
||||
reset_ids = None
|
||||
try:
|
||||
interface = await pytuya.connect(
|
||||
data[CONF_HOST],
|
||||
data[CONF_DEVICE_ID],
|
||||
data[CONF_LOCAL_KEY],
|
||||
float(data[CONF_PROTOCOL_VERSION]),
|
||||
data[CONF_ENABLE_DEBUG],
|
||||
)
|
||||
if CONF_RESET_DPIDS in data:
|
||||
reset_ids_str = data[CONF_RESET_DPIDS].split(",")
|
||||
reset_ids = []
|
||||
for reset_id in reset_ids_str:
|
||||
reset_ids.append(int(reset_id.strip()))
|
||||
_LOGGER.debug(
|
||||
"Reset DPIDs configured: %s (%s)",
|
||||
data[CONF_RESET_DPIDS],
|
||||
reset_ids,
|
||||
)
|
||||
try:
|
||||
detected_dps = await interface.detect_available_dps()
|
||||
except Exception as ex:
|
||||
try:
|
||||
_LOGGER.debug(
|
||||
"Initial state update failed (%s), trying reset command", ex
|
||||
)
|
||||
if len(reset_ids) > 0:
|
||||
await interface.reset(reset_ids)
|
||||
detected_dps = await interface.detect_available_dps()
|
||||
except Exception as ex:
|
||||
_LOGGER.debug("No DPS able to be detected: %s", ex)
|
||||
detected_dps = {}
|
||||
|
||||
# if manual DPs are set, merge these.
|
||||
_LOGGER.debug("Detected DPS: %s", detected_dps)
|
||||
if CONF_MANUAL_DPS in data:
|
||||
manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")]
|
||||
_LOGGER.debug(
|
||||
"Manual DPS Setting: %s (%s)", data[CONF_MANUAL_DPS], manual_dps_list
|
||||
)
|
||||
# merge the lists
|
||||
for new_dps in manual_dps_list + (reset_ids or []):
|
||||
# If the DPS not in the detected dps list, then add with a
|
||||
# default value indicating that it has been manually added
|
||||
if str(new_dps) not in detected_dps:
|
||||
detected_dps[new_dps] = -1
|
||||
|
||||
except (ConnectionRefusedError, ConnectionResetError) as ex:
|
||||
raise CannotConnect from ex
|
||||
except ValueError as ex:
|
||||
raise InvalidAuth from ex
|
||||
finally:
|
||||
if interface:
|
||||
await interface.close()
|
||||
|
||||
# Indicate an error if no datapoints found as the rest of the flow
|
||||
# won't work in this case
|
||||
if not detected_dps:
|
||||
raise EmptyDpsList
|
||||
|
||||
_LOGGER.debug("Total DPS: %s", detected_dps)
|
||||
|
||||
return dps_string_list(detected_dps)
|
||||
|
||||
|
||||
async def attempt_cloud_connection(hass, user_input):
|
||||
"""Create device."""
|
||||
cloud_api = TuyaCloudApi(
|
||||
hass,
|
||||
user_input.get(CONF_REGION),
|
||||
user_input.get(CONF_CLIENT_ID),
|
||||
user_input.get(CONF_CLIENT_SECRET),
|
||||
user_input.get(CONF_USER_ID),
|
||||
)
|
||||
|
||||
res = await cloud_api.async_get_access_token()
|
||||
if res != "ok":
|
||||
_LOGGER.error("Cloud API connection failed: %s", res)
|
||||
return cloud_api, {"reason": "authentication_failed", "msg": res}
|
||||
|
||||
res = await cloud_api.async_get_devices_list()
|
||||
if res != "ok":
|
||||
_LOGGER.error("Cloud API get_devices_list failed: %s", res)
|
||||
return cloud_api, {"reason": "device_list_failed", "msg": res}
|
||||
_LOGGER.info("Cloud API connection succeeded.")
|
||||
|
||||
return cloud_api, {}
|
||||
|
||||
|
||||
class LocaltuyaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for LocalTuya integration."""
|
||||
|
||||
VERSION = ENTRIES_VERSION
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Get options flow for this handler."""
|
||||
return LocalTuyaOptionsFlowHandler(config_entry)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a new LocaltuyaConfigFlow."""
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
placeholders = {}
|
||||
if user_input is not None:
|
||||
if user_input.get(CONF_NO_CLOUD):
|
||||
for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
|
||||
user_input[i] = ""
|
||||
return await self._create_entry(user_input)
|
||||
|
||||
cloud_api, res = await attempt_cloud_connection(self.hass, user_input)
|
||||
|
||||
if not res:
|
||||
return await self._create_entry(user_input)
|
||||
errors["base"] = res["reason"]
|
||||
placeholders = {"msg": res["msg"]}
|
||||
|
||||
defaults = {}
|
||||
defaults.update(user_input or {})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults),
|
||||
errors=errors,
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
|
||||
async def _create_entry(self, user_input):
|
||||
"""Register new entry."""
|
||||
# if self._async_current_entries():
|
||||
# return self.async_abort(reason="already_configured")
|
||||
|
||||
await self.async_set_unique_id(user_input.get(CONF_USER_ID))
|
||||
user_input[CONF_DEVICES] = {}
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input.get(CONF_USERNAME),
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
async def async_step_import(self, user_input):
|
||||
"""Handle import from YAML."""
|
||||
_LOGGER.error(
|
||||
"Configuration via YAML file is no longer supported by this integration."
|
||||
)
|
||||
|
||||
|
||||
class LocalTuyaOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options flow for LocalTuya integration."""
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Initialize localtuya options flow."""
|
||||
self._config_entry = config_entry
|
||||
# self.dps_strings = config_entry.data.get(CONF_DPS_STRINGS, gen_dps_strings())
|
||||
# self.entities = config_entry.data[CONF_ENTITIES]
|
||||
self.selected_device = None
|
||||
self.editing_device = False
|
||||
self.device_data = None
|
||||
self.dps_strings = []
|
||||
self.selected_platform = None
|
||||
self.discovered_devices = {}
|
||||
self.entities = []
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Manage basic options."""
|
||||
# device_id = self.config_entry.data[CONF_DEVICE_ID]
|
||||
if user_input is not None:
|
||||
if user_input.get(CONF_ACTION) == CONF_SETUP_CLOUD:
|
||||
return await self.async_step_cloud_setup()
|
||||
if user_input.get(CONF_ACTION) == CONF_ADD_DEVICE:
|
||||
return await self.async_step_add_device()
|
||||
if user_input.get(CONF_ACTION) == CONF_EDIT_DEVICE:
|
||||
return await self.async_step_edit_device()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=CONFIGURE_SCHEMA,
|
||||
)
|
||||
|
||||
async def async_step_cloud_setup(self, user_input=None):
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
placeholders = {}
|
||||
if user_input is not None:
|
||||
if user_input.get(CONF_NO_CLOUD):
|
||||
new_data = self.config_entry.data.copy()
|
||||
new_data.update(user_input)
|
||||
for i in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
|
||||
new_data[i] = ""
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data=new_data,
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=new_data.get(CONF_USERNAME), data={}
|
||||
)
|
||||
|
||||
cloud_api, res = await attempt_cloud_connection(self.hass, user_input)
|
||||
|
||||
if not res:
|
||||
new_data = self.config_entry.data.copy()
|
||||
new_data.update(user_input)
|
||||
cloud_devs = cloud_api.device_list
|
||||
for dev_id, dev in new_data[CONF_DEVICES].items():
|
||||
if CONF_MODEL not in dev and dev_id in cloud_devs:
|
||||
model = cloud_devs[dev_id].get(CONF_PRODUCT_NAME)
|
||||
new_data[CONF_DEVICES][dev_id][CONF_MODEL] = model
|
||||
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data=new_data,
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=new_data.get(CONF_USERNAME), data={}
|
||||
)
|
||||
errors["base"] = res["reason"]
|
||||
placeholders = {"msg": res["msg"]}
|
||||
|
||||
defaults = self.config_entry.data.copy()
|
||||
defaults.update(user_input or {})
|
||||
defaults[CONF_NO_CLOUD] = False
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="cloud_setup",
|
||||
data_schema=schema_defaults(CLOUD_SETUP_SCHEMA, **defaults),
|
||||
errors=errors,
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
|
||||
async def async_step_add_device(self, user_input=None):
|
||||
"""Handle adding a new device."""
|
||||
# Use cache if available or fallback to manual discovery
|
||||
self.editing_device = False
|
||||
self.selected_device = None
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
if user_input[SELECTED_DEVICE] != CUSTOM_DEVICE:
|
||||
self.selected_device = user_input[SELECTED_DEVICE]
|
||||
|
||||
return await self.async_step_configure_device()
|
||||
|
||||
self.discovered_devices = {}
|
||||
data = self.hass.data.get(DOMAIN)
|
||||
|
||||
if data and DATA_DISCOVERY in data:
|
||||
self.discovered_devices = data[DATA_DISCOVERY].devices
|
||||
else:
|
||||
try:
|
||||
self.discovered_devices = await discover()
|
||||
except OSError as ex:
|
||||
if ex.errno == errno.EADDRINUSE:
|
||||
errors["base"] = "address_in_use"
|
||||
else:
|
||||
errors["base"] = "discovery_failed"
|
||||
except Exception as ex:
|
||||
_LOGGER.exception("discovery failed: %s", ex)
|
||||
errors["base"] = "discovery_failed"
|
||||
|
||||
devices = {
|
||||
dev_id: dev["ip"]
|
||||
for dev_id, dev in self.discovered_devices.items()
|
||||
if dev["gwId"] not in self.config_entry.data[CONF_DEVICES]
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="add_device",
|
||||
data_schema=devices_schema(
|
||||
devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_edit_device(self, user_input=None):
|
||||
"""Handle editing a device."""
|
||||
self.editing_device = True
|
||||
# Use cache if available or fallback to manual discovery
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
self.selected_device = user_input[SELECTED_DEVICE]
|
||||
dev_conf = self.config_entry.data[CONF_DEVICES][self.selected_device]
|
||||
self.dps_strings = dev_conf.get(CONF_DPS_STRINGS, gen_dps_strings())
|
||||
self.entities = dev_conf[CONF_ENTITIES]
|
||||
|
||||
return await self.async_step_configure_device()
|
||||
|
||||
devices = {}
|
||||
for dev_id, configured_dev in self.config_entry.data[CONF_DEVICES].items():
|
||||
devices[dev_id] = configured_dev[CONF_HOST]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="edit_device",
|
||||
data_schema=devices_schema(
|
||||
devices, self.hass.data[DOMAIN][DATA_CLOUD].device_list, False
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_configure_device(self, user_input=None):
|
||||
"""Handle input of basic info."""
|
||||
errors = {}
|
||||
dev_id = self.selected_device
|
||||
if user_input is not None:
|
||||
try:
|
||||
self.device_data = user_input.copy()
|
||||
if dev_id is not None:
|
||||
# self.device_data[CONF_PRODUCT_KEY] = self.devices[
|
||||
# self.selected_device
|
||||
# ]["productKey"]
|
||||
cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
|
||||
if dev_id in cloud_devs:
|
||||
self.device_data[CONF_MODEL] = cloud_devs[dev_id].get(
|
||||
CONF_PRODUCT_NAME
|
||||
)
|
||||
if self.editing_device:
|
||||
if user_input[CONF_ENABLE_ADD_ENTITIES]:
|
||||
self.editing_device = False
|
||||
user_input[CONF_DEVICE_ID] = dev_id
|
||||
self.device_data.update(
|
||||
{
|
||||
CONF_DEVICE_ID: dev_id,
|
||||
CONF_DPS_STRINGS: self.dps_strings,
|
||||
}
|
||||
)
|
||||
return await self.async_step_pick_entity_type()
|
||||
|
||||
self.device_data.update(
|
||||
{
|
||||
CONF_DEVICE_ID: dev_id,
|
||||
CONF_DPS_STRINGS: self.dps_strings,
|
||||
CONF_ENTITIES: [],
|
||||
}
|
||||
)
|
||||
if len(user_input[CONF_ENTITIES]) == 0:
|
||||
return self.async_abort(
|
||||
reason="no_entities",
|
||||
description_placeholders={},
|
||||
)
|
||||
if user_input[CONF_ENTITIES]:
|
||||
entity_ids = [
|
||||
int(entity.split(":")[0])
|
||||
for entity in user_input[CONF_ENTITIES]
|
||||
]
|
||||
device_config = self.config_entry.data[CONF_DEVICES][dev_id]
|
||||
self.entities = [
|
||||
entity
|
||||
for entity in device_config[CONF_ENTITIES]
|
||||
if entity[CONF_ID] in entity_ids
|
||||
]
|
||||
return await self.async_step_configure_entity()
|
||||
|
||||
self.dps_strings = await validate_input(self.hass, user_input)
|
||||
return await self.async_step_pick_entity_type()
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except EmptyDpsList:
|
||||
errors["base"] = "empty_dps"
|
||||
except Exception as ex:
|
||||
_LOGGER.exception("Unexpected exception: %s", ex)
|
||||
errors["base"] = "unknown"
|
||||
|
||||
defaults = {}
|
||||
if self.editing_device:
|
||||
# If selected device exists as a config entry, load config from it
|
||||
defaults = self.config_entry.data[CONF_DEVICES][dev_id].copy()
|
||||
cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
|
||||
placeholders = {"for_device": f" for device `{dev_id}`"}
|
||||
if dev_id in cloud_devs:
|
||||
cloud_local_key = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
|
||||
if defaults[CONF_LOCAL_KEY] != cloud_local_key:
|
||||
_LOGGER.info(
|
||||
"New local_key detected: new %s vs old %s",
|
||||
cloud_local_key,
|
||||
defaults[CONF_LOCAL_KEY],
|
||||
)
|
||||
defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
|
||||
note = "\nNOTE: a new local_key has been retrieved using cloud API"
|
||||
placeholders = {"for_device": f" for device `{dev_id}`.{note}"}
|
||||
defaults[CONF_ENABLE_ADD_ENTITIES] = False
|
||||
schema = schema_defaults(options_schema(self.entities), **defaults)
|
||||
else:
|
||||
defaults[CONF_PROTOCOL_VERSION] = "3.3"
|
||||
defaults[CONF_HOST] = ""
|
||||
defaults[CONF_DEVICE_ID] = ""
|
||||
defaults[CONF_LOCAL_KEY] = ""
|
||||
defaults[CONF_FRIENDLY_NAME] = ""
|
||||
if dev_id is not None:
|
||||
# Insert default values from discovery and cloud if present
|
||||
device = self.discovered_devices[dev_id]
|
||||
defaults[CONF_HOST] = device.get("ip")
|
||||
defaults[CONF_DEVICE_ID] = device.get("gwId")
|
||||
defaults[CONF_PROTOCOL_VERSION] = device.get("version")
|
||||
cloud_devs = self.hass.data[DOMAIN][DATA_CLOUD].device_list
|
||||
if dev_id in cloud_devs:
|
||||
defaults[CONF_LOCAL_KEY] = cloud_devs[dev_id].get(CONF_LOCAL_KEY)
|
||||
defaults[CONF_FRIENDLY_NAME] = cloud_devs[dev_id].get(CONF_NAME)
|
||||
schema = schema_defaults(DEVICE_SCHEMA, **defaults)
|
||||
|
||||
placeholders = {"for_device": ""}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_device",
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
|
||||
async def async_step_pick_entity_type(self, user_input=None):
|
||||
"""Handle asking if user wants to add another entity."""
|
||||
if user_input is not None:
|
||||
if user_input.get(NO_ADDITIONAL_ENTITIES):
|
||||
config = {
|
||||
**self.device_data,
|
||||
CONF_DPS_STRINGS: self.dps_strings,
|
||||
CONF_ENTITIES: self.entities,
|
||||
}
|
||||
|
||||
dev_id = self.device_data.get(CONF_DEVICE_ID)
|
||||
|
||||
new_data = self.config_entry.data.copy()
|
||||
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
|
||||
new_data[CONF_DEVICES].update({dev_id: config})
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data=new_data,
|
||||
)
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
self.selected_platform = user_input[PLATFORM_TO_ADD]
|
||||
return await self.async_step_configure_entity()
|
||||
|
||||
# Add a checkbox that allows bailing out from config flow if at least one
|
||||
# entity has been added
|
||||
schema = PICK_ENTITY_SCHEMA
|
||||
if self.selected_platform is not None:
|
||||
schema = schema.extend(
|
||||
{vol.Required(NO_ADDITIONAL_ENTITIES, default=True): bool}
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="pick_entity_type", data_schema=schema)
|
||||
|
||||
def available_dps_strings(self):
|
||||
"""Return list of DPs use by the device's entities."""
|
||||
available_dps = []
|
||||
used_dps = [str(entity[CONF_ID]) for entity in self.entities]
|
||||
for dp_string in self.dps_strings:
|
||||
dp = dp_string.split(" ")[0]
|
||||
if dp not in used_dps:
|
||||
available_dps.append(dp_string)
|
||||
return available_dps
|
||||
|
||||
async def async_step_entity(self, user_input=None):
|
||||
"""Manage entity settings."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
entity = strip_dps_values(user_input, self.dps_strings)
|
||||
entity[CONF_ID] = self.current_entity[CONF_ID]
|
||||
entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM]
|
||||
self.device_data[CONF_ENTITIES].append(entity)
|
||||
|
||||
if len(self.entities) == len(self.device_data[CONF_ENTITIES]):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
title=self.device_data[CONF_FRIENDLY_NAME],
|
||||
data=self.device_data,
|
||||
)
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
schema = platform_schema(
|
||||
self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="entity",
|
||||
errors=errors,
|
||||
data_schema=schema_defaults(
|
||||
schema, self.dps_strings, **self.current_entity
|
||||
),
|
||||
description_placeholders={
|
||||
"id": self.current_entity[CONF_ID],
|
||||
"platform": self.current_entity[CONF_PLATFORM],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_configure_entity(self, user_input=None):
|
||||
"""Manage entity settings."""
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
if self.editing_device:
|
||||
entity = strip_dps_values(user_input, self.dps_strings)
|
||||
entity[CONF_ID] = self.current_entity[CONF_ID]
|
||||
entity[CONF_PLATFORM] = self.current_entity[CONF_PLATFORM]
|
||||
self.device_data[CONF_ENTITIES].append(entity)
|
||||
|
||||
if len(self.entities) == len(self.device_data[CONF_ENTITIES]):
|
||||
# finished editing device. Let's store the new config entry....
|
||||
dev_id = self.device_data[CONF_DEVICE_ID]
|
||||
new_data = self.config_entry.data.copy()
|
||||
entry_id = self.config_entry.entry_id
|
||||
# removing entities from registry (they will be recreated)
|
||||
ent_reg = er.async_get(self.hass)
|
||||
reg_entities = {
|
||||
ent.unique_id: ent.entity_id
|
||||
for ent in er.async_entries_for_config_entry(ent_reg, entry_id)
|
||||
if dev_id in ent.unique_id
|
||||
}
|
||||
for entity_id in reg_entities.values():
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
new_data[CONF_DEVICES][dev_id] = self.device_data
|
||||
new_data[ATTR_UPDATED_AT] = str(int(time.time() * 1000))
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data=new_data,
|
||||
)
|
||||
return self.async_create_entry(title="", data={})
|
||||
else:
|
||||
user_input[CONF_PLATFORM] = self.selected_platform
|
||||
self.entities.append(strip_dps_values(user_input, self.dps_strings))
|
||||
# new entity added. Let's check if there are more left...
|
||||
user_input = None
|
||||
if len(self.available_dps_strings()) == 0:
|
||||
user_input = {NO_ADDITIONAL_ENTITIES: True}
|
||||
return await self.async_step_pick_entity_type(user_input)
|
||||
|
||||
if self.editing_device:
|
||||
schema = platform_schema(
|
||||
self.current_entity[CONF_PLATFORM], self.dps_strings, allow_id=False
|
||||
)
|
||||
schema = schema_defaults(schema, self.dps_strings, **self.current_entity)
|
||||
placeholders = {
|
||||
"entity": f"entity with DP {self.current_entity[CONF_ID]}",
|
||||
"platform": self.current_entity[CONF_PLATFORM],
|
||||
}
|
||||
else:
|
||||
available_dps = self.available_dps_strings()
|
||||
schema = platform_schema(self.selected_platform, available_dps)
|
||||
placeholders = {
|
||||
"entity": "an entity",
|
||||
"platform": self.selected_platform,
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="configure_entity",
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
|
||||
async def async_step_yaml_import(self, user_input=None):
|
||||
"""Manage YAML imports."""
|
||||
_LOGGER.error(
|
||||
"Configuration via YAML file is no longer supported by this integration."
|
||||
)
|
||||
# if user_input is not None:
|
||||
# return self.async_create_entry(title="", data={})
|
||||
# return self.async_show_form(step_id="yaml_import")
|
||||
|
||||
@property
|
||||
def current_entity(self):
|
||||
"""Existing configuration for entity currently being edited."""
|
||||
return self.entities[len(self.device_data[CONF_ENTITIES])]
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(exceptions.HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
class EmptyDpsList(exceptions.HomeAssistantError):
|
||||
"""Error to indicate no datapoints found."""
|
||||
143
homeassistant/config/custom_components/localtuya/const.py
Normal file
143
homeassistant/config/custom_components/localtuya/const.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Constants for localtuya integration."""
|
||||
|
||||
DOMAIN = "localtuya"
|
||||
|
||||
DATA_DISCOVERY = "discovery"
|
||||
DATA_CLOUD = "cloud_data"
|
||||
|
||||
# Platforms in this list must support config flows
|
||||
PLATFORMS = [
|
||||
"binary_sensor",
|
||||
"climate",
|
||||
"cover",
|
||||
"fan",
|
||||
"light",
|
||||
"number",
|
||||
"select",
|
||||
"sensor",
|
||||
"switch",
|
||||
"vacuum",
|
||||
]
|
||||
|
||||
TUYA_DEVICES = "tuya_devices"
|
||||
|
||||
ATTR_CURRENT = "current"
|
||||
ATTR_CURRENT_CONSUMPTION = "current_consumption"
|
||||
ATTR_VOLTAGE = "voltage"
|
||||
ATTR_UPDATED_AT = "updated_at"
|
||||
|
||||
# config flow
|
||||
CONF_LOCAL_KEY = "local_key"
|
||||
CONF_ENABLE_DEBUG = "enable_debug"
|
||||
CONF_PROTOCOL_VERSION = "protocol_version"
|
||||
CONF_DPS_STRINGS = "dps_strings"
|
||||
CONF_MODEL = "model"
|
||||
CONF_PRODUCT_KEY = "product_key"
|
||||
CONF_PRODUCT_NAME = "product_name"
|
||||
CONF_USER_ID = "user_id"
|
||||
CONF_ENABLE_ADD_ENTITIES = "add_entities"
|
||||
|
||||
|
||||
CONF_ACTION = "action"
|
||||
CONF_ADD_DEVICE = "add_device"
|
||||
CONF_EDIT_DEVICE = "edit_device"
|
||||
CONF_SETUP_CLOUD = "setup_cloud"
|
||||
CONF_NO_CLOUD = "no_cloud"
|
||||
CONF_MANUAL_DPS = "manual_dps_strings"
|
||||
CONF_DEFAULT_VALUE = "dps_default_value"
|
||||
CONF_RESET_DPIDS = "reset_dpids"
|
||||
CONF_PASSIVE_ENTITY = "is_passive_entity"
|
||||
|
||||
# light
|
||||
CONF_BRIGHTNESS_LOWER = "brightness_lower"
|
||||
CONF_BRIGHTNESS_UPPER = "brightness_upper"
|
||||
CONF_COLOR = "color"
|
||||
CONF_COLOR_MODE = "color_mode"
|
||||
CONF_COLOR_MODE_SET = "color_mode_set"
|
||||
CONF_COLOR_TEMP_MIN_KELVIN = "color_temp_min_kelvin"
|
||||
CONF_COLOR_TEMP_MAX_KELVIN = "color_temp_max_kelvin"
|
||||
CONF_COLOR_TEMP_REVERSE = "color_temp_reverse"
|
||||
CONF_MUSIC_MODE = "music_mode"
|
||||
|
||||
# switch
|
||||
CONF_CURRENT = "current"
|
||||
CONF_CURRENT_CONSUMPTION = "current_consumption"
|
||||
CONF_VOLTAGE = "voltage"
|
||||
|
||||
# cover
|
||||
CONF_COMMANDS_SET = "commands_set"
|
||||
CONF_POSITIONING_MODE = "positioning_mode"
|
||||
CONF_CURRENT_POSITION_DP = "current_position_dp"
|
||||
CONF_SET_POSITION_DP = "set_position_dp"
|
||||
CONF_POSITION_INVERTED = "position_inverted"
|
||||
CONF_SPAN_TIME = "span_time"
|
||||
|
||||
# fan
|
||||
CONF_FAN_SPEED_CONTROL = "fan_speed_control"
|
||||
CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control"
|
||||
CONF_FAN_SPEED_MIN = "fan_speed_min"
|
||||
CONF_FAN_SPEED_MAX = "fan_speed_max"
|
||||
CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list"
|
||||
CONF_FAN_DIRECTION = "fan_direction"
|
||||
CONF_FAN_DIRECTION_FWD = "fan_direction_forward"
|
||||
CONF_FAN_DIRECTION_REV = "fan_direction_reverse"
|
||||
CONF_FAN_DPS_TYPE = "fan_dps_type"
|
||||
|
||||
# sensor
|
||||
CONF_SCALING = "scaling"
|
||||
|
||||
# climate
|
||||
CONF_TARGET_TEMPERATURE_DP = "target_temperature_dp"
|
||||
CONF_CURRENT_TEMPERATURE_DP = "current_temperature_dp"
|
||||
CONF_TEMPERATURE_STEP = "temperature_step"
|
||||
CONF_MAX_TEMP_DP = "max_temperature_dp"
|
||||
CONF_MIN_TEMP_DP = "min_temperature_dp"
|
||||
CONF_TEMP_MAX = "max_temperature_const"
|
||||
CONF_TEMP_MIN = "min_temperature_const"
|
||||
CONF_PRECISION = "precision"
|
||||
CONF_TARGET_PRECISION = "target_precision"
|
||||
CONF_HVAC_MODE_DP = "hvac_mode_dp"
|
||||
CONF_HVAC_MODE_SET = "hvac_mode_set"
|
||||
CONF_HVAC_FAN_MODE_DP = "hvac_fan_mode_dp"
|
||||
CONF_HVAC_FAN_MODE_SET = "hvac_fan_mode_set"
|
||||
CONF_HVAC_SWING_MODE_DP = "hvac_swing_mode_dp"
|
||||
CONF_HVAC_SWING_MODE_SET = "hvac_swing_mode_set"
|
||||
CONF_PRESET_DP = "preset_dp"
|
||||
CONF_PRESET_SET = "preset_set"
|
||||
CONF_HEURISTIC_ACTION = "heuristic_action"
|
||||
CONF_HVAC_ACTION_DP = "hvac_action_dp"
|
||||
CONF_HVAC_ACTION_SET = "hvac_action_set"
|
||||
CONF_ECO_DP = "eco_dp"
|
||||
CONF_ECO_VALUE = "eco_value"
|
||||
|
||||
# vacuum
|
||||
CONF_POWERGO_DP = "powergo_dp"
|
||||
CONF_IDLE_STATUS_VALUE = "idle_status_value"
|
||||
CONF_RETURNING_STATUS_VALUE = "returning_status_value"
|
||||
CONF_DOCKED_STATUS_VALUE = "docked_status_value"
|
||||
CONF_BATTERY_DP = "battery_dp"
|
||||
CONF_MODE_DP = "mode_dp"
|
||||
CONF_MODES = "modes"
|
||||
CONF_FAN_SPEED_DP = "fan_speed_dp"
|
||||
CONF_FAN_SPEEDS = "fan_speeds"
|
||||
CONF_CLEAN_TIME_DP = "clean_time_dp"
|
||||
CONF_CLEAN_AREA_DP = "clean_area_dp"
|
||||
CONF_CLEAN_RECORD_DP = "clean_record_dp"
|
||||
CONF_LOCATE_DP = "locate_dp"
|
||||
CONF_FAULT_DP = "fault_dp"
|
||||
CONF_PAUSED_STATE = "paused_state"
|
||||
CONF_RETURN_MODE = "return_mode"
|
||||
CONF_STOP_STATUS = "stop_status"
|
||||
|
||||
# number
|
||||
CONF_MIN_VALUE = "min_value"
|
||||
CONF_MAX_VALUE = "max_value"
|
||||
CONF_STEPSIZE_VALUE = "step_size"
|
||||
|
||||
# select
|
||||
CONF_OPTIONS = "select_options"
|
||||
CONF_OPTIONS_FRIENDLY = "select_options_friendly"
|
||||
|
||||
# States
|
||||
ATTR_STATE = "raw_state"
|
||||
CONF_RESTORE_ON_RECONNECT = "restore_on_reconnect"
|
||||
233
homeassistant/config/custom_components/localtuya/cover.py
Normal file
233
homeassistant/config/custom_components/localtuya/cover.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Platform to locally control Tuya-based cover devices."""
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from functools import partial
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
DOMAIN,
|
||||
CoverEntity, CoverEntityFeature,
|
||||
)
|
||||
|
||||
from .common import LocalTuyaEntity, async_setup_entry
|
||||
from .const import (
|
||||
CONF_COMMANDS_SET,
|
||||
CONF_CURRENT_POSITION_DP,
|
||||
CONF_POSITION_INVERTED,
|
||||
CONF_POSITIONING_MODE,
|
||||
CONF_SET_POSITION_DP,
|
||||
CONF_SPAN_TIME,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
COVER_ONOFF_CMDS = "on_off_stop"
|
||||
COVER_OPENCLOSE_CMDS = "open_close_stop"
|
||||
COVER_FZZZ_CMDS = "fz_zz_stop"
|
||||
COVER_12_CMDS = "1_2_3"
|
||||
COVER_MODE_NONE = "none"
|
||||
COVER_MODE_POSITION = "position"
|
||||
COVER_MODE_TIMED = "timed"
|
||||
COVER_TIMEOUT_TOLERANCE = 3.0
|
||||
|
||||
DEFAULT_COMMANDS_SET = COVER_ONOFF_CMDS
|
||||
DEFAULT_POSITIONING_MODE = COVER_MODE_NONE
|
||||
DEFAULT_SPAN_TIME = 25.0
|
||||
|
||||
|
||||
def flow_schema(dps):
|
||||
"""Return schema used in config flow."""
|
||||
return {
|
||||
vol.Optional(CONF_COMMANDS_SET): vol.In(
|
||||
[COVER_ONOFF_CMDS, COVER_OPENCLOSE_CMDS, COVER_FZZZ_CMDS, COVER_12_CMDS]
|
||||
),
|
||||
vol.Optional(CONF_POSITIONING_MODE, default=DEFAULT_POSITIONING_MODE): vol.In(
|
||||
[COVER_MODE_NONE, COVER_MODE_POSITION, COVER_MODE_TIMED]
|
||||
),
|
||||
vol.Optional(CONF_CURRENT_POSITION_DP): vol.In(dps),
|
||||
vol.Optional(CONF_SET_POSITION_DP): vol.In(dps),
|
||||
vol.Optional(CONF_POSITION_INVERTED, default=False): bool,
|
||||
vol.Optional(CONF_SPAN_TIME, default=DEFAULT_SPAN_TIME): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=1.0, max=300.0)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class LocaltuyaCover(LocalTuyaEntity, CoverEntity):
|
||||
"""Tuya cover device."""
|
||||
|
||||
def __init__(self, device, config_entry, switchid, **kwargs):
|
||||
"""Initialize a new LocaltuyaCover."""
|
||||
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
|
||||
commands_set = DEFAULT_COMMANDS_SET
|
||||
if self.has_config(CONF_COMMANDS_SET):
|
||||
commands_set = self._config[CONF_COMMANDS_SET]
|
||||
self._open_cmd = commands_set.split("_")[0]
|
||||
self._close_cmd = commands_set.split("_")[1]
|
||||
self._stop_cmd = commands_set.split("_")[2]
|
||||
self._timer_start = time.time()
|
||||
self._state = self._stop_cmd
|
||||
self._previous_state = self._state
|
||||
self._current_cover_position = 0
|
||||
_LOGGER.debug("Initialized cover [%s]", self.name)
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
|
||||
if self._config[CONF_POSITIONING_MODE] != COVER_MODE_NONE:
|
||||
supported_features = supported_features | CoverEntityFeature.SET_POSITION
|
||||
return supported_features
|
||||
|
||||
@property
|
||||
def current_cover_position(self):
|
||||
"""Return current cover position in percent."""
|
||||
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE:
|
||||
return None
|
||||
return self._current_cover_position
|
||||
|
||||
@property
|
||||
def is_opening(self):
|
||||
"""Return if cover is opening."""
|
||||
state = self._state
|
||||
return state == self._open_cmd
|
||||
|
||||
@property
|
||||
def is_closing(self):
|
||||
"""Return if cover is closing."""
|
||||
state = self._state
|
||||
return state == self._close_cmd
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Return if the cover is closed or not."""
|
||||
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_NONE:
|
||||
return False
|
||||
|
||||
if self._current_cover_position == 0:
|
||||
return True
|
||||
if self._current_cover_position == 100:
|
||||
return False
|
||||
return False
|
||||
|
||||
async def async_set_cover_position(self, **kwargs):
|
||||
"""Move the cover to a specific position."""
|
||||
self.debug("Setting cover position: %r", kwargs[ATTR_POSITION])
|
||||
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
|
||||
newpos = float(kwargs[ATTR_POSITION])
|
||||
|
||||
currpos = self.current_cover_position
|
||||
posdiff = abs(newpos - currpos)
|
||||
mydelay = posdiff / 100.0 * self._config[CONF_SPAN_TIME]
|
||||
if newpos > currpos:
|
||||
self.debug("Opening to %f: delay %f", newpos, mydelay)
|
||||
await self.async_open_cover()
|
||||
else:
|
||||
self.debug("Closing to %f: delay %f", newpos, mydelay)
|
||||
await self.async_close_cover()
|
||||
self.hass.async_create_task(self.async_stop_after_timeout(mydelay))
|
||||
self.debug("Done")
|
||||
|
||||
elif self._config[CONF_POSITIONING_MODE] == COVER_MODE_POSITION:
|
||||
converted_position = int(kwargs[ATTR_POSITION])
|
||||
if self._config[CONF_POSITION_INVERTED]:
|
||||
converted_position = 100 - converted_position
|
||||
|
||||
if 0 <= converted_position <= 100 and self.has_config(CONF_SET_POSITION_DP):
|
||||
await self._device.set_dp(
|
||||
converted_position, self._config[CONF_SET_POSITION_DP]
|
||||
)
|
||||
|
||||
async def async_stop_after_timeout(self, delay_sec):
|
||||
"""Stop the cover if timeout (max movement span) occurred."""
|
||||
await asyncio.sleep(delay_sec)
|
||||
await self.async_stop_cover()
|
||||
|
||||
async def async_open_cover(self, **kwargs):
|
||||
"""Open the cover."""
|
||||
self.debug("Launching command %s to cover ", self._open_cmd)
|
||||
await self._device.set_dp(self._open_cmd, self._dp_id)
|
||||
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
|
||||
# for timed positioning, stop the cover after a full opening timespan
|
||||
# instead of waiting the internal timeout
|
||||
self.hass.async_create_task(
|
||||
self.async_stop_after_timeout(
|
||||
self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE
|
||||
)
|
||||
)
|
||||
|
||||
async def async_close_cover(self, **kwargs):
|
||||
"""Close cover."""
|
||||
self.debug("Launching command %s to cover ", self._close_cmd)
|
||||
await self._device.set_dp(self._close_cmd, self._dp_id)
|
||||
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
|
||||
# for timed positioning, stop the cover after a full opening timespan
|
||||
# instead of waiting the internal timeout
|
||||
self.hass.async_create_task(
|
||||
self.async_stop_after_timeout(
|
||||
self._config[CONF_SPAN_TIME] + COVER_TIMEOUT_TOLERANCE
|
||||
)
|
||||
)
|
||||
|
||||
async def async_stop_cover(self, **kwargs):
|
||||
"""Stop the cover."""
|
||||
self.debug("Launching command %s to cover ", self._stop_cmd)
|
||||
await self._device.set_dp(self._stop_cmd, self._dp_id)
|
||||
|
||||
def status_restored(self, stored_state):
|
||||
"""Restore the last stored cover status."""
|
||||
if self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED:
|
||||
stored_pos = stored_state.attributes.get("current_position")
|
||||
if stored_pos is not None:
|
||||
self._current_cover_position = stored_pos
|
||||
self.debug("Restored cover position %s", self._current_cover_position)
|
||||
|
||||
def status_updated(self):
|
||||
"""Device status was updated."""
|
||||
self._previous_state = self._state
|
||||
self._state = self.dps(self._dp_id)
|
||||
if self._state.isupper():
|
||||
self._open_cmd = self._open_cmd.upper()
|
||||
self._close_cmd = self._close_cmd.upper()
|
||||
self._stop_cmd = self._stop_cmd.upper()
|
||||
|
||||
if self.has_config(CONF_CURRENT_POSITION_DP):
|
||||
curr_pos = self.dps_conf(CONF_CURRENT_POSITION_DP)
|
||||
if self._config[CONF_POSITION_INVERTED]:
|
||||
self._current_cover_position = 100 - curr_pos
|
||||
else:
|
||||
self._current_cover_position = curr_pos
|
||||
if (
|
||||
self._config[CONF_POSITIONING_MODE] == COVER_MODE_TIMED
|
||||
and self._state != self._previous_state
|
||||
):
|
||||
if self._previous_state != self._stop_cmd:
|
||||
# the state has changed, and the cover was moving
|
||||
time_diff = time.time() - self._timer_start
|
||||
pos_diff = round(time_diff / self._config[CONF_SPAN_TIME] * 100.0)
|
||||
if self._previous_state == self._close_cmd:
|
||||
pos_diff = -pos_diff
|
||||
self._current_cover_position = min(
|
||||
100, max(0, self._current_cover_position + pos_diff)
|
||||
)
|
||||
|
||||
change = "stopped" if self._state == self._stop_cmd else "inverted"
|
||||
self.debug(
|
||||
"Movement %s after %s sec., position difference %s",
|
||||
change,
|
||||
time_diff,
|
||||
pos_diff,
|
||||
)
|
||||
|
||||
# store the time of the last movement change
|
||||
self._timer_start = time.time()
|
||||
|
||||
# Keep record in last_state as long as not during connection/re-connection,
|
||||
# as last state will be used to restore the previous state
|
||||
if (self._state is not None) and (not self._device.is_connecting):
|
||||
self._last_state = self._state
|
||||
|
||||
|
||||
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Diagnostics support for LocalTuya."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DEVICES
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from .const import CONF_LOCAL_KEY, CONF_USER_ID, DATA_CLOUD, DOMAIN
|
||||
|
||||
CLOUD_DEVICES = "cloud_devices"
|
||||
DEVICE_CONFIG = "device_config"
|
||||
DEVICE_CLOUD_INFO = "device_cloud_info"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
data = {}
|
||||
data = dict(entry.data)
|
||||
tuya_api = hass.data[DOMAIN][DATA_CLOUD]
|
||||
# censoring private information on integration diagnostic data
|
||||
for field in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]:
|
||||
data[field] = f"{data[field][0:3]}...{data[field][-3:]}"
|
||||
data[CONF_DEVICES] = copy.deepcopy(entry.data[CONF_DEVICES])
|
||||
for dev_id, dev in data[CONF_DEVICES].items():
|
||||
local_key = dev[CONF_LOCAL_KEY]
|
||||
local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}"
|
||||
dev[CONF_LOCAL_KEY] = local_key_obfuscated
|
||||
data[CLOUD_DEVICES] = tuya_api.device_list
|
||||
for dev_id, dev in data[CLOUD_DEVICES].items():
|
||||
local_key = data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY]
|
||||
local_key_obfuscated = f"{local_key[0:3]}...{local_key[-3:]}"
|
||||
data[CLOUD_DEVICES][dev_id][CONF_LOCAL_KEY] = local_key_obfuscated
|
||||
return data
|
||||
|
||||
|
||||
async def async_get_device_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a device entry."""
|
||||
data = {}
|
||||
dev_id = list(device.identifiers)[0][1].split("_")[-1]
|
||||
data[DEVICE_CONFIG] = entry.data[CONF_DEVICES][dev_id].copy()
|
||||
# NOT censoring private information on device diagnostic data
|
||||
# local_key = data[DEVICE_CONFIG][CONF_LOCAL_KEY]
|
||||
# data[DEVICE_CONFIG][CONF_LOCAL_KEY] = f"{local_key[0:3]}...{local_key[-3:]}"
|
||||
|
||||
tuya_api = hass.data[DOMAIN][DATA_CLOUD]
|
||||
if dev_id in tuya_api.device_list:
|
||||
data[DEVICE_CLOUD_INFO] = tuya_api.device_list[dev_id]
|
||||
# NOT censoring private information on device diagnostic data
|
||||
# local_key = data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY]
|
||||
# local_key_obfuscated = "{local_key[0:3]}...{local_key[-3:]}"
|
||||
# data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] = local_key_obfuscated
|
||||
|
||||
# data["log"] = hass.data[DOMAIN][CONF_DEVICES][dev_id].logger.retrieve_log()
|
||||
return data
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Discovery module for Tuya devices.
|
||||
|
||||
Entirely based on tuya-convert.py from tuya-convert:
|
||||
|
||||
https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from hashlib import md5
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UDP_KEY = md5(b"yGAdlopoPVldABfn").digest()
|
||||
|
||||
DEFAULT_TIMEOUT = 6.0
|
||||
|
||||
|
||||
def decrypt_udp(message):
|
||||
"""Decrypt encrypted UDP broadcasts."""
|
||||
|
||||
def _unpad(data):
|
||||
return data[: -ord(data[len(data) - 1 :])]
|
||||
|
||||
cipher = Cipher(algorithms.AES(UDP_KEY), modes.ECB(), default_backend())
|
||||
decryptor = cipher.decryptor()
|
||||
return _unpad(decryptor.update(message) + decryptor.finalize()).decode()
|
||||
|
||||
|
||||
class TuyaDiscovery(asyncio.DatagramProtocol):
|
||||
"""Datagram handler listening for Tuya broadcast messages."""
|
||||
|
||||
def __init__(self, callback=None):
|
||||
"""Initialize a new BaseDiscovery."""
|
||||
self.devices = {}
|
||||
self._listeners = []
|
||||
self._callback = callback
|
||||
|
||||
async def start(self):
|
||||
"""Start discovery by listening to broadcasts."""
|
||||
loop = asyncio.get_running_loop()
|
||||
listener = loop.create_datagram_endpoint(
|
||||
lambda: self, local_addr=("0.0.0.0", 6666), reuse_port=True
|
||||
)
|
||||
encrypted_listener = loop.create_datagram_endpoint(
|
||||
lambda: self, local_addr=("0.0.0.0", 6667), reuse_port=True
|
||||
)
|
||||
|
||||
self._listeners = await asyncio.gather(listener, encrypted_listener)
|
||||
_LOGGER.debug("Listening to broadcasts on UDP port 6666 and 6667")
|
||||
|
||||
def close(self):
|
||||
"""Stop discovery."""
|
||||
self._callback = None
|
||||
for transport, _ in self._listeners:
|
||||
transport.close()
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
"""Handle received broadcast message."""
|
||||
data = data[20:-8]
|
||||
try:
|
||||
data = decrypt_udp(data)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
data = data.decode()
|
||||
|
||||
decoded = json.loads(data)
|
||||
self.device_found(decoded)
|
||||
|
||||
def device_found(self, device):
|
||||
"""Discover a new device."""
|
||||
if device.get("gwId") not in self.devices:
|
||||
self.devices[device.get("gwId")] = device
|
||||
_LOGGER.debug("Discovered device: %s", device)
|
||||
|
||||
if self._callback:
|
||||
self._callback(device)
|
||||
|
||||
|
||||
async def discover():
|
||||
"""Discover and return devices on local network."""
|
||||
discovery = TuyaDiscovery()
|
||||
try:
|
||||
await discovery.start()
|
||||
await asyncio.sleep(DEFAULT_TIMEOUT)
|
||||
finally:
|
||||
discovery.close()
|
||||
return discovery.devices
|
||||
259
homeassistant/config/custom_components/localtuya/fan.py
Normal file
259
homeassistant/config/custom_components/localtuya/fan.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Platform to locally control Tuya-based fan devices."""
|
||||
import logging
|
||||
import math
|
||||
from functools import partial
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.fan import (
|
||||
DIRECTION_FORWARD,
|
||||
DIRECTION_REVERSE,
|
||||
DOMAIN,
|
||||
FanEntityFeature,
|
||||
FanEntity,
|
||||
)
|
||||
from homeassistant.util.percentage import (
|
||||
int_states_in_range,
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
percentage_to_ranged_value,
|
||||
ranged_value_to_percentage,
|
||||
)
|
||||
|
||||
from .common import LocalTuyaEntity, async_setup_entry
|
||||
from .const import (
|
||||
CONF_FAN_DIRECTION,
|
||||
CONF_FAN_DIRECTION_FWD,
|
||||
CONF_FAN_DIRECTION_REV,
|
||||
CONF_FAN_DPS_TYPE,
|
||||
CONF_FAN_ORDERED_LIST,
|
||||
CONF_FAN_OSCILLATING_CONTROL,
|
||||
CONF_FAN_SPEED_CONTROL,
|
||||
CONF_FAN_SPEED_MAX,
|
||||
CONF_FAN_SPEED_MIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def flow_schema(dps):
|
||||
"""Return schema used in config flow."""
|
||||
return {
|
||||
vol.Optional(CONF_FAN_SPEED_CONTROL): vol.In(dps),
|
||||
vol.Optional(CONF_FAN_OSCILLATING_CONTROL): vol.In(dps),
|
||||
vol.Optional(CONF_FAN_DIRECTION): vol.In(dps),
|
||||
vol.Optional(CONF_FAN_DIRECTION_FWD, default="forward"): cv.string,
|
||||
vol.Optional(CONF_FAN_DIRECTION_REV, default="reverse"): cv.string,
|
||||
vol.Optional(CONF_FAN_SPEED_MIN, default=1): cv.positive_int,
|
||||
vol.Optional(CONF_FAN_SPEED_MAX, default=9): cv.positive_int,
|
||||
vol.Optional(CONF_FAN_ORDERED_LIST, default="disabled"): cv.string,
|
||||
vol.Optional(CONF_FAN_DPS_TYPE, default="str"): vol.In(["str", "int"]),
|
||||
}
|
||||
|
||||
|
||||
class LocaltuyaFan(LocalTuyaEntity, FanEntity):
|
||||
"""Representation of a Tuya fan."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device,
|
||||
config_entry,
|
||||
fanid,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the entity."""
|
||||
super().__init__(device, config_entry, fanid, _LOGGER, **kwargs)
|
||||
self._is_on = False
|
||||
self._oscillating = None
|
||||
self._direction = None
|
||||
self._percentage = None
|
||||
self._speed_range = (
|
||||
self._config.get(CONF_FAN_SPEED_MIN),
|
||||
self._config.get(CONF_FAN_SPEED_MAX),
|
||||
)
|
||||
self._ordered_list = self._config.get(CONF_FAN_ORDERED_LIST).split(",")
|
||||
self._ordered_list_mode = None
|
||||
self._dps_type = int if self._config.get(CONF_FAN_DPS_TYPE) == "int" else str
|
||||
|
||||
if isinstance(self._ordered_list, list) and len(self._ordered_list) > 1:
|
||||
self._use_ordered_list = True
|
||||
_LOGGER.debug(
|
||||
"Fan _use_ordered_list: %s > %s",
|
||||
self._use_ordered_list,
|
||||
self._ordered_list,
|
||||
)
|
||||
else:
|
||||
self._use_ordered_list = False
|
||||
_LOGGER.debug("Fan _use_ordered_list: %s", self._use_ordered_list)
|
||||
|
||||
@property
|
||||
def oscillating(self):
|
||||
"""Return current oscillating status."""
|
||||
return self._oscillating
|
||||
|
||||
@property
|
||||
def current_direction(self):
|
||||
"""Return the current direction of the fan."""
|
||||
return self._direction
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Check if Tuya fan is on."""
|
||||
return self._is_on
|
||||
|
||||
@property
|
||||
def percentage(self):
|
||||
"""Return the current percentage."""
|
||||
return self._percentage
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
speed: str = None,
|
||||
percentage: int = None,
|
||||
preset_mode: str = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Turn on the entity."""
|
||||
_LOGGER.debug("Fan async_turn_on")
|
||||
await self._device.set_dp(True, self._dp_id)
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
else:
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""Turn off the entity."""
|
||||
_LOGGER.debug("Fan async_turn_off")
|
||||
|
||||
await self._device.set_dp(False, self._dp_id)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_set_percentage(self, percentage):
|
||||
"""Set the speed of the fan."""
|
||||
_LOGGER.debug("Fan async_set_percentage: %s", percentage)
|
||||
|
||||
if percentage is not None:
|
||||
if percentage == 0:
|
||||
return await self.async_turn_off()
|
||||
if not self.is_on:
|
||||
await self.async_turn_on()
|
||||
if self._use_ordered_list:
|
||||
await self._device.set_dp(
|
||||
self._dps_type(
|
||||
percentage_to_ordered_list_item(self._ordered_list, percentage)
|
||||
),
|
||||
self._config.get(CONF_FAN_SPEED_CONTROL),
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Fan async_set_percentage: %s > %s",
|
||||
percentage,
|
||||
percentage_to_ordered_list_item(self._ordered_list, percentage),
|
||||
)
|
||||
|
||||
else:
|
||||
await self._device.set_dp(
|
||||
self._dps_type(
|
||||
math.ceil(
|
||||
percentage_to_ranged_value(self._speed_range, percentage)
|
||||
)
|
||||
),
|
||||
self._config.get(CONF_FAN_SPEED_CONTROL),
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Fan async_set_percentage: %s > %s",
|
||||
percentage,
|
||||
percentage_to_ranged_value(self._speed_range, percentage),
|
||||
)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_oscillate(self, oscillating: bool) -> None:
|
||||
"""Set oscillation."""
|
||||
_LOGGER.debug("Fan async_oscillate: %s", oscillating)
|
||||
await self._device.set_dp(
|
||||
oscillating, self._config.get(CONF_FAN_OSCILLATING_CONTROL)
|
||||
)
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_set_direction(self, direction):
|
||||
"""Set the direction of the fan."""
|
||||
_LOGGER.debug("Fan async_set_direction: %s", direction)
|
||||
|
||||
if direction == DIRECTION_FORWARD:
|
||||
value = self._config.get(CONF_FAN_DIRECTION_FWD)
|
||||
|
||||
if direction == DIRECTION_REVERSE:
|
||||
value = self._config.get(CONF_FAN_DIRECTION_REV)
|
||||
await self._device.set_dp(value, self._config.get(CONF_FAN_DIRECTION))
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def supported_features(self) -> FanEntityFeature:
|
||||
"""Flag supported features."""
|
||||
features = FanEntityFeature(0)
|
||||
|
||||
if self.has_config(CONF_FAN_OSCILLATING_CONTROL):
|
||||
features |= FanEntityFeature.OSCILLATE
|
||||
|
||||
if self.has_config(CONF_FAN_SPEED_CONTROL):
|
||||
features |= FanEntityFeature.SET_SPEED
|
||||
|
||||
if self.has_config(CONF_FAN_DIRECTION):
|
||||
features |= FanEntityFeature.DIRECTION
|
||||
|
||||
features |= FanEntityFeature.TURN_OFF
|
||||
features |= FanEntityFeature.TURN_ON
|
||||
|
||||
return features
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
"""Speed count for the fan."""
|
||||
speed_count = int_states_in_range(self._speed_range)
|
||||
_LOGGER.debug("Fan speed_count: %s", speed_count)
|
||||
return speed_count
|
||||
|
||||
def status_updated(self):
|
||||
"""Get state of Tuya fan."""
|
||||
self._is_on = self.dps(self._dp_id)
|
||||
|
||||
current_speed = self.dps_conf(CONF_FAN_SPEED_CONTROL)
|
||||
if self._use_ordered_list:
|
||||
_LOGGER.debug(
|
||||
"Fan current_speed ordered_list_item_to_percentage: %s from %s",
|
||||
current_speed,
|
||||
self._ordered_list,
|
||||
)
|
||||
if current_speed is not None:
|
||||
self._percentage = ordered_list_item_to_percentage(
|
||||
self._ordered_list, str(current_speed)
|
||||
)
|
||||
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Fan current_speed ranged_value_to_percentage: %s from %s",
|
||||
current_speed,
|
||||
self._speed_range,
|
||||
)
|
||||
if current_speed is not None:
|
||||
self._percentage = ranged_value_to_percentage(
|
||||
self._speed_range, int(current_speed)
|
||||
)
|
||||
|
||||
_LOGGER.debug("Fan current_percentage: %s", self._percentage)
|
||||
|
||||
if self.has_config(CONF_FAN_OSCILLATING_CONTROL):
|
||||
self._oscillating = self.dps_conf(CONF_FAN_OSCILLATING_CONTROL)
|
||||
_LOGGER.debug("Fan current_oscillating : %s", self._oscillating)
|
||||
|
||||
if self.has_config(CONF_FAN_DIRECTION):
|
||||
value = self.dps_conf(CONF_FAN_DIRECTION)
|
||||
if value is not None:
|
||||
if value == self._config.get(CONF_FAN_DIRECTION_FWD):
|
||||
self._direction = DIRECTION_FORWARD
|
||||
|
||||
if value == self._config.get(CONF_FAN_DIRECTION_REV):
|
||||
self._direction = DIRECTION_REVERSE
|
||||
_LOGGER.debug("Fan current_direction : %s > %s", value, self._direction)
|
||||
|
||||
|
||||
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaFan, flow_schema)
|
||||
506
homeassistant/config/custom_components/localtuya/light.py
Normal file
506
homeassistant/config/custom_components/localtuya/light.py
Normal file
@@ -0,0 +1,506 @@
|
||||
"""Platform to locally control Tuya-based light devices."""
|
||||
import logging
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
|
||||
import homeassistant.util.color as color_util
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_EFFECT,
|
||||
ATTR_HS_COLOR,
|
||||
DOMAIN,
|
||||
LightEntity,
|
||||
LightEntityFeature,
|
||||
ColorMode,
|
||||
)
|
||||
from homeassistant.const import CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_SCENE
|
||||
|
||||
from .common import LocalTuyaEntity, async_setup_entry
|
||||
from .const import (
|
||||
CONF_BRIGHTNESS_LOWER,
|
||||
CONF_BRIGHTNESS_UPPER,
|
||||
CONF_COLOR,
|
||||
CONF_COLOR_MODE,
|
||||
CONF_COLOR_TEMP_MAX_KELVIN,
|
||||
CONF_COLOR_TEMP_MIN_KELVIN,
|
||||
CONF_COLOR_TEMP_REVERSE,
|
||||
CONF_MUSIC_MODE, CONF_COLOR_MODE_SET,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MIN_KELVIN = 2700 # MIRED 370
|
||||
DEFAULT_MAX_KELVIN = 6500 # MIRED 153
|
||||
|
||||
DEFAULT_COLOR_TEMP_REVERSE = False
|
||||
|
||||
DEFAULT_LOWER_BRIGHTNESS = 29
|
||||
DEFAULT_UPPER_BRIGHTNESS = 1000
|
||||
|
||||
MODE_MANUAL = "manual"
|
||||
MODE_COLOR = "colour"
|
||||
MODE_MUSIC = "music"
|
||||
MODE_SCENE = "scene"
|
||||
MODE_WHITE = "white"
|
||||
|
||||
SCENE_CUSTOM = "Custom"
|
||||
SCENE_MUSIC = "Music"
|
||||
|
||||
MODES_SET = {"Colour, Music, Scene and White": 0, "Manual, Music, Scene and White": 1}
|
||||
|
||||
SCENE_LIST_RGBW_1000 = {
|
||||
"Night": "000e0d0000000000000000c80000",
|
||||
"Read": "010e0d0000000000000003e801f4",
|
||||
"Meeting": "020e0d0000000000000003e803e8",
|
||||
"Leasure": "030e0d0000000000000001f401f4",
|
||||
"Soft": "04464602007803e803e800000000464602007803e8000a00000000",
|
||||
"Rainbow": "05464601000003e803e800000000464601007803e803e80000000046460100f003e803"
|
||||
+ "e800000000",
|
||||
"Shine": "06464601000003e803e800000000464601007803e803e80000000046460100f003e803e8"
|
||||
+ "00000000",
|
||||
"Beautiful": "07464602000003e803e800000000464602007803e803e80000000046460200f003e8"
|
||||
+ "03e800000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e80"
|
||||
+ "3e800000000",
|
||||
}
|
||||
|
||||
SCENE_LIST_RGBW_255 = {
|
||||
"Night": "bd76000168ffff",
|
||||
"Read": "fffcf70168ffff",
|
||||
"Meeting": "cf38000168ffff",
|
||||
"Leasure": "3855b40168ffff",
|
||||
"Scenario 1": "scene_1",
|
||||
"Scenario 2": "scene_2",
|
||||
"Scenario 3": "scene_3",
|
||||
"Scenario 4": "scene_4",
|
||||
}
|
||||
|
||||
SCENE_LIST_RGB_1000 = {
|
||||
"Night": "000e0d00002e03e802cc00000000",
|
||||
"Read": "010e0d000084000003e800000000",
|
||||
"Working": "020e0d00001403e803e800000000",
|
||||
"Leisure": "030e0d0000e80383031c00000000",
|
||||
"Soft": "04464602007803e803e800000000464602007803e8000a00000000",
|
||||
"Colorful": "05464601000003e803e800000000464601007803e803e80000000046460100f003e80"
|
||||
+ "3e800000000464601003d03e803e80000000046460100ae03e803e800000000464601011303e803"
|
||||
+ "e800000000",
|
||||
"Dazzling": "06464601000003e803e800000000464601007803e803e80000000046460100f003e80"
|
||||
+ "3e800000000",
|
||||
"Music": "07464602000003e803e800000000464602007803e803e80000000046460200f003e803e8"
|
||||
+ "00000000464602003d03e803e80000000046460200ae03e803e800000000464602011303e803e80"
|
||||
+ "0000000",
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Mode:
|
||||
color: str = MODE_COLOR
|
||||
music: str = MODE_MUSIC
|
||||
scene: str = MODE_SCENE
|
||||
white: str = MODE_WHITE
|
||||
|
||||
def as_list(self) -> list:
|
||||
return [self.color, self.music, self.scene, self.white]
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
default = {"Default": self.white}
|
||||
return {**default, "Mode Color": self.color, "Mode Scene": self.scene}
|
||||
|
||||
MAP_MODE_SET = {0: Mode(), 1: Mode(color=MODE_MANUAL)}
|
||||
|
||||
|
||||
def map_range(value, from_lower, from_upper, to_lower, to_upper):
|
||||
"""Map a value in one range to another."""
|
||||
mapped = (value - from_lower) * (to_upper - to_lower) / (
|
||||
from_upper - from_lower
|
||||
) + to_lower
|
||||
return round(min(max(mapped, to_lower), to_upper))
|
||||
|
||||
|
||||
def flow_schema(dps):
|
||||
"""Return schema used in config flow."""
|
||||
return {
|
||||
vol.Optional(CONF_BRIGHTNESS): vol.In(dps),
|
||||
vol.Optional(CONF_COLOR_TEMP): vol.In(dps),
|
||||
vol.Optional(CONF_BRIGHTNESS_LOWER, default=DEFAULT_LOWER_BRIGHTNESS): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=10000)
|
||||
),
|
||||
vol.Optional(CONF_BRIGHTNESS_UPPER, default=DEFAULT_UPPER_BRIGHTNESS): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=10000)
|
||||
),
|
||||
vol.Optional(CONF_COLOR_MODE): vol.In(dps),
|
||||
vol.Optional(CONF_COLOR): vol.In(dps),
|
||||
vol.Optional(CONF_COLOR_TEMP_MIN_KELVIN, default=DEFAULT_MIN_KELVIN): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1500, max=8000)
|
||||
),
|
||||
vol.Optional(CONF_COLOR_TEMP_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1500, max=8000)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_COLOR_TEMP_REVERSE,
|
||||
default=DEFAULT_COLOR_TEMP_REVERSE,
|
||||
description={"suggested_value": DEFAULT_COLOR_TEMP_REVERSE},
|
||||
): bool,
|
||||
vol.Optional(CONF_SCENE): vol.In(dps),
|
||||
vol.Optional(
|
||||
CONF_MUSIC_MODE, default=False, description={"suggested_value": False}
|
||||
): bool,
|
||||
}
|
||||
|
||||
|
||||
class LocaltuyaLight(LocalTuyaEntity, LightEntity):
|
||||
"""Representation of a Tuya light."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device,
|
||||
config_entry,
|
||||
lightid,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the Tuya light."""
|
||||
super().__init__(device, config_entry, lightid, _LOGGER, **kwargs)
|
||||
self._state = False
|
||||
self._brightness = None
|
||||
self._color_temp = None
|
||||
self._lower_brightness = self._config.get(
|
||||
CONF_BRIGHTNESS_LOWER, DEFAULT_LOWER_BRIGHTNESS
|
||||
)
|
||||
self._upper_brightness = self._config.get(
|
||||
CONF_BRIGHTNESS_UPPER, DEFAULT_UPPER_BRIGHTNESS
|
||||
)
|
||||
self._upper_color_temp = self._upper_brightness
|
||||
self._max_mired = color_util.color_temperature_kelvin_to_mired(
|
||||
self._config.get(CONF_COLOR_TEMP_MIN_KELVIN, DEFAULT_MIN_KELVIN)
|
||||
)
|
||||
self._min_mired = color_util.color_temperature_kelvin_to_mired(
|
||||
self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN)
|
||||
)
|
||||
self._color_temp_reverse = self._config.get(
|
||||
CONF_COLOR_TEMP_REVERSE, DEFAULT_COLOR_TEMP_REVERSE
|
||||
)
|
||||
self._modes = MAP_MODE_SET[int(self._config.get(CONF_COLOR_MODE_SET, 0))]
|
||||
self._hs = None
|
||||
self._effect = None
|
||||
self._effect_list = []
|
||||
self._scenes = {}
|
||||
|
||||
if self.has_config(CONF_SCENE):
|
||||
if self._config.get(CONF_SCENE) < 20:
|
||||
self._scenes = SCENE_LIST_RGBW_255
|
||||
elif self._config.get(CONF_BRIGHTNESS) is None:
|
||||
self._scenes = SCENE_LIST_RGB_1000
|
||||
else:
|
||||
self._scenes = SCENE_LIST_RGBW_1000
|
||||
self._effect_list = list(self._scenes.keys())
|
||||
|
||||
if self._config.get(CONF_MUSIC_MODE):
|
||||
self._effect_list.append(SCENE_MUSIC)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Check if Tuya light is on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of the light."""
|
||||
if self.is_color_mode or self.is_white_mode:
|
||||
return map_range(
|
||||
self._brightness, self._lower_brightness, self._upper_brightness, 0, 255
|
||||
)
|
||||
return None
|
||||
|
||||
@property
|
||||
def hs_color(self):
|
||||
"""Return the hs color value."""
|
||||
if self.is_color_mode:
|
||||
return self._hs
|
||||
if (
|
||||
ColorMode.HS in self.supported_color_modes
|
||||
and not ColorMode.COLOR_TEMP in self.supported_color_modes
|
||||
):
|
||||
return [0, 0]
|
||||
return None
|
||||
|
||||
@property
|
||||
def color_temp(self):
|
||||
"""Return the color_temp of the light."""
|
||||
if self.has_config(CONF_COLOR_TEMP) and self.is_white_mode:
|
||||
color_temp_value = (
|
||||
self._upper_color_temp - self._color_temp
|
||||
if self._color_temp_reverse
|
||||
else self._color_temp
|
||||
)
|
||||
return int(
|
||||
self._max_mired
|
||||
- (
|
||||
((self._max_mired - self._min_mired) / self._upper_color_temp)
|
||||
* color_temp_value
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
@property
|
||||
def min_mireds(self):
|
||||
"""Return color temperature min mireds."""
|
||||
return self._min_mired
|
||||
|
||||
@property
|
||||
def max_mireds(self):
|
||||
"""Return color temperature max mireds."""
|
||||
return self._max_mired
|
||||
|
||||
@property
|
||||
def effect(self):
|
||||
"""Return the current effect for this light."""
|
||||
if self.is_scene_mode or self.is_music_mode:
|
||||
return self._effect
|
||||
return None
|
||||
|
||||
@property
|
||||
def effect_list(self):
|
||||
"""Return the list of supported effects for this light."""
|
||||
if self.is_scene_mode or self.is_music_mode:
|
||||
return self._effect
|
||||
elif (color_mode := self.__get_color_mode()) in self._scenes.values():
|
||||
return self.__find_scene_by_scene_data(color_mode)
|
||||
return None
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode] | set[str] | None:
|
||||
"""Flag supported color modes."""
|
||||
color_modes: set[ColorMode] = set()
|
||||
|
||||
if self.has_config(CONF_COLOR_TEMP):
|
||||
color_modes.add(ColorMode.COLOR_TEMP)
|
||||
if self.has_config(CONF_COLOR):
|
||||
color_modes.add(ColorMode.HS)
|
||||
|
||||
if not color_modes and self.has_config(CONF_BRIGHTNESS):
|
||||
return {ColorMode.BRIGHTNESS}
|
||||
|
||||
if not color_modes:
|
||||
return {ColorMode.ONOFF}
|
||||
|
||||
return color_modes
|
||||
|
||||
@property
|
||||
def supported_features(self) -> LightEntityFeature:
|
||||
"""Flag supported features."""
|
||||
supports = LightEntityFeature(0)
|
||||
if self.has_config(CONF_SCENE) or self.has_config(CONF_MUSIC_MODE):
|
||||
supports |= LightEntityFeature.EFFECT
|
||||
return supports
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color_mode of the light."""
|
||||
if len(self.supported_color_modes) == 1:
|
||||
return next(iter(self.supported_color_modes))
|
||||
|
||||
if self.is_color_mode:
|
||||
return ColorMode.HS
|
||||
if self.is_white_mode:
|
||||
return ColorMode.COLOR_TEMP
|
||||
if self._brightness:
|
||||
return ColorMode.BRIGHTNESS
|
||||
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@property
|
||||
def is_white_mode(self):
|
||||
"""Return true if the light is in white mode."""
|
||||
color_mode = self.__get_color_mode()
|
||||
return color_mode is None or color_mode == self._modes.white
|
||||
|
||||
@property
|
||||
def is_color_mode(self):
|
||||
"""Return true if the light is in color mode."""
|
||||
color_mode = self.__get_color_mode()
|
||||
return color_mode is not None and color_mode == self._modes.color
|
||||
|
||||
@property
|
||||
def is_scene_mode(self):
|
||||
"""Return true if the light is in scene mode."""
|
||||
color_mode = self.__get_color_mode()
|
||||
return color_mode is not None and color_mode.startswith(self._modes.scene)
|
||||
|
||||
@property
|
||||
def is_music_mode(self):
|
||||
"""Return true if the light is in music mode."""
|
||||
color_mode = self.__get_color_mode()
|
||||
return color_mode is not None and color_mode == self._modes.music
|
||||
|
||||
def __is_color_rgb_encoded(self):
|
||||
return len(self.dps_conf(CONF_COLOR)) > 12
|
||||
|
||||
def __find_scene_by_scene_data(self, data):
|
||||
return next(
|
||||
(item for item in self._effect_list if self._scenes.get(item) == data),
|
||||
SCENE_CUSTOM,
|
||||
)
|
||||
|
||||
def __get_color_mode(self):
|
||||
return (
|
||||
self.dps_conf(CONF_COLOR_MODE)
|
||||
if self.has_config(CONF_COLOR_MODE)
|
||||
else self._modes.white
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn on or control the light."""
|
||||
states = {}
|
||||
if not self.is_on:
|
||||
states[self._dp_id] = True
|
||||
features = self.supported_features
|
||||
brightness = None
|
||||
if ATTR_EFFECT in kwargs and (features & LightEntityFeature.EFFECT):
|
||||
scene = self._scenes.get(kwargs[ATTR_EFFECT])
|
||||
if scene is not None:
|
||||
if scene.startswith(MODE_SCENE):
|
||||
states[self._config.get(CONF_COLOR_MODE)] = scene
|
||||
else:
|
||||
states[self._config.get(CONF_COLOR_MODE)] = MODE_SCENE
|
||||
states[self._config.get(CONF_SCENE)] = scene
|
||||
elif kwargs[ATTR_EFFECT] == SCENE_MUSIC:
|
||||
states[self._config.get(CONF_COLOR_MODE)] = MODE_MUSIC
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs and (
|
||||
ColorMode.BRIGHTNESS in self.supported_color_modes
|
||||
or self.has_config(CONF_BRIGHTNESS)
|
||||
or self.has_config(CONF_COLOR)
|
||||
):
|
||||
brightness = map_range(
|
||||
int(kwargs[ATTR_BRIGHTNESS]),
|
||||
0,
|
||||
255,
|
||||
self._lower_brightness,
|
||||
self._upper_brightness,
|
||||
)
|
||||
if self.is_white_mode:
|
||||
states[self._config.get(CONF_BRIGHTNESS)] = brightness
|
||||
else:
|
||||
if self.__is_color_rgb_encoded():
|
||||
rgb = color_util.color_hsv_to_RGB(
|
||||
self._hs[0],
|
||||
self._hs[1],
|
||||
int(brightness * 100 / self._upper_brightness),
|
||||
)
|
||||
color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format(
|
||||
round(rgb[0]),
|
||||
round(rgb[1]),
|
||||
round(rgb[2]),
|
||||
round(self._hs[0]),
|
||||
round(self._hs[1] * 255 / 100),
|
||||
brightness,
|
||||
)
|
||||
else:
|
||||
color = "{:04x}{:04x}{:04x}".format(
|
||||
round(self._hs[0]), round(self._hs[1] * 10.0), brightness
|
||||
)
|
||||
states[self._config.get(CONF_COLOR)] = color
|
||||
states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR
|
||||
|
||||
if ATTR_HS_COLOR in kwargs and ColorMode.HS in self.supported_color_modes:
|
||||
if brightness is None:
|
||||
brightness = self._brightness
|
||||
hs = kwargs[ATTR_HS_COLOR]
|
||||
if hs[1] == 0 and self.has_config(CONF_BRIGHTNESS):
|
||||
states[self._config.get(CONF_BRIGHTNESS)] = brightness
|
||||
states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE
|
||||
else:
|
||||
if self.__is_color_rgb_encoded():
|
||||
rgb = color_util.color_hsv_to_RGB(
|
||||
hs[0], hs[1], int(brightness * 100 / self._upper_brightness)
|
||||
)
|
||||
color = "{:02x}{:02x}{:02x}{:04x}{:02x}{:02x}".format(
|
||||
round(rgb[0]),
|
||||
round(rgb[1]),
|
||||
round(rgb[2]),
|
||||
round(hs[0]),
|
||||
round(hs[1] * 255 / 100),
|
||||
brightness,
|
||||
)
|
||||
else:
|
||||
color = "{:04x}{:04x}{:04x}".format(
|
||||
round(hs[0]), round(hs[1] * 10.0), brightness
|
||||
)
|
||||
states[self._config.get(CONF_COLOR)] = color
|
||||
states[self._config.get(CONF_COLOR_MODE)] = MODE_COLOR
|
||||
|
||||
if ColorMode.COLOR_TEMP in kwargs and ColorMode.COLOR_TEMP in self.supported_color_modes:
|
||||
if brightness is None:
|
||||
brightness = self._brightness
|
||||
mired = int(kwargs[ColorMode.COLOR_TEMP])
|
||||
if self._color_temp_reverse:
|
||||
mired = self._max_mired - (mired - self._min_mired)
|
||||
if mired < self._min_mired:
|
||||
mired = self._min_mired
|
||||
elif mired > self._max_mired:
|
||||
mired = self._max_mired
|
||||
color_temp = int(
|
||||
self._upper_color_temp
|
||||
- (self._upper_color_temp / (self._max_mired - self._min_mired))
|
||||
* (mired - self._min_mired)
|
||||
)
|
||||
states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE
|
||||
states[self._config.get(CONF_BRIGHTNESS)] = brightness
|
||||
states[self._config.get(CONF_COLOR_TEMP)] = color_temp
|
||||
await self._device.set_dps(states)
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn Tuya light off."""
|
||||
await self._device.set_dp(False, self._dp_id)
|
||||
|
||||
def status_updated(self):
|
||||
"""Device status was updated."""
|
||||
self._state = self.dps(self._dp_id)
|
||||
supported = self.supported_features
|
||||
self._effect = None
|
||||
|
||||
if (ColorMode.BRIGHTNESS in self.supported_color_modes
|
||||
or self.has_config(CONF_BRIGHTNESS)
|
||||
or self.has_config(CONF_COLOR)
|
||||
):
|
||||
self._brightness = self.dps_conf(CONF_BRIGHTNESS)
|
||||
|
||||
if ColorMode.HS in self.supported_color_modes:
|
||||
color = self.dps_conf(CONF_COLOR)
|
||||
if color is not None and not self.is_white_mode:
|
||||
if self.__is_color_rgb_encoded():
|
||||
hue = int(color[6:10], 16)
|
||||
sat = int(color[10:12], 16)
|
||||
value = int(color[12:14], 16)
|
||||
self._hs = [hue, (sat * 100 / 255)]
|
||||
self._brightness = value
|
||||
else:
|
||||
hue, sat, value = [
|
||||
int(value, 16) for value in textwrap.wrap(color, 4)
|
||||
]
|
||||
self._hs = [hue, sat / 10.0]
|
||||
self._brightness = value
|
||||
|
||||
if ColorMode.COLOR_TEMP in self.supported_color_modes:
|
||||
self._color_temp = self.dps_conf(CONF_COLOR_TEMP)
|
||||
|
||||
if self.is_scene_mode and supported & LightEntityFeature.EFFECT:
|
||||
if self.dps_conf(CONF_COLOR_MODE) != MODE_SCENE:
|
||||
self._effect = self.__find_scene_by_scene_data(
|
||||
self.dps_conf(CONF_COLOR_MODE)
|
||||
)
|
||||
else:
|
||||
self._effect = self.__find_scene_by_scene_data(
|
||||
self.dps_conf(CONF_SCENE)
|
||||
)
|
||||
if self._effect == SCENE_CUSTOM:
|
||||
if SCENE_CUSTOM not in self._effect_list:
|
||||
self._effect_list.append(SCENE_CUSTOM)
|
||||
elif SCENE_CUSTOM in self._effect_list:
|
||||
self._effect_list.remove(SCENE_CUSTOM)
|
||||
|
||||
if self.is_music_mode and supported & LightEntityFeature.EFFECT:
|
||||
self._effect = SCENE_MUSIC
|
||||
|
||||
|
||||
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaLight, flow_schema)
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"domain": "localtuya",
|
||||
"name": "LocalTuya integration",
|
||||
"codeowners": [
|
||||
"@rospogrigio", "@postlund"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/rospogrigio/localtuya/",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/rospogrigio/localtuya/issues",
|
||||
"requirements": [],
|
||||
"version": "5.2.3"
|
||||
}
|
||||
113
homeassistant/config/custom_components/localtuya/number.py
Normal file
113
homeassistant/config/custom_components/localtuya/number.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Platform to present any Tuya DP as a number."""
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.number import DOMAIN, NumberEntity
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN
|
||||
|
||||
from .common import LocalTuyaEntity, async_setup_entry
|
||||
from .const import (
|
||||
CONF_DEFAULT_VALUE,
|
||||
CONF_MAX_VALUE,
|
||||
CONF_MIN_VALUE,
|
||||
CONF_PASSIVE_ENTITY,
|
||||
CONF_RESTORE_ON_RECONNECT,
|
||||
CONF_STEPSIZE_VALUE,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MIN = 0
|
||||
DEFAULT_MAX = 100000
|
||||
DEFAULT_STEP = 1.0
|
||||
|
||||
|
||||
def flow_schema(dps):
|
||||
"""Return schema used in config flow."""
|
||||
return {
|
||||
vol.Optional(CONF_MIN_VALUE, default=DEFAULT_MIN): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=-1000000.0, max=1000000.0),
|
||||
),
|
||||
vol.Required(CONF_MAX_VALUE, default=DEFAULT_MAX): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=-1000000.0, max=1000000.0),
|
||||
),
|
||||
vol.Required(CONF_STEPSIZE_VALUE, default=DEFAULT_STEP): vol.All(
|
||||
vol.Coerce(float),
|
||||
vol.Range(min=0.0, max=1000000.0),
|
||||
),
|
||||
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
|
||||
vol.Required(CONF_PASSIVE_ENTITY): bool,
|
||||
vol.Optional(CONF_DEFAULT_VALUE): str,
|
||||
}
|
||||
|
||||
|
||||
class LocaltuyaNumber(LocalTuyaEntity, NumberEntity):
|
||||
"""Representation of a Tuya Number."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device,
|
||||
config_entry,
|
||||
sensorid,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the Tuya sensor."""
|
||||
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
|
||||
self._state = STATE_UNKNOWN
|
||||
|
||||
self._min_value = DEFAULT_MIN
|
||||
if CONF_MIN_VALUE in self._config:
|
||||
self._min_value = self._config.get(CONF_MIN_VALUE)
|
||||
|
||||
self._max_value = DEFAULT_MAX
|
||||
if CONF_MAX_VALUE in self._config:
|
||||
self._max_value = self._config.get(CONF_MAX_VALUE)
|
||||
|
||||
self._step_size = DEFAULT_STEP
|
||||
if CONF_STEPSIZE_VALUE in self._config:
|
||||
self._step_size = self._config.get(CONF_STEPSIZE_VALUE)
|
||||
|
||||
# Override standard default value handling to cast to a float
|
||||
default_value = self._config.get(CONF_DEFAULT_VALUE)
|
||||
if default_value is not None:
|
||||
self._default_value = float(default_value)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float:
|
||||
"""Return sensor state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def native_min_value(self) -> float:
|
||||
"""Return the minimum value."""
|
||||
return self._min_value
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return the maximum value."""
|
||||
return self._max_value
|
||||
|
||||
@property
|
||||
def native_step(self) -> float:
|
||||
"""Return the maximum value."""
|
||||
return self._step_size
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this device."""
|
||||
return self._config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update the current value."""
|
||||
await self._device.set_dp(value, self._dp_id)
|
||||
|
||||
# Default value is the minimum value
|
||||
def entity_default_value(self):
|
||||
"""Return the minimum value as the default for this entity type."""
|
||||
return self._min_value
|
||||
|
||||
|
||||
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema)
|
||||
1196
homeassistant/config/custom_components/localtuya/pytuya/__init__.py
Normal file
1196
homeassistant/config/custom_components/localtuya/pytuya/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
123
homeassistant/config/custom_components/localtuya/select.py
Normal file
123
homeassistant/config/custom_components/localtuya/select.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Platform to present any Tuya DP as an enumeration."""
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.select import DOMAIN, SelectEntity
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN
|
||||
|
||||
from .common import LocalTuyaEntity, async_setup_entry
|
||||
from .const import (
|
||||
CONF_DEFAULT_VALUE,
|
||||
CONF_OPTIONS,
|
||||
CONF_OPTIONS_FRIENDLY,
|
||||
CONF_PASSIVE_ENTITY,
|
||||
CONF_RESTORE_ON_RECONNECT,
|
||||
)
|
||||
|
||||
|
||||
def flow_schema(dps):
|
||||
"""Return schema used in config flow."""
|
||||
return {
|
||||
vol.Required(CONF_OPTIONS): str,
|
||||
vol.Optional(CONF_OPTIONS_FRIENDLY): str,
|
||||
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
|
||||
vol.Required(CONF_PASSIVE_ENTITY): bool,
|
||||
vol.Optional(CONF_DEFAULT_VALUE): str,
|
||||
}
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocaltuyaSelect(LocalTuyaEntity, SelectEntity):
|
||||
"""Representation of a Tuya Enumeration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device,
|
||||
config_entry,
|
||||
sensorid,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the Tuya sensor."""
|
||||
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
|
||||
self._state = STATE_UNKNOWN
|
||||
self._state_friendly = ""
|
||||
self._valid_options = self._config.get(CONF_OPTIONS).split(";")
|
||||
|
||||
# Set Display options
|
||||
self._display_options = []
|
||||
display_options_str = ""
|
||||
if CONF_OPTIONS_FRIENDLY in self._config:
|
||||
display_options_str = self._config.get(CONF_OPTIONS_FRIENDLY).strip()
|
||||
_LOGGER.debug("Display Options Configured: %s", display_options_str)
|
||||
|
||||
if display_options_str.find(";") >= 0:
|
||||
self._display_options = display_options_str.split(";")
|
||||
elif len(display_options_str.strip()) > 0:
|
||||
self._display_options.append(display_options_str)
|
||||
else:
|
||||
# Default display string to raw string
|
||||
_LOGGER.debug("No Display options configured - defaulting to raw values")
|
||||
self._display_options = self._valid_options
|
||||
|
||||
_LOGGER.debug(
|
||||
"Total Raw Options: %s - Total Display Options: %s",
|
||||
str(len(self._valid_options)),
|
||||
str(len(self._display_options)),
|
||||
)
|
||||
if len(self._valid_options) > len(self._display_options):
|
||||
# If list of display items smaller than list of valid items,
|
||||
# then default remaining items to be the raw value
|
||||
_LOGGER.debug(
|
||||
"Valid options is larger than display options - \
|
||||
filling up with raw values"
|
||||
)
|
||||
for i in range(len(self._display_options), len(self._valid_options)):
|
||||
self._display_options.append(self._valid_options[i])
|
||||
|
||||
@property
|
||||
def current_option(self) -> str:
|
||||
"""Return the current value."""
|
||||
return self._state_friendly
|
||||
|
||||
@property
|
||||
def options(self) -> list:
|
||||
"""Return the list of values."""
|
||||
return self._display_options
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this device."""
|
||||
return self._config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Update the current value."""
|
||||
option_value = self._valid_options[self._display_options.index(option)]
|
||||
_LOGGER.debug("Sending Option: " + option + " -> " + option_value)
|
||||
await self._device.set_dp(option_value, self._dp_id)
|
||||
|
||||
def status_updated(self):
|
||||
"""Device status was updated."""
|
||||
super().status_updated()
|
||||
|
||||
state = self.dps(self._dp_id)
|
||||
|
||||
# Check that received status update for this entity.
|
||||
if state is not None:
|
||||
try:
|
||||
self._state_friendly = self._display_options[
|
||||
self._valid_options.index(state)
|
||||
]
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Friendly value couldn't be mapped
|
||||
self._state_friendly = state
|
||||
|
||||
# Default value is the first option
|
||||
def entity_default_value(self):
|
||||
"""Return the first option as the default value for this entity type."""
|
||||
return self._valid_options[0]
|
||||
|
||||
|
||||
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema)
|
||||
75
homeassistant/config/custom_components/localtuya/sensor.py
Normal file
75
homeassistant/config/custom_components/localtuya/sensor.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Platform to present any Tuya DP as a sensor."""
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.sensor import DEVICE_CLASSES, DOMAIN
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
|
||||
from .common import LocalTuyaEntity, async_setup_entry
|
||||
from .const import CONF_SCALING
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PRECISION = 2
|
||||
|
||||
|
||||
def flow_schema(dps):
|
||||
"""Return schema used in config flow."""
|
||||
return {
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): str,
|
||||
vol.Optional(CONF_DEVICE_CLASS): vol.In(DEVICE_CLASSES),
|
||||
vol.Optional(CONF_SCALING): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class LocaltuyaSensor(LocalTuyaEntity):
|
||||
"""Representation of a Tuya sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device,
|
||||
config_entry,
|
||||
sensorid,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the Tuya sensor."""
|
||||
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
|
||||
self._state = STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return sensor state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this device."""
|
||||
return self._config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit of measurement of this entity, if any."""
|
||||
return self._config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
|
||||
def status_updated(self):
|
||||
"""Device status was updated."""
|
||||
state = self.dps(self._dp_id)
|
||||
scale_factor = self._config.get(CONF_SCALING)
|
||||
if scale_factor is not None and isinstance(state, (int, float)):
|
||||
state = round(state * scale_factor, DEFAULT_PRECISION)
|
||||
self._state = state
|
||||
|
||||
# No need to restore state for a sensor
|
||||
async def restore_state_when_connected(self):
|
||||
"""Do nothing for a sensor."""
|
||||
return
|
||||
|
||||
|
||||
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSensor, flow_schema)
|
||||
@@ -0,0 +1,15 @@
|
||||
reload:
|
||||
description: Reload localtuya and reconnect to all devices.
|
||||
|
||||
set_dp:
|
||||
description: Change the value of a datapoint (DP)
|
||||
fields:
|
||||
device_id:
|
||||
description: Device ID of device to change datapoint value for
|
||||
example: 11100118278aab4de001
|
||||
dp:
|
||||
description: Datapoint index
|
||||
example: 1
|
||||
value:
|
||||
description: New value to set
|
||||
example: False
|
||||
139
homeassistant/config/custom_components/localtuya/strings.json
Normal file
139
homeassistant/config/custom_components/localtuya/strings.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device has already been configured.",
|
||||
"unsupported_device_type": "Unsupported device type!"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connect to device. Verify that address is correct.",
|
||||
"invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.",
|
||||
"unknown": "An unknown error occurred. See log for details.",
|
||||
"switch_already_configured": "Switch with this ID has already been configured."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Main Configuration",
|
||||
"description": "Input the credentials for Tuya Cloud API.",
|
||||
"data": {
|
||||
"region": "API server region",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Secret",
|
||||
"user_id": "User ID"
|
||||
}
|
||||
},
|
||||
"power_outlet": {
|
||||
"title": "Add subswitch",
|
||||
"description": "You are about to add subswitch number `{number}`. If you want to add another, tick `Add another switch` before continuing.",
|
||||
"data": {
|
||||
"id": "ID",
|
||||
"name": "Name",
|
||||
"friendly_name": "Friendly name",
|
||||
"current": "Current",
|
||||
"current_consumption": "Current Consumption",
|
||||
"voltage": "Voltage",
|
||||
"add_another_switch": "Add another switch"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "LocalTuya Configuration",
|
||||
"description": "Please select the desired actionSSSS.",
|
||||
"data": {
|
||||
"add_device": "Add a new device",
|
||||
"edit_device": "Edit a device",
|
||||
"delete_device": "Delete a device",
|
||||
"setup_cloud": "Reconfigure Cloud API account"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"title": "Entity Configuration",
|
||||
"description": "Editing entity with DPS `{id}` and platform `{platform}`.",
|
||||
"data": {
|
||||
"id": "ID",
|
||||
"friendly_name": "Friendly name",
|
||||
"current": "Current",
|
||||
"current_consumption": "Current Consumption",
|
||||
"voltage": "Voltage",
|
||||
"commands_set": "Open_Close_Stop Commands Set",
|
||||
"positioning_mode": "Positioning mode",
|
||||
"current_position_dp": "Current Position (for *position* mode only)",
|
||||
"set_position_dp": "Set Position (for *position* mode only)",
|
||||
"position_inverted": "Invert 0-100 position (for *position* mode only)",
|
||||
"span_time": "Full opening time, in secs. (for *timed* mode only)",
|
||||
"unit_of_measurement": "Unit of Measurement",
|
||||
"device_class": "Device Class",
|
||||
"scaling": "Scaling Factor",
|
||||
"state_on": "On Value",
|
||||
"state_off": "Off Value",
|
||||
"powergo_dp": "Power DP (Usually 25 or 2)",
|
||||
"idle_status_value": "Idle Status (comma-separated)",
|
||||
"returning_status_value": "Returning Status",
|
||||
"docked_status_value": "Docked Status (comma-separated)",
|
||||
"fault_dp": "Fault DP (Usually 11)",
|
||||
"battery_dp": "Battery status DP (Usually 14)",
|
||||
"mode_dp": "Mode DP (Usually 27)",
|
||||
"modes": "Modes list",
|
||||
"return_mode": "Return home mode",
|
||||
"fan_speed_dp": "Fan speeds DP (Usually 30)",
|
||||
"fan_speeds": "Fan speeds list (comma-separated)",
|
||||
"clean_time_dp": "Clean Time DP (Usually 33)",
|
||||
"clean_area_dp": "Clean Area DP (Usually 32)",
|
||||
"clean_record_dp": "Clean Record DP (Usually 34)",
|
||||
"locate_dp": "Locate DP (Usually 31)",
|
||||
"paused_state": "Pause state (pause, paused, etc)",
|
||||
"stop_status": "Stop status",
|
||||
"brightness": "Brightness (only for white color)",
|
||||
"brightness_lower": "Brightness Lower Value",
|
||||
"brightness_upper": "Brightness Upper Value",
|
||||
"color_temp": "Color Temperature",
|
||||
"color_temp_reverse": "Color Temperature Reverse",
|
||||
"color": "Color",
|
||||
"color_mode": "Color Mode",
|
||||
"color_temp_min_kelvin": "Minimum Color Temperature in K",
|
||||
"color_temp_max_kelvin": "Maximum Color Temperature in K",
|
||||
"music_mode": "Music mode available",
|
||||
"scene": "Scene",
|
||||
"fan_speed_control": "Fan Speed Control dps",
|
||||
"fan_oscillating_control": "Fan Oscillating Control dps",
|
||||
"fan_speed_min": "minimum fan speed integer",
|
||||
"fan_speed_max": "maximum fan speed integer",
|
||||
"fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)",
|
||||
"fan_direction": "fan direction dps",
|
||||
"fan_direction_forward": "forward dps string",
|
||||
"fan_direction_reverse": "reverse dps string",
|
||||
"fan_dps_type": "DP value type",
|
||||
"current_temperature_dp": "Current Temperature",
|
||||
"target_temperature_dp": "Target Temperature",
|
||||
"temperature_step": "Temperature Step (optional)",
|
||||
"max_temperature_dp": "Max Temperature (optional)",
|
||||
"min_temperature_dp": "Min Temperature (optional)",
|
||||
"precision": "Precision (optional, for DPs values)",
|
||||
"target_precision": "Target Precision (optional, for DPs values)",
|
||||
"temperature_unit": "Temperature Unit (optional)",
|
||||
"hvac_mode_dp": "HVAC Mode DP (optional)",
|
||||
"hvac_mode_set": "HVAC Mode Set (optional)",
|
||||
"hvac_action_dp": "HVAC Current Action DP (optional)",
|
||||
"hvac_action_set": "HVAC Current Action Set (optional)",
|
||||
"preset_dp": "Presets DP (optional)",
|
||||
"preset_set": "Presets Set (optional)",
|
||||
"eco_dp": "Eco DP (optional)",
|
||||
"eco_value": "Eco value (optional)",
|
||||
"heuristic_action": "Enable heuristic action (optional)",
|
||||
"dps_default_value": "Default value when un-initialised (optional)",
|
||||
"restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection",
|
||||
"min_value": "Minimum Value",
|
||||
"max_value": "Maximum Value",
|
||||
"step_size": "Minimum increment between numbers"
|
||||
}
|
||||
},
|
||||
"yaml_import": {
|
||||
"title": "Not Supported",
|
||||
"description": "Options cannot be edited when configured via YAML."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "LocalTuya"
|
||||
}
|
||||
91
homeassistant/config/custom_components/localtuya/switch.py
Normal file
91
homeassistant/config/custom_components/localtuya/switch.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Platform to locally control Tuya-based switch devices."""
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.switch import DOMAIN, SwitchEntity
|
||||
|
||||
from .common import LocalTuyaEntity, async_setup_entry
|
||||
from .const import (
|
||||
ATTR_CURRENT,
|
||||
ATTR_CURRENT_CONSUMPTION,
|
||||
ATTR_STATE,
|
||||
ATTR_VOLTAGE,
|
||||
CONF_CURRENT,
|
||||
CONF_CURRENT_CONSUMPTION,
|
||||
CONF_DEFAULT_VALUE,
|
||||
CONF_PASSIVE_ENTITY,
|
||||
CONF_RESTORE_ON_RECONNECT,
|
||||
CONF_VOLTAGE,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def flow_schema(dps):
|
||||
"""Return schema used in config flow."""
|
||||
return {
|
||||
vol.Optional(CONF_CURRENT): vol.In(dps),
|
||||
vol.Optional(CONF_CURRENT_CONSUMPTION): vol.In(dps),
|
||||
vol.Optional(CONF_VOLTAGE): vol.In(dps),
|
||||
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
|
||||
vol.Required(CONF_PASSIVE_ENTITY): bool,
|
||||
vol.Optional(CONF_DEFAULT_VALUE): str,
|
||||
}
|
||||
|
||||
|
||||
class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity):
|
||||
"""Representation of a Tuya switch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device,
|
||||
config_entry,
|
||||
switchid,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the Tuya switch."""
|
||||
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
|
||||
self._state = None
|
||||
_LOGGER.debug("Initialized switch [%s]", self.name)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Check if Tuya switch is on."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return device state attributes."""
|
||||
attrs = {}
|
||||
if self.has_config(CONF_CURRENT):
|
||||
attrs[ATTR_CURRENT] = self.dps(self._config[CONF_CURRENT])
|
||||
if self.has_config(CONF_CURRENT_CONSUMPTION):
|
||||
attrs[ATTR_CURRENT_CONSUMPTION] = (
|
||||
self.dps(self._config[CONF_CURRENT_CONSUMPTION]) / 10
|
||||
)
|
||||
if self.has_config(CONF_VOLTAGE):
|
||||
attrs[ATTR_VOLTAGE] = self.dps(self._config[CONF_VOLTAGE]) / 10
|
||||
|
||||
# Store the state
|
||||
if self._state is not None:
|
||||
attrs[ATTR_STATE] = self._state
|
||||
elif self._last_state is not None:
|
||||
attrs[ATTR_STATE] = self._last_state
|
||||
return attrs
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn Tuya switch on."""
|
||||
await self._device.set_dp(True, self._dp_id)
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn Tuya switch off."""
|
||||
await self._device.set_dp(False, self._dp_id)
|
||||
|
||||
# Default value is the "OFF" state
|
||||
def entity_default_value(self):
|
||||
"""Return False as the default value for this entity type."""
|
||||
return False
|
||||
|
||||
|
||||
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema)
|
||||
@@ -0,0 +1,238 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device has already been configured.",
|
||||
"device_updated": "Device configuration has been updated!"
|
||||
},
|
||||
"error": {
|
||||
"authentication_failed": "Failed to authenticate.\n{msg}",
|
||||
"cannot_connect": "Cannot connect to device. Verify that address is correct and try again.",
|
||||
"device_list_failed": "Failed to retrieve device list.\n{msg}",
|
||||
"invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.",
|
||||
"unknown": "An unknown error occurred. See log for details.",
|
||||
"entity_already_configured": "Entity with this ID has already been configured.",
|
||||
"address_in_use": "Address used for discovery is already in use. Make sure no other application is using it (TCP port 6668).",
|
||||
"discovery_failed": "Something failed when discovering devices. See log for details.",
|
||||
"empty_dps": "Connection to device succeeded but no datapoints found, please try again. Create a new issue and include debug logs if problem persists."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Cloud API account configuration",
|
||||
"description": "Input the credentials for Tuya Cloud API.",
|
||||
"data": {
|
||||
"region": "API server region",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Secret",
|
||||
"user_id": "User ID",
|
||||
"user_name": "Username",
|
||||
"no_cloud": "Do not configure a Cloud API account"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"already_configured": "Device has already been configured.",
|
||||
"device_success": "Device {dev_name} successfully {action}.",
|
||||
"no_entities": "Cannot remove all entities from a device.\nIf you want to delete a device, enter it in the Devices menu, click the 3 dots in the 'Device info' frame, and press the Delete button."
|
||||
},
|
||||
"error": {
|
||||
"authentication_failed": "Failed to authenticate.\n{msg}",
|
||||
"cannot_connect": "Cannot connect to device. Verify that address is correct and try again.",
|
||||
"device_list_failed": "Failed to retrieve device list.\n{msg}",
|
||||
"invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.",
|
||||
"unknown": "An unknown error occurred. See log for details.",
|
||||
"entity_already_configured": "Entity with this ID has already been configured.",
|
||||
"address_in_use": "Address used for discovery is already in use. Make sure no other application is using it (TCP port 6668).",
|
||||
"discovery_failed": "Something failed when discovering devices. See log for details.",
|
||||
"empty_dps": "Connection to device succeeded but no datapoints found, please try again. Create a new issue and include debug logs if problem persists."
|
||||
},
|
||||
"step": {
|
||||
"yaml_import": {
|
||||
"title": "Not Supported",
|
||||
"description": "Options cannot be edited when configured via YAML."
|
||||
},
|
||||
"init": {
|
||||
"title": "LocalTuya Configuration",
|
||||
"description": "Please select the desired action.",
|
||||
"data": {
|
||||
"add_device": "Add a new device",
|
||||
"edit_device": "Edit a device",
|
||||
"setup_cloud": "Reconfigure Cloud API account"
|
||||
}
|
||||
},
|
||||
"add_device": {
|
||||
"title": "Add a new device",
|
||||
"description": "Pick one of the automatically discovered devices or `...` to manually to add a device.",
|
||||
"data": {
|
||||
"selected_device": "Discovered Devices"
|
||||
}
|
||||
},
|
||||
"edit_device": {
|
||||
"title": "Edit a new device",
|
||||
"description": "Pick the configured device you wish to edit.",
|
||||
"data": {
|
||||
"selected_device": "Configured Devices",
|
||||
"max_temperature_const": "Max Temperature Constant (optional)",
|
||||
"min_temperature_const": "Min Temperature Constant (optional)",
|
||||
"hvac_fan_mode_dp": "HVAC Fan Mode DP (optional)",
|
||||
"hvac_fan_mode_set": "HVAC Fan Mode Set (optional)",
|
||||
"hvac_swing_mode_dp": "HVAC Swing Mode DP (optional)",
|
||||
"hvac_swing_mode_set": "HVAC Swing Mode Set (optional)"
|
||||
}
|
||||
},
|
||||
"cloud_setup": {
|
||||
"title": "Cloud API account configuration",
|
||||
"description": "Input the credentials for Tuya Cloud API.",
|
||||
"data": {
|
||||
"region": "API server region",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Secret",
|
||||
"user_id": "User ID",
|
||||
"user_name": "Username",
|
||||
"no_cloud": "Do not configure Cloud API account"
|
||||
}
|
||||
},
|
||||
"configure_device": {
|
||||
"title": "Configure Tuya device",
|
||||
"description": "Fill in the device details{for_device}.",
|
||||
"data": {
|
||||
"friendly_name": "Name",
|
||||
"host": "Host",
|
||||
"device_id": "Device ID",
|
||||
"local_key": "Local key",
|
||||
"protocol_version": "Protocol Version",
|
||||
"enable_debug": "Enable debugging for this device (debug must be enabled also in configuration.yaml)",
|
||||
"scan_interval": "Scan interval (seconds, only when not updating automatically)",
|
||||
"entities": "Entities (uncheck an entity to remove it)",
|
||||
"add_entities": "Add more entities in 'edit device' mode",
|
||||
"manual_dps_strings": "Manual DPS to add (separated by commas ',') - used when detection is not working (optional)",
|
||||
"reset_dpids": "DPIDs to send in RESET command (separated by commas ',')- Used when device does not respond to status requests after turning on (optional)"
|
||||
}
|
||||
},
|
||||
"pick_entity_type": {
|
||||
"title": "Entity type selection",
|
||||
"description": "Please pick the type of entity you want to add.",
|
||||
"data": {
|
||||
"platform_to_add": "Platform",
|
||||
"no_additional_entities": "Do not add any more entities"
|
||||
}
|
||||
},
|
||||
"configure_entity": {
|
||||
"title": "Configure entity",
|
||||
"description": "Please fill out the details for {entity} with type `{platform}`. All settings except for `ID` can be changed from the Options page later.",
|
||||
"data": {
|
||||
"id": "ID",
|
||||
"friendly_name": "Friendly name",
|
||||
"current": "Current",
|
||||
"current_consumption": "Current Consumption",
|
||||
"voltage": "Voltage",
|
||||
"commands_set": "Open_Close_Stop Commands Set",
|
||||
"positioning_mode": "Positioning mode",
|
||||
"current_position_dp": "Current Position (for *position* mode only)",
|
||||
"set_position_dp": "Set Position (for *position* mode only)",
|
||||
"position_inverted": "Invert 0-100 position (for *position* mode only)",
|
||||
"span_time": "Full opening time, in secs. (for *timed* mode only)",
|
||||
"unit_of_measurement": "Unit of Measurement",
|
||||
"device_class": "Device Class",
|
||||
"scaling": "Scaling Factor",
|
||||
"state_on": "On Value",
|
||||
"state_off": "Off Value",
|
||||
"powergo_dp": "Power DP (Usually 25 or 2)",
|
||||
"idle_status_value": "Idle Status (comma-separated)",
|
||||
"returning_status_value": "Returning Status",
|
||||
"docked_status_value": "Docked Status (comma-separated)",
|
||||
"fault_dp": "Fault DP (Usually 11)",
|
||||
"battery_dp": "Battery status DP (Usually 14)",
|
||||
"mode_dp": "Mode DP (Usually 27)",
|
||||
"modes": "Modes list",
|
||||
"return_mode": "Return home mode",
|
||||
"fan_speed_dp": "Fan speeds DP (Usually 30)",
|
||||
"fan_speeds": "Fan speeds list (comma-separated)",
|
||||
"clean_time_dp": "Clean Time DP (Usually 33)",
|
||||
"clean_area_dp": "Clean Area DP (Usually 32)",
|
||||
"clean_record_dp": "Clean Record DP (Usually 34)",
|
||||
"locate_dp": "Locate DP (Usually 31)",
|
||||
"paused_state": "Pause state (pause, paused, etc)",
|
||||
"stop_status": "Stop status",
|
||||
"brightness": "Brightness (only for white color)",
|
||||
"brightness_lower": "Brightness Lower Value",
|
||||
"brightness_upper": "Brightness Upper Value",
|
||||
"color_temp": "Color Temperature",
|
||||
"color_temp_reverse": "Color Temperature Reverse",
|
||||
"color": "Color",
|
||||
"color_mode": "Color Mode",
|
||||
"color_temp_min_kelvin": "Minimum Color Temperature in K",
|
||||
"color_temp_max_kelvin": "Maximum Color Temperature in K",
|
||||
"music_mode": "Music mode available",
|
||||
"scene": "Scene",
|
||||
"select_options": "Valid entries, separate entries by a ;",
|
||||
"select_options_friendly": "User Friendly options, separate entries by a ;",
|
||||
"fan_speed_control": "Fan Speed Control dps",
|
||||
"fan_oscillating_control": "Fan Oscillating Control dps",
|
||||
"fan_speed_min": "minimum fan speed integer",
|
||||
"fan_speed_max": "maximum fan speed integer",
|
||||
"fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)",
|
||||
"fan_direction": "fan direction dps",
|
||||
"fan_direction_forward": "forward dps string",
|
||||
"fan_direction_reverse": "reverse dps string",
|
||||
"fan_dps_type": "DP value type",
|
||||
"current_temperature_dp": "Current Temperature",
|
||||
"target_temperature_dp": "Target Temperature",
|
||||
"temperature_step": "Temperature Step (optional)",
|
||||
"max_temperature_dp": "Max Temperature DP (optional)",
|
||||
"min_temperature_dp": "Min Temperature DP (optional)",
|
||||
"max_temperature_const": "Max Temperature Constant (optional)",
|
||||
"min_temperature_const": "Min Temperature Constant (optional)",
|
||||
"precision": "Precision (optional, for DPs values)",
|
||||
"target_precision": "Target Precision (optional, for DPs values)",
|
||||
"temperature_unit": "Temperature Unit (optional)",
|
||||
"hvac_mode_dp": "HVAC Mode DP (optional)",
|
||||
"hvac_mode_set": "HVAC Mode Set (optional)",
|
||||
"hvac_fan_mode_dp": "HVAC Fan Mode DP (optional)",
|
||||
"hvac_fan_mode_set": "HVAC Fan Mode Set (optional)",
|
||||
"hvac_swing_mode_dp": "HVAC Swing Mode DP (optional)",
|
||||
"hvac_swing_mode_set": "HVAC Swing Mode Set (optional)",
|
||||
"hvac_action_dp": "HVAC Current Action DP (optional)",
|
||||
"hvac_action_set": "HVAC Current Action Set (optional)",
|
||||
"preset_dp": "Presets DP (optional)",
|
||||
"preset_set": "Presets Set (optional)",
|
||||
"eco_dp": "Eco DP (optional)",
|
||||
"eco_value": "Eco value (optional)",
|
||||
"heuristic_action": "Enable heuristic action (optional)",
|
||||
"dps_default_value": "Default value when un-initialised (optional)",
|
||||
"restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection",
|
||||
"min_value": "Minimum Value",
|
||||
"max_value": "Maximum Value",
|
||||
"step_size": "Minimum increment between numbers",
|
||||
"is_passive_entity": "Passive entity - requires integration to send initialisation value"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"name": "Reload",
|
||||
"description": "Reload localtuya and reconnect to all devices."
|
||||
},
|
||||
"set_dp": {
|
||||
"name": "Set datapoint",
|
||||
"description": "Change the value of a datapoint (DP)",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device ID",
|
||||
"description": "Device ID of device to change datapoint value for"
|
||||
},
|
||||
"dp": {
|
||||
"name": "DP",
|
||||
"description": "Datapoint index"
|
||||
},
|
||||
"value": {
|
||||
"name": "Value",
|
||||
"description": "New value to set"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "LocalTuya"
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Il dispositivo è già stato configurato.",
|
||||
"device_updated": "La configurazione del dispositivo è stata aggiornata."
|
||||
},
|
||||
"error": {
|
||||
"authentication_failed": "Autenticazione fallita. Errore:\n{msg}",
|
||||
"cannot_connect": "Impossibile connettersi al dispositivo. Verifica che l'indirizzo sia corretto e riprova.",
|
||||
"device_list_failed": "Impossibile recuperare l'elenco dei dispositivi.\n{msg}",
|
||||
"invalid_auth": "Impossibile autenticarsi con il dispositivo. Verificare che device_id e local_key siano corretti.",
|
||||
"unknown": "Si è verificato un errore sconosciuto. Vedere registro per i dettagli.",
|
||||
"entity_already_configured": "L'entity con questo ID è già stata configurata.",
|
||||
"address_in_use": "L'indirizzo utilizzato per il discovery è già in uso. Assicurarsi che nessun'altra applicazione lo stia utilizzando (porta TCP 6668).",
|
||||
"discovery_failed": "Qualcosa è fallito nella discovery dei dispositivi. Vedi registro per i dettagli.",
|
||||
"empty_dps": "La connessione al dispositivo è riuscita ma non sono stati trovati i datapoint, riprova. Crea un nuovo Issue e includi i log di debug se il problema persiste."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configurazione dell'account Cloud API",
|
||||
"description": "Inserisci le credenziali per l'account Cloud API Tuya.",
|
||||
"data": {
|
||||
"region": "Regione del server API",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Secret",
|
||||
"user_id": "User ID",
|
||||
"user_name": "Username",
|
||||
"no_cloud": "Non configurare un account Cloud API"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"already_configured": "Il dispositivo è già stato configurato.",
|
||||
"device_success": "Dispositivo {dev_name} {action} con successo.",
|
||||
"no_entities": "Non si possono rimuovere tutte le entities da un device.\nPer rimuovere un device, entrarci nel menu Devices, premere sui 3 punti nel riquadro 'Device info', e premere il pulsante Delete."
|
||||
},
|
||||
"error": {
|
||||
"authentication_failed": "Autenticazione fallita. Errore:\n{msg}",
|
||||
"cannot_connect": "Impossibile connettersi al dispositivo. Verifica che l'indirizzo sia corretto e riprova.",
|
||||
"device_list_failed": "Impossibile recuperare l'elenco dei dispositivi.\n{msg}",
|
||||
"invalid_auth": "Impossibile autenticarsi con il dispositivo. Verificare che device_id e local_key siano corretti.",
|
||||
"unknown": "Si è verificato un errore sconosciuto. Vedere registro per i dettagli.",
|
||||
"entity_already_configured": "L'entity con questo ID è già stata configurata.",
|
||||
"address_in_use": "L'indirizzo utilizzato per il discovery è già in uso. Assicurarsi che nessun'altra applicazione lo stia utilizzando (porta TCP 6668).",
|
||||
"discovery_failed": "Qualcosa è fallito nella discovery dei dispositivi. Vedi registro per i dettagli.",
|
||||
"empty_dps": "La connessione al dispositivo è riuscita ma non sono stati trovati i datapoint, riprova. Crea un nuovo Issue e includi i log di debug se il problema persiste."
|
||||
},
|
||||
"step": {
|
||||
"yaml_import": {
|
||||
"title": "Non supportato",
|
||||
"description": "Le impostazioni non possono essere configurate tramite file YAML."
|
||||
},
|
||||
"init": {
|
||||
"title": "Configurazione LocalTuya",
|
||||
"description": "Seleziona l'azione desiderata.",
|
||||
"data": {
|
||||
"add_device": "Aggiungi un nuovo dispositivo",
|
||||
"edit_device": "Modifica un dispositivo",
|
||||
"setup_cloud": "Riconfigurare l'account Cloud API"
|
||||
}
|
||||
},
|
||||
"add_device": {
|
||||
"title": "Aggiungi un nuovo dispositivo",
|
||||
"description": "Scegli uno dei dispositivi trovati automaticamente o `...` per aggiungere manualmente un dispositivo.",
|
||||
"data": {
|
||||
"selected_device": "Dispositivi trovati"
|
||||
}
|
||||
},
|
||||
"edit_device": {
|
||||
"title": "Modifica un dispositivo",
|
||||
"description": "Scegli il dispositivo configurato che si desidera modificare.",
|
||||
"data": {
|
||||
"selected_device": "Dispositivi configurati"
|
||||
}
|
||||
},
|
||||
"cloud_setup": {
|
||||
"title": "Configurazione dell'account Cloud API",
|
||||
"description": "Inserisci le credenziali per l'account Cloud API Tuya.",
|
||||
"data": {
|
||||
"region": "Regione del server API",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Secret",
|
||||
"user_id": "User ID",
|
||||
"user_name": "Username",
|
||||
"no_cloud": "Non configurare l'account Cloud API"
|
||||
}
|
||||
},
|
||||
"configure_device": {
|
||||
"title": "Configura il dispositivo",
|
||||
"description": "Compila i dettagli del dispositivo {for_device}.",
|
||||
"data": {
|
||||
"friendly_name": "Nome",
|
||||
"host": "Host",
|
||||
"device_id": "ID del dispositivo",
|
||||
"local_key": "Chiave locale",
|
||||
"protocol_version": "Versione del protocollo",
|
||||
"enable_debug": "Abilita il debugging per questo device (il debug va abilitato anche in configuration.yaml)",
|
||||
"scan_interval": "Intervallo di scansione (secondi, solo quando non si aggiorna automaticamente)",
|
||||
"entities": "Entities (deseleziona un'entity per rimuoverla)"
|
||||
}
|
||||
},
|
||||
"pick_entity_type": {
|
||||
"title": "Selezione del tipo di entity",
|
||||
"description": "Scegli il tipo di entity che desideri aggiungere.",
|
||||
"data": {
|
||||
"platform_to_add": "piattaforma",
|
||||
"no_additional_entities": "Non aggiungere altre entity"
|
||||
}
|
||||
},
|
||||
"configure_entity": {
|
||||
"title": "Configurare entity",
|
||||
"description": "Compila i dettagli per {entity} con tipo `{platform}`.Tutte le impostazioni ad eccezione di `id` possono essere modificate dalla pagina delle opzioni in seguito.",
|
||||
"data": {
|
||||
"id": "ID",
|
||||
"friendly_name": "Nome amichevole",
|
||||
"current": "Corrente",
|
||||
"current_consumption": "Potenza",
|
||||
"voltage": "Tensione",
|
||||
"commands_set": "Set di comandi Aperto_Chiuso_Stop",
|
||||
"positioning_mode": "Modalità di posizionamento",
|
||||
"current_position_dp": "Posizione attuale (solo per la modalità *posizione*)",
|
||||
"set_position_dp": "Imposta posizione (solo per modalità *posizione*)",
|
||||
"position_inverted": "Inverti posizione 0-100 (solo per modalità *posizione*)",
|
||||
"span_time": "Tempo di apertura totale, in sec. (solo per modalità *a tempo*)",
|
||||
"unit_of_measurement": "Unità di misura",
|
||||
"device_class": "Classe del dispositivo",
|
||||
"scaling": "Fattore di scala",
|
||||
"state_on": "Valore di ON",
|
||||
"state_off": "Valore di OFF",
|
||||
"powergo_dp": "Potenza DP (di solito 25 o 2)",
|
||||
"idle_status_value": "Stato di inattività (separato da virgole)",
|
||||
"returning_status_value": "Stato di ritorno alla base",
|
||||
"docked_status_value": "Stato di tornato alla base (separato da virgole)",
|
||||
"fault_dp": "DP di guasto (di solito 11)",
|
||||
"battery_dp": "DP di stato batteria (di solito 14)",
|
||||
"mode_dp": "DP di modalità (di solito 27)",
|
||||
"modes": "Elenco delle modalità",
|
||||
"return_mode": "Ritorno in modalità home",
|
||||
"fan_speed_dp": "DP di velocità del ventilatore (di solito 30)",
|
||||
"fan_speeds": "DP di elenco delle velocità del ventilatore (separato da virgola)",
|
||||
"clean_time_dp": "DP di tempo di pulizia (di solito 33)",
|
||||
"clean_area_dp": "DP di area pulita (di solito 32)",
|
||||
"clean_record_dp": "DP di record delle pulizie (di solito 34)",
|
||||
"locate_dp": "DP di individuazione (di solito 31)",
|
||||
"paused_state": "Stato di pausa (pausa, pausa, ecc.)",
|
||||
"stop_status": "Stato di stop",
|
||||
"brightness": "Luminosità (solo per il colore bianco)",
|
||||
"brightness_lower": "Limite inferiore per la luminosità",
|
||||
"brightness_upper": "Limite superiore per la luminosità",
|
||||
"color_temp": "Temperatura di colore",
|
||||
"color_temp_reverse": "Temperatura di colore invertita",
|
||||
"color": "Colore",
|
||||
"color_mode": "Modalità colore",
|
||||
"color_temp_min_kelvin": "Minima temperatura di colore in K",
|
||||
"color_temp_max_kelvin": "Massima temperatura di colore in k",
|
||||
"music_mode": "Modalità musicale disponibile",
|
||||
"scene": "Scena",
|
||||
"select_options": "Opzioni valide, voci separate da una vigola (;)",
|
||||
"select_options_friendly": "Opzioni intuitive, voci separate da una virgola",
|
||||
"fan_speed_control": "DP di controllo di velocità del ventilatore",
|
||||
"fan_oscillating_control": "DP di controllo dell'oscillazione del ventilatore",
|
||||
"fan_speed_min": "Velocità del ventilatore minima",
|
||||
"fan_speed_max": "Velocità del ventilatore massima",
|
||||
"fan_speed_ordered_list": "Elenco delle modalità di velocità del ventilatore (sovrascrive velocità min/max)",
|
||||
"fan_direction":"DP di direzione del ventilatore",
|
||||
"fan_direction_forward": "Stringa del DP per avanti",
|
||||
"fan_direction_reverse": "Stringa del DP per indietro",
|
||||
"current_temperature_dp": "Temperatura attuale",
|
||||
"target_temperature_dp": "Temperatura target",
|
||||
"temperature_step": "Intervalli di temperatura (facoltativo)",
|
||||
"max_temperature_dp": "Temperatura massima (opzionale)",
|
||||
"min_temperature_dp": "Temperatura minima (opzionale)",
|
||||
"precision": "Precisione (opzionale, per valori DP)",
|
||||
"target_precision": "Precisione del target (opzionale, per valori DP)",
|
||||
"temperature_unit": "Unità di temperatura (opzionale)",
|
||||
"hvac_mode_dp": "Modalità HVAC attuale (opzionale)",
|
||||
"hvac_mode_set": "Impostazione modalità HVAC (opzionale)",
|
||||
"hvac_action_dp": "Azione HVAC attuale (opzionale)",
|
||||
"hvac_action_set": "Impostazione azione HVAC (opzionale)",
|
||||
"preset_dp": "Preset DP (opzionale)",
|
||||
"preset_set": "Set di preset (opzionale)",
|
||||
"eco_dp": "DP per Eco (opzionale)",
|
||||
"eco_value": "Valore Eco (opzionale)",
|
||||
"heuristic_action": "Abilita azione euristica (opzionale)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"name": "Reload",
|
||||
"description": "Reload localtuya and reconnect to all devices."
|
||||
},
|
||||
"set_dp": {
|
||||
"name": "Set datapoint",
|
||||
"description": "Change the value of a datapoint (DP)",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device ID",
|
||||
"description": "Device ID of device to change datapoint value for"
|
||||
},
|
||||
"dp": {
|
||||
"name": "DP",
|
||||
"description": "Datapoint index"
|
||||
},
|
||||
"value": {
|
||||
"name": "Value",
|
||||
"description": "New value to set"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "LocalTuya"
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "O dispositivo já foi configurado.",
|
||||
"device_updated": "A configuração do dispositivo foi atualizada!"
|
||||
},
|
||||
"error": {
|
||||
"authentication_failed": "Falha ao autenticar.\n{msg}",
|
||||
"cannot_connect": "Não é possível se conectar ao dispositivo. Verifique se o endereço está correto e tente novamente",
|
||||
"device_list_failed": "Falha ao recuperar a lista de dispositivos.\n{msg}",
|
||||
"invalid_auth": "Falha ao autenticar com o dispositivo. Verifique se o ID do dispositivo e a chave local estão corretos.",
|
||||
"unknown": "Ocorreu um erro desconhecido. Consulte o registro para obter detalhes.",
|
||||
"entity_already_configured": "A entidade com este ID já foi configurada.",
|
||||
"address_in_use": "AddresO endereço usado para descoberta já está em uso. Certifique-se de que nenhum outro aplicativo o esteja usando (porta TCP 6668).s used for discovery is already in use. Make sure no other application is using it (TCP port 6668).",
|
||||
"discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para obter detalhes.",
|
||||
"empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados foi encontrado. Tente novamente. Crie um novo issue e inclua os logs de depuração se o problema persistir."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configuração da conta da API do Cloud",
|
||||
"description": "Insira as credenciais para a API Tuya Cloud.",
|
||||
"data": {
|
||||
"region": "Região do servidor de API",
|
||||
"client_id": "ID do cliente",
|
||||
"client_secret": "Secret",
|
||||
"user_id": "ID de usuário",
|
||||
"user_name": "Nome de usuário",
|
||||
"no_cloud": "Não configure uma conta de API da Cloud"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"already_configured": "O dispositivo já foi configurado.",
|
||||
"device_success": "Dispositivo {dev_name} {action} com sucesso.",
|
||||
"no_entities": "Não é possível remover todas as entidades de um dispositivo.\nSe você deseja excluir um dispositivo, insira-o no menu Dispositivos, clique nos 3 pontos no quadro 'Informações do dispositivo' e pressione o botão Excluir."
|
||||
},
|
||||
"error": {
|
||||
"authentication_failed": "Falha ao autenticar.\n{msg}",
|
||||
"cannot_connect": "Não é possível se conectar ao dispositivo. Verifique se o endereço está correto e tente novamente",
|
||||
"device_list_failed": "Falha ao recuperar a lista de dispositivos.\n{msg}",
|
||||
"invalid_auth": "Falha ao autenticar com o dispositivo. Verifique se o ID do dispositivo e a chave local estão corretos.",
|
||||
"unknown": "Ocorreu um erro desconhecido. Consulte o registro para obter detalhes.",
|
||||
"entity_already_configured": "A entidade com este ID já foi configurada.",
|
||||
"address_in_use": "O endereço usado para descoberta já está em uso. Certifique-se de que nenhum outro aplicativo o esteja usando (porta TCP 6668).",
|
||||
"discovery_failed": "Algo falhou ao descobrir dispositivos. Consulte o registro para obter detalhes.",
|
||||
"empty_dps": "A conexão com o dispositivo foi bem-sucedida, mas nenhum ponto de dados foi encontrado. Tente novamente. Crie um novo issue e inclua os logs de depuração se o problema persistir."
|
||||
},
|
||||
"step": {
|
||||
"yaml_import": {
|
||||
"title": "Não suportado",
|
||||
"description": "As opções não podem ser editadas quando configuradas via YAML."
|
||||
},
|
||||
"init": {
|
||||
"title": "Configuração LocalTuya",
|
||||
"description": "Selecione a ação desejada.",
|
||||
"data": {
|
||||
"add_device": "Adicionar um novo dispositivo",
|
||||
"edit_device": "Editar um dispositivo",
|
||||
"setup_cloud": "Reconfigurar a conta da API da Cloud"
|
||||
}
|
||||
},
|
||||
"add_device": {
|
||||
"title": "Adicionar um novo dispositivo",
|
||||
"description": "Escolha um dos dispositivos descobertos automaticamente ou `...` para adicionar um dispositivo manualmente.",
|
||||
"data": {
|
||||
"selected_device": "Dispositivos descobertos"
|
||||
}
|
||||
},
|
||||
"edit_device": {
|
||||
"title": "Editar um novo dispositivo",
|
||||
"description": "Escolha o dispositivo configurado que você deseja editar.",
|
||||
"data": {
|
||||
"selected_device": "Dispositivos configurados"
|
||||
}
|
||||
},
|
||||
"cloud_setup": {
|
||||
"title": "Configuração da conta da API da Cloud",
|
||||
"description": "Insira as credenciais para a API Tuya Cloud.",
|
||||
"data": {
|
||||
"region": "Região do servidor de API",
|
||||
"client_id": "ID do Cliente",
|
||||
"client_secret": "Secret",
|
||||
"user_id": "ID do usuário",
|
||||
"user_name": "Nome de usuário",
|
||||
"no_cloud": "Não configure a conta da API da Cloud"
|
||||
}
|
||||
},
|
||||
"configure_device": {
|
||||
"title": "Configurar dispositivo Tuya",
|
||||
"description": "Preencha os detalhes do dispositivo {for_device}.",
|
||||
"data": {
|
||||
"friendly_name": "Nome",
|
||||
"host": "Host",
|
||||
"device_id": "ID do dispositivo",
|
||||
"local_key": "Local key",
|
||||
"protocol_version": "Versão do protocolo",
|
||||
"enable_debug": "Ative a depuração para este dispositivo (a depuração também deve ser ativada em configuration.yaml)",
|
||||
"scan_interval": "Intervalo de escaneamento (segundos, somente quando não estiver atualizando automaticamente)",
|
||||
"entities": "Entidades (desmarque uma entidade para removê-la)"
|
||||
}
|
||||
},
|
||||
"pick_entity_type": {
|
||||
"title": "Seleção do tipo de entidade",
|
||||
"description": "Escolha o tipo de entidade que deseja adicionar.",
|
||||
"data": {
|
||||
"platform_to_add": "Plataforma",
|
||||
"no_additional_entities": "Não adicione mais entidades"
|
||||
}
|
||||
},
|
||||
"configure_entity": {
|
||||
"title": "Configurar entidade",
|
||||
"description": "Por favor, preencha os detalhes de {entity} com o tipo `{platform}`. Todas as configurações, exceto `ID`, podem ser alteradas na página Opções posteriormente.",
|
||||
"data": {
|
||||
"id": "ID",
|
||||
"friendly_name": "Nome fantasia",
|
||||
"current": "Atual",
|
||||
"current_consumption": "Consumo atual",
|
||||
"voltage": "Voltagem",
|
||||
"commands_set": "Conjunto de comandos Abrir_Fechar_Parar",
|
||||
"positioning_mode": "Modo de posicionamento",
|
||||
"current_position_dp": "Posição atual (somente para o modo *posição*)",
|
||||
"set_position_dp": "Definir posição (somente para o modo *posição*)",
|
||||
"position_inverted": "Inverter 0-100 posição (somente para o modo *posição*)",
|
||||
"span_time": "Tempo de abertura completo, em segundos. (somente para o modo *temporizado*)",
|
||||
"unit_of_measurement": "Unidade de medida",
|
||||
"device_class": "Classe do dispositivo",
|
||||
"scaling": "Fator de escala",
|
||||
"state_on": "Valor ligado",
|
||||
"state_off": "Valor desligado",
|
||||
"powergo_dp": "Potência DP (Geralmente 25 ou 2)",
|
||||
"idle_status_value": "Status ocioso (separado por vírgula)",
|
||||
"returning_status_value": "Status de retorno",
|
||||
"docked_status_value": "Status encaixado (separado por vírgula)",
|
||||
"fault_dp": "Falha DP (Geralmente 11)",
|
||||
"battery_dp": "Status da bateria DP (normalmente 14)",
|
||||
"mode_dp": "Modo DP (Geralmente 27)",
|
||||
"modes": "Lista de modos",
|
||||
"return_mode": "Modo de retorno para casa",
|
||||
"fan_speed_dp": "Velocidades do ventilador DP (normalmente 30)",
|
||||
"fan_speeds": "Lista de velocidades do ventilador (separadas por vírgulas)",
|
||||
"clean_time_dp": "Tempo Limpo DP (Geralmente 33)",
|
||||
"clean_area_dp": "Área Limpa DP (Geralmente 32)",
|
||||
"clean_record_dp": "Limpar Registro DP (Geralmente 34)",
|
||||
"locate_dp": "Localize DP (Geralmente 31)",
|
||||
"paused_state": "Estado de pausa (pausa, pausado, etc)",
|
||||
"stop_status": "Status de parada",
|
||||
"brightness": "Brilho (somente para cor branca)",
|
||||
"brightness_lower": "Valor mais baixo de brilho",
|
||||
"brightness_upper": "Valor superior de brilho",
|
||||
"color_temp": "Temperatura da cor",
|
||||
"color_temp_reverse": "Temperatura da cor reversa",
|
||||
"color": "Cor",
|
||||
"color_mode": "Modo de cor",
|
||||
"color_temp_min_kelvin": "Temperatura de cor mínima em K",
|
||||
"color_temp_max_kelvin": "Temperatura máxima de cor em K",
|
||||
"music_mode": "Modo de música disponível",
|
||||
"scene": "Cena",
|
||||
"select_options": "Entradas válidas, entradas separadas por um ;",
|
||||
"select_options_friendly": "Opções fantasia ao usuário, entradas separadas por um ;",
|
||||
"fan_speed_control": "Dps de controle de velocidade do ventilador",
|
||||
"fan_oscillating_control": "Dps de controle oscilante do ventilador",
|
||||
"fan_speed_min": "Velocidade mínima do ventilador inteiro",
|
||||
"fan_speed_max": "Velocidade máxima do ventilador inteiro",
|
||||
"fan_speed_ordered_list": "Lista de modos de velocidade do ventilador (substitui a velocidade min/max)",
|
||||
"fan_direction":"Direção do ventilador dps",
|
||||
"fan_direction_forward": "Seqüência de dps para frente",
|
||||
"fan_direction_reverse": "String dps reversa",
|
||||
"current_temperature_dp": "Temperatura atual",
|
||||
"target_temperature_dp": "Temperatura alvo",
|
||||
"temperature_step": "Etapa de temperatura (opcional)",
|
||||
"max_temperature_dp": "Temperatura máxima (opcional)",
|
||||
"min_temperature_dp": "Temperatura mínima (opcional)",
|
||||
"precision": "Precisão (opcional, para valores de DPs)",
|
||||
"target_precision": "Precisão do alvo (opcional, para valores de DPs)",
|
||||
"temperature_unit": "Unidade de Temperatura (opcional)",
|
||||
"hvac_mode_dp": "Modo HVAC DP (opcional)",
|
||||
"hvac_mode_set": "Conjunto de modo HVAC (opcional)",
|
||||
"hvac_action_dp": "Ação atual de HVAC DP (opcional)",
|
||||
"hvac_action_set": "Conjunto de ação atual HVAC (opcional)",
|
||||
"preset_dp": "Predefinições DP (opcional)",
|
||||
"preset_set": "Conjunto de predefinições (opcional)",
|
||||
"eco_dp": "Eco DP (opcional)",
|
||||
"eco_value": "Valor eco (opcional)",
|
||||
"heuristic_action": "Ativar ação heurística (opcional)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"name": "Reload",
|
||||
"description": "Reload localtuya and reconnect to all devices."
|
||||
},
|
||||
"set_dp": {
|
||||
"name": "Set datapoint",
|
||||
"description": "Change the value of a datapoint (DP)",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"name": "Device ID",
|
||||
"description": "Device ID of device to change datapoint value for"
|
||||
},
|
||||
"dp": {
|
||||
"name": "DP",
|
||||
"description": "Datapoint index"
|
||||
},
|
||||
"value": {
|
||||
"name": "Value",
|
||||
"description": "New value to set"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "LocalTuya"
|
||||
}
|
||||
241
homeassistant/config/custom_components/localtuya/vacuum.py
Normal file
241
homeassistant/config/custom_components/localtuya/vacuum.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Platform to locally control Tuya-based vacuum devices."""
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components.vacuum import (
|
||||
DOMAIN,
|
||||
StateVacuumEntity, VacuumActivity, VacuumEntityFeature,
|
||||
)
|
||||
|
||||
from .common import LocalTuyaEntity, async_setup_entry
|
||||
from .const import (
|
||||
CONF_BATTERY_DP,
|
||||
CONF_CLEAN_AREA_DP,
|
||||
CONF_CLEAN_RECORD_DP,
|
||||
CONF_CLEAN_TIME_DP,
|
||||
CONF_DOCKED_STATUS_VALUE,
|
||||
CONF_FAN_SPEED_DP,
|
||||
CONF_FAN_SPEEDS,
|
||||
CONF_FAULT_DP,
|
||||
CONF_IDLE_STATUS_VALUE,
|
||||
CONF_LOCATE_DP,
|
||||
CONF_MODE_DP,
|
||||
CONF_MODES,
|
||||
CONF_PAUSED_STATE,
|
||||
CONF_POWERGO_DP,
|
||||
CONF_RETURN_MODE,
|
||||
CONF_RETURNING_STATUS_VALUE,
|
||||
CONF_STOP_STATUS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CLEAN_TIME = "clean_time"
|
||||
CLEAN_AREA = "clean_area"
|
||||
CLEAN_RECORD = "clean_record"
|
||||
MODES_LIST = "cleaning_mode_list"
|
||||
MODE = "cleaning_mode"
|
||||
FAULT = "fault"
|
||||
|
||||
DEFAULT_IDLE_STATUS = "standby,sleep"
|
||||
DEFAULT_RETURNING_STATUS = "docking"
|
||||
DEFAULT_DOCKED_STATUS = "charging,chargecompleted"
|
||||
DEFAULT_MODES = "smart,wall_follow,spiral,single"
|
||||
DEFAULT_FAN_SPEEDS = "low,normal,high"
|
||||
DEFAULT_PAUSED_STATE = "paused"
|
||||
DEFAULT_RETURN_MODE = "chargego"
|
||||
DEFAULT_STOP_STATUS = "standby"
|
||||
|
||||
|
||||
def flow_schema(dps):
|
||||
"""Return schema used in config flow."""
|
||||
return {
|
||||
vol.Required(CONF_IDLE_STATUS_VALUE, default=DEFAULT_IDLE_STATUS): str,
|
||||
vol.Required(CONF_POWERGO_DP): vol.In(dps),
|
||||
vol.Required(CONF_DOCKED_STATUS_VALUE, default=DEFAULT_DOCKED_STATUS): str,
|
||||
vol.Optional(
|
||||
CONF_RETURNING_STATUS_VALUE, default=DEFAULT_RETURNING_STATUS
|
||||
): str,
|
||||
vol.Optional(CONF_BATTERY_DP): vol.In(dps),
|
||||
vol.Optional(CONF_MODE_DP): vol.In(dps),
|
||||
vol.Optional(CONF_MODES, default=DEFAULT_MODES): str,
|
||||
vol.Optional(CONF_RETURN_MODE, default=DEFAULT_RETURN_MODE): str,
|
||||
vol.Optional(CONF_FAN_SPEED_DP): vol.In(dps),
|
||||
vol.Optional(CONF_FAN_SPEEDS, default=DEFAULT_FAN_SPEEDS): str,
|
||||
vol.Optional(CONF_CLEAN_TIME_DP): vol.In(dps),
|
||||
vol.Optional(CONF_CLEAN_AREA_DP): vol.In(dps),
|
||||
vol.Optional(CONF_CLEAN_RECORD_DP): vol.In(dps),
|
||||
vol.Optional(CONF_LOCATE_DP): vol.In(dps),
|
||||
vol.Optional(CONF_FAULT_DP): vol.In(dps),
|
||||
vol.Optional(CONF_PAUSED_STATE, default=DEFAULT_PAUSED_STATE): str,
|
||||
vol.Optional(CONF_STOP_STATUS, default=DEFAULT_STOP_STATUS): str,
|
||||
}
|
||||
|
||||
|
||||
class LocaltuyaVacuum(LocalTuyaEntity, StateVacuumEntity):
|
||||
"""Tuya vacuum device."""
|
||||
|
||||
def __init__(self, device, config_entry, switchid, **kwargs):
|
||||
"""Initialize a new LocaltuyaVacuum."""
|
||||
super().__init__(device, config_entry, switchid, _LOGGER, **kwargs)
|
||||
self._state = None
|
||||
self._battery_level = None
|
||||
self._attrs = {}
|
||||
|
||||
self._idle_status_list = []
|
||||
if self.has_config(CONF_IDLE_STATUS_VALUE):
|
||||
self._idle_status_list = self._config[CONF_IDLE_STATUS_VALUE].split(",")
|
||||
|
||||
self._modes_list = []
|
||||
if self.has_config(CONF_MODES):
|
||||
self._modes_list = self._config[CONF_MODES].split(",")
|
||||
self._attrs[MODES_LIST] = self._modes_list
|
||||
|
||||
self._docked_status_list = []
|
||||
if self.has_config(CONF_DOCKED_STATUS_VALUE):
|
||||
self._docked_status_list = self._config[CONF_DOCKED_STATUS_VALUE].split(",")
|
||||
|
||||
self._fan_speed_list = []
|
||||
if self.has_config(CONF_FAN_SPEEDS):
|
||||
self._fan_speed_list = self._config[CONF_FAN_SPEEDS].split(",")
|
||||
|
||||
self._fan_speed = ""
|
||||
self._cleaning_mode = ""
|
||||
_LOGGER.debug("Initialized vacuum [%s]", self.name)
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
supported_features = (
|
||||
VacuumEntityFeature.START
|
||||
| VacuumEntityFeature.PAUSE
|
||||
| VacuumEntityFeature.STOP
|
||||
| VacuumEntityFeature.STATUS
|
||||
| VacuumEntityFeature.STATE
|
||||
)
|
||||
|
||||
if self.has_config(CONF_RETURN_MODE):
|
||||
supported_features = supported_features | VacuumEntityFeature.RETURN_HOME
|
||||
if self.has_config(CONF_FAN_SPEED_DP):
|
||||
supported_features = supported_features | VacuumEntityFeature.FAN_SPEED
|
||||
if self.has_config(CONF_BATTERY_DP):
|
||||
supported_features = supported_features | VacuumEntityFeature.BATTERY
|
||||
if self.has_config(CONF_LOCATE_DP):
|
||||
supported_features = supported_features | VacuumEntityFeature.LOCATE
|
||||
|
||||
return supported_features
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the vacuum state."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def battery_level(self):
|
||||
"""Return the current battery level."""
|
||||
return self._battery_level
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the specific state attributes of this vacuum cleaner."""
|
||||
return self._attrs
|
||||
|
||||
@property
|
||||
def fan_speed(self):
|
||||
"""Return the current fan speed."""
|
||||
return self._fan_speed
|
||||
|
||||
@property
|
||||
def fan_speed_list(self) -> list:
|
||||
"""Return the list of available fan speeds."""
|
||||
return self._fan_speed_list
|
||||
|
||||
async def async_start(self, **kwargs):
|
||||
"""Turn the vacuum on and start cleaning."""
|
||||
await self._device.set_dp(True, self._config[CONF_POWERGO_DP])
|
||||
|
||||
async def async_pause(self, **kwargs):
|
||||
"""Stop the vacuum cleaner, do not return to base."""
|
||||
await self._device.set_dp(False, self._config[CONF_POWERGO_DP])
|
||||
|
||||
async def async_return_to_base(self, **kwargs):
|
||||
"""Set the vacuum cleaner to return to the dock."""
|
||||
if self.has_config(CONF_RETURN_MODE):
|
||||
await self._device.set_dp(
|
||||
self._config[CONF_RETURN_MODE], self._config[CONF_MODE_DP]
|
||||
)
|
||||
else:
|
||||
_LOGGER.error("Missing command for return home in commands set.")
|
||||
|
||||
async def async_stop(self, **kwargs):
|
||||
"""Turn the vacuum off stopping the cleaning."""
|
||||
if self.has_config(CONF_STOP_STATUS):
|
||||
await self._device.set_dp(
|
||||
self._config[CONF_STOP_STATUS], self._config[CONF_MODE_DP]
|
||||
)
|
||||
else:
|
||||
_LOGGER.error("Missing command for stop in commands set.")
|
||||
|
||||
async def async_clean_spot(self, **kwargs):
|
||||
"""Perform a spot clean-up."""
|
||||
return None
|
||||
|
||||
async def async_locate(self, **kwargs):
|
||||
"""Locate the vacuum cleaner."""
|
||||
if self.has_config(CONF_LOCATE_DP):
|
||||
await self._device.set_dp("", self._config[CONF_LOCATE_DP])
|
||||
|
||||
async def async_set_fan_speed(self, fan_speed, **kwargs):
|
||||
"""Set the fan speed."""
|
||||
await self._device.set_dp(fan_speed, self._config[CONF_FAN_SPEED_DP])
|
||||
|
||||
async def async_send_command(self, command, params=None, **kwargs):
|
||||
"""Send a command to a vacuum cleaner."""
|
||||
if command == "set_mode" and "mode" in params:
|
||||
mode = params["mode"]
|
||||
await self._device.set_dp(mode, self._config[CONF_MODE_DP])
|
||||
|
||||
def status_updated(self):
|
||||
"""Device status was updated."""
|
||||
state_value = str(self.dps(self._dp_id))
|
||||
|
||||
if state_value in self._idle_status_list:
|
||||
self._state = VacuumActivity.IDLE
|
||||
elif state_value in self._docked_status_list:
|
||||
self._state = VacuumActivity.DOCKED
|
||||
elif state_value == self._config[CONF_RETURNING_STATUS_VALUE]:
|
||||
self._state = VacuumActivity.RETURNING
|
||||
elif state_value == self._config[CONF_PAUSED_STATE]:
|
||||
self._state = VacuumActivity.PAUSED
|
||||
else:
|
||||
self._state = VacuumActivity.CLEANING
|
||||
|
||||
if self.has_config(CONF_BATTERY_DP):
|
||||
self._battery_level = self.dps_conf(CONF_BATTERY_DP)
|
||||
|
||||
self._cleaning_mode = ""
|
||||
if self.has_config(CONF_MODES):
|
||||
self._cleaning_mode = self.dps_conf(CONF_MODE_DP)
|
||||
self._attrs[MODE] = self._cleaning_mode
|
||||
|
||||
self._fan_speed = ""
|
||||
if self.has_config(CONF_FAN_SPEEDS):
|
||||
self._fan_speed = self.dps_conf(CONF_FAN_SPEED_DP)
|
||||
|
||||
if self.has_config(CONF_CLEAN_TIME_DP):
|
||||
self._attrs[CLEAN_TIME] = self.dps_conf(CONF_CLEAN_TIME_DP)
|
||||
|
||||
if self.has_config(CONF_CLEAN_AREA_DP):
|
||||
self._attrs[CLEAN_AREA] = self.dps_conf(CONF_CLEAN_AREA_DP)
|
||||
|
||||
if self.has_config(CONF_CLEAN_RECORD_DP):
|
||||
self._attrs[CLEAN_RECORD] = self.dps_conf(CONF_CLEAN_RECORD_DP)
|
||||
|
||||
if self.has_config(CONF_FAULT_DP):
|
||||
self._attrs[FAULT] = self.dps_conf(CONF_FAULT_DP)
|
||||
if self._attrs[FAULT] != 0:
|
||||
self._state = VacuumActivity.ERROR
|
||||
|
||||
|
||||
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaVacuum, flow_schema)
|
||||
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
The custom component for local network access to Midea appliances
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_API_VERSION,
|
||||
CONF_BROADCAST_ADDRESS,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_EXCLUDE,
|
||||
CONF_ID,
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_TTL,
|
||||
CONF_TYPE,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_registry import async_get
|
||||
from midea_beautiful.cloud import MideaCloud
|
||||
from midea_beautiful.exceptions import MideaError
|
||||
from midea_beautiful.lan import LanDevice
|
||||
from midea_beautiful.midea import SUPPORTED_APPS, DEFAULT_APP_ID, DEFAULT_APPKEY
|
||||
|
||||
from custom_components.midea_dehumidifier_lan.const import (
|
||||
CONF_MOBILE_APP,
|
||||
CONF_TOKEN_KEY,
|
||||
CONF_USE_CLOUD_OBSOLETE,
|
||||
DEFAULT_APP,
|
||||
DEFAULT_TTL,
|
||||
DISCOVERY_CLOUD,
|
||||
DISCOVERY_IGNORE,
|
||||
DISCOVERY_LAN,
|
||||
DISCOVERY_WAIT,
|
||||
DOMAIN,
|
||||
LOCAL_BROADCAST,
|
||||
NAME,
|
||||
CURRENT_CONFIG_VERSION,
|
||||
OBSOLETE_CONF_APPID,
|
||||
OBSOLETE_CONF_APPKEY,
|
||||
PLATFORMS,
|
||||
UNKNOWN_IP,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.hub import Hub
|
||||
from custom_components.midea_dehumidifier_lan.util import MideaClient, address_ok
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Set up platform from a ConfigEntry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
if (hub := hass.data[DOMAIN].get(config_entry.entry_id)) is None:
|
||||
hub = Hub(hass, config_entry)
|
||||
hass.data[DOMAIN][config_entry.entry_id] = hub
|
||||
await hub.async_setup()
|
||||
await _async_migrate_names(hass, config_entry)
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_migrate_names(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
entity_registry = async_get(hass)
|
||||
|
||||
conf = config_entry.data
|
||||
if devices := conf.get(CONF_DEVICES):
|
||||
old_entites = [
|
||||
entry
|
||||
for _, entry in entity_registry.entities.items()
|
||||
if entry.platform == DOMAIN
|
||||
]
|
||||
for reg_entry in old_entites:
|
||||
for device in devices:
|
||||
old_suffix = f"_{device[CONF_ID]}"
|
||||
new_suffix = f"_{device[CONF_UNIQUE_ID]}"
|
||||
if reg_entry.unique_id.endswith(old_suffix):
|
||||
prefix = reg_entry.unique_id[: -len(old_suffix)]
|
||||
old_unique_id = reg_entry.unique_id
|
||||
new_unique_id = f"{prefix}{new_suffix}"
|
||||
try:
|
||||
entity_registry.async_update_entity(
|
||||
reg_entry.entity_id,
|
||||
new_unique_id=new_unique_id,
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"Changed unique id of %s from %s to %s",
|
||||
reg_entry.entity_id,
|
||||
old_unique_id,
|
||||
new_unique_id,
|
||||
)
|
||||
except ValueError as ex:
|
||||
_LOGGER.error(
|
||||
"Unable to change unique id of %s: %s",
|
||||
reg_entry.entity_id,
|
||||
ex,
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hub: Hub = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
await hub.async_unload()
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old config entry to new version."""
|
||||
_LOGGER.debug("Migrating from version %s", config_entry.version)
|
||||
|
||||
if config_entry.version < CURRENT_CONFIG_VERSION:
|
||||
old_conf = config_entry.data
|
||||
old_broadcast = old_conf.get(CONF_BROADCAST_ADDRESS, [])
|
||||
if not old_broadcast:
|
||||
old_broadcast = [LOCAL_BROADCAST]
|
||||
new_conf = {
|
||||
CONF_MOBILE_APP: old_conf.get(CONF_MOBILE_APP),
|
||||
CONF_BROADCAST_ADDRESS: old_broadcast,
|
||||
CONF_USERNAME: old_conf.get(CONF_USERNAME),
|
||||
CONF_PASSWORD: old_conf.get(CONF_PASSWORD),
|
||||
}
|
||||
if not old_conf.get(OBSOLETE_CONF_APPID) or not old_conf.get(
|
||||
OBSOLETE_CONF_APPKEY
|
||||
):
|
||||
new_conf[CONF_MOBILE_APP] = DEFAULT_APP
|
||||
else:
|
||||
appkey = old_conf.get(OBSOLETE_CONF_APPKEY, DEFAULT_APPKEY)
|
||||
if appkey:
|
||||
for appname, appconf in SUPPORTED_APPS.items():
|
||||
if appconf["appkey"] == appkey:
|
||||
new_conf[CONF_MOBILE_APP] = appname
|
||||
break
|
||||
else:
|
||||
appid = old_conf.get(OBSOLETE_CONF_APPID, DEFAULT_APP_ID)
|
||||
for appname, appconf in SUPPORTED_APPS.items():
|
||||
if appconf["appid"] == appid:
|
||||
new_conf[CONF_MOBILE_APP] = appname
|
||||
break
|
||||
|
||||
new_devices = []
|
||||
new_conf[CONF_DEVICES] = new_devices
|
||||
|
||||
id_resolver = _ApplianceIdResolver(hass)
|
||||
|
||||
old: dict[str, Any]
|
||||
for old in config_entry.data[CONF_DEVICES]:
|
||||
new = {
|
||||
CONF_API_VERSION: old.get(CONF_API_VERSION),
|
||||
CONF_DISCOVERY: old.get(CONF_DISCOVERY),
|
||||
CONF_ID: old.get(CONF_ID),
|
||||
CONF_IP_ADDRESS: old.get(CONF_IP_ADDRESS, UNKNOWN_IP),
|
||||
CONF_NAME: old.get(CONF_NAME),
|
||||
CONF_TOKEN: old.get(CONF_TOKEN),
|
||||
CONF_TOKEN_KEY: old.get(CONF_TOKEN_KEY),
|
||||
CONF_TYPE: old.get(CONF_TYPE),
|
||||
CONF_UNIQUE_ID: old.get(CONF_UNIQUE_ID),
|
||||
CONF_TTL: old.get(CONF_TTL, DEFAULT_TTL),
|
||||
}
|
||||
|
||||
discovery_mode = new.get(CONF_DISCOVERY)
|
||||
if discovery_mode not in [
|
||||
DISCOVERY_WAIT,
|
||||
DISCOVERY_LAN,
|
||||
DISCOVERY_IGNORE,
|
||||
DISCOVERY_CLOUD,
|
||||
]:
|
||||
if old.get(CONF_USE_CLOUD_OBSOLETE):
|
||||
new[CONF_DISCOVERY] = DISCOVERY_CLOUD
|
||||
elif old.get(CONF_EXCLUDE):
|
||||
new[CONF_DISCOVERY] = DISCOVERY_IGNORE
|
||||
elif not address_ok(old.get(CONF_IP_ADDRESS)):
|
||||
new[CONF_DISCOVERY] = DISCOVERY_WAIT
|
||||
else:
|
||||
new[CONF_DISCOVERY] = DISCOVERY_LAN
|
||||
|
||||
await id_resolver.async_get_unique_id_if_missing(new_conf, new)
|
||||
new_devices.append(new)
|
||||
|
||||
config_entry.version = CURRENT_CONFIG_VERSION
|
||||
_LOGGER.debug(
|
||||
"Migrating configuration from %s to %s", config_entry.data, new_conf
|
||||
)
|
||||
if hass.config_entries.async_update_entry(
|
||||
config_entry, data=new_conf, title=NAME
|
||||
):
|
||||
_LOGGER.info("Configuration migrated to version %s", config_entry.version)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Configuration didn't change during migration to version %s",
|
||||
config_entry.version,
|
||||
)
|
||||
return id_resolver.success
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class _ApplianceIdResolver:
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
self.hass = hass
|
||||
self.client = MideaClient(hass)
|
||||
self.cloud: MideaCloud | None = None
|
||||
self.descriptors: list[dict] | None = None
|
||||
self.success = True
|
||||
|
||||
async def _start(self, conf: dict[str, Any]) -> None:
|
||||
try:
|
||||
self.cloud = await self.client.async_connect_to_cloud(conf)
|
||||
self.descriptors = await self.client.async_list_appliances(self.cloud)
|
||||
except MideaError as ex:
|
||||
_LOGGER.error(
|
||||
"Unable to get list of appliances during configuration migration %s.",
|
||||
ex,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def async_get_unique_id_if_missing(
|
||||
self,
|
||||
conf: dict[str, Any],
|
||||
device_conf: dict[str, Any],
|
||||
) -> None:
|
||||
"""If there is no unique_id assigned, try to find serial number"""
|
||||
if device_conf[CONF_UNIQUE_ID] is None:
|
||||
if device_conf[CONF_DISCOVERY] == DISCOVERY_LAN:
|
||||
appliance = await self._get_appliance_state(device_conf)
|
||||
device_conf[CONF_UNIQUE_ID] = appliance and appliance.serial_number
|
||||
if device_conf[CONF_UNIQUE_ID] is None:
|
||||
if self.cloud is None:
|
||||
await self._start(conf)
|
||||
self._find_unique_id_in_appliance_list(device_conf)
|
||||
if device_conf[CONF_UNIQUE_ID] is None:
|
||||
_LOGGER.error(
|
||||
"Unable to find serial number for appliance %s."
|
||||
"Please re-install %s integration.",
|
||||
device_conf[CONF_NAME],
|
||||
NAME,
|
||||
)
|
||||
self.success = False
|
||||
|
||||
def _find_unique_id_in_appliance_list(self, device_conf) -> None:
|
||||
if self.descriptors is not None:
|
||||
for app in self.descriptors:
|
||||
if app["id"] == device_conf[CONF_ID]:
|
||||
if app["sn"] and app["sn"] != "Unknown":
|
||||
device_conf[CONF_UNIQUE_ID] = app["sn"]
|
||||
else:
|
||||
_LOGGER.warning("Unable to get serial number for %s", app)
|
||||
break
|
||||
|
||||
async def _get_appliance_state(
|
||||
self,
|
||||
device_conf: dict[str, Any],
|
||||
cloud: MideaCloud = None,
|
||||
use_cloud: bool = False,
|
||||
) -> LanDevice | None:
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(
|
||||
self.client.appliance_state,
|
||||
device_conf[CONF_IP_ADDRESS],
|
||||
device_conf[CONF_TOKEN],
|
||||
device_conf[CONF_TOKEN_KEY],
|
||||
cloud,
|
||||
use_cloud,
|
||||
device_conf[CONF_ID],
|
||||
)
|
||||
except MideaError as ex:
|
||||
_LOGGER.error(
|
||||
"Unable to poll appliance during configuration migration %s.",
|
||||
ex,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Update coordinator for Midea devices"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from time import monotonic
|
||||
from typing import Any, cast, final
|
||||
|
||||
from homeassistant.const import CONF_DISCOVERY, CONF_TOKEN, CONF_TTL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
from homeassistant.util import slugify
|
||||
from midea_beautiful.appliance import AirConditionerAppliance, DehumidifierAppliance
|
||||
from midea_beautiful.cloud import MideaCloud
|
||||
from midea_beautiful.exceptions import MideaError
|
||||
from midea_beautiful.lan import LanDevice
|
||||
|
||||
from custom_components.midea_dehumidifier_lan.const import (
|
||||
APPLIANCE_REFRESH_COOLDOWN,
|
||||
APPLIANCE_REFRESH_INTERVAL,
|
||||
CONF_TOKEN_KEY,
|
||||
DEFAULT_TTL,
|
||||
DISCOVERY_CLOUD,
|
||||
DISCOVERY_IGNORE,
|
||||
DOMAIN,
|
||||
ENTITY_DISABLED_BY_DEFAULT,
|
||||
ENTITY_ENABLED_BY_DEFAULT,
|
||||
UNIQUE_DEHUMIDIFIER_PREFIX,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.util import (
|
||||
AbstractHub,
|
||||
ApplianceCoordinator,
|
||||
RedactedConf,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class ApplianceUpdateCoordinator(DataUpdateCoordinator, ApplianceCoordinator):
|
||||
"""Single class to retrieve data from an appliance"""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
hub: AbstractHub,
|
||||
appliance: LanDevice,
|
||||
device: dict[str, Any],
|
||||
available: bool,
|
||||
):
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=appliance.name,
|
||||
update_method=self._async_appliance_refresh,
|
||||
update_interval=timedelta(seconds=APPLIANCE_REFRESH_INTERVAL),
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass,
|
||||
_LOGGER,
|
||||
cooldown=APPLIANCE_REFRESH_COOLDOWN,
|
||||
immediate=True,
|
||||
function=self.async_refresh,
|
||||
),
|
||||
)
|
||||
self.hub = hub
|
||||
self.appliance = appliance
|
||||
self.updating = {}
|
||||
self.wait_for_update = False
|
||||
self.device = device
|
||||
self.discovery_mode = device.get(CONF_DISCOVERY, DISCOVERY_IGNORE)
|
||||
self.use_cloud: bool = self.discovery_mode == DISCOVERY_CLOUD
|
||||
self.available = available
|
||||
# TTL is in minutes
|
||||
self.time_to_leave = 60 * int(device.get(CONF_TTL, DEFAULT_TTL))
|
||||
self.has_failure = False
|
||||
self.first_failure_time: float = 0
|
||||
|
||||
def _cloud(self) -> MideaCloud | None:
|
||||
if self.use_cloud:
|
||||
if not self.hub.cloud:
|
||||
raise UpdateFailed(
|
||||
f"Midea cloud API was not initialized, {self.appliance}"
|
||||
f" configuration={RedactedConf(self.hub.config)}"
|
||||
)
|
||||
return self.hub.cloud
|
||||
return None
|
||||
|
||||
async def _async_appliance_refresh(self) -> LanDevice:
|
||||
"""Called to refresh appliance state"""
|
||||
|
||||
if not self.available:
|
||||
await self._async_try_to_detect()
|
||||
|
||||
if self.wait_for_update:
|
||||
return self.appliance
|
||||
|
||||
try:
|
||||
if self.updating:
|
||||
await self._async_do_update()
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
self.appliance.refresh, self._cloud()
|
||||
)
|
||||
self.has_failure = False
|
||||
except MideaError as ex:
|
||||
if not self.has_failure:
|
||||
self.has_failure = True
|
||||
self.first_failure_time = monotonic()
|
||||
if (monotonic() - self.first_failure_time) >= self.time_to_leave:
|
||||
raise UpdateFailed(str(ex)) from ex
|
||||
_LOGGER.warning(
|
||||
"Error fetching %s data: %s, will be trying again.", self.name, ex
|
||||
)
|
||||
finally:
|
||||
self.wait_for_update = False
|
||||
return self.appliance
|
||||
|
||||
async def _async_do_update(self):
|
||||
self.wait_for_update = True
|
||||
_LOGGER.debug("Updating attributes for %s: %s", self.appliance, self.updating)
|
||||
for attr in self.updating:
|
||||
setattr(self.appliance.state, attr, self.updating[attr])
|
||||
self.updating.clear()
|
||||
await self.hass.async_add_executor_job(self.appliance.apply, self._cloud())
|
||||
|
||||
async def _async_try_to_detect(self):
|
||||
_LOGGER.debug("Trying to find appliance %s", self.appliance)
|
||||
need_token, appliance = await self.hub.async_discover_device(self.device)
|
||||
if not appliance:
|
||||
raise UpdateFailed(self.hub.errors.get(str(self.appliance.serial_number)))
|
||||
if need_token:
|
||||
self.device[CONF_TOKEN] = appliance.token
|
||||
self.device[CONF_TOKEN_KEY] = appliance.key
|
||||
|
||||
self.appliance = appliance
|
||||
await self.hub.async_update_config()
|
||||
self.available = True
|
||||
|
||||
async def async_apply(self, args: dict) -> None:
|
||||
"""Applies changes to device"""
|
||||
for key, value in args.items():
|
||||
self.updating[key] = value
|
||||
await self.async_request_refresh()
|
||||
|
||||
|
||||
class ApplianceEntity(CoordinatorEntity):
|
||||
"""Represents an appliance that gets data from a coordinator"""
|
||||
|
||||
_unique_id_prefx = UNIQUE_DEHUMIDIFIER_PREFIX
|
||||
_name_suffix = ""
|
||||
_capability_attr = ""
|
||||
_add_extra_attrs = False
|
||||
_was_online_registered = False
|
||||
|
||||
def __init__(self, coordinator: ApplianceUpdateCoordinator) -> None:
|
||||
self.coordinator = coordinator
|
||||
self.appliance = coordinator.appliance
|
||||
self._set_enabled_for_capability()
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{self.unique_id_prefix}{self.appliance.serial_number}"
|
||||
self._attr_name = str(self.appliance.name or self.unique_id) + self.name_suffix
|
||||
if self._add_extra_attrs:
|
||||
self._attr_extra_state_attributes = {
|
||||
"last_error_code": 0,
|
||||
"last_error_time": datetime.now(),
|
||||
}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
# Override parent, we will handle state
|
||||
self.async_on_remove(self.coordinator.async_add_listener(self._updated_data))
|
||||
if self.coordinator.available:
|
||||
self.on_online(True)
|
||||
self._was_online_registered = True
|
||||
|
||||
@callback
|
||||
def _updated_data(self) -> None:
|
||||
"""Called when data has been updated by coordinator"""
|
||||
|
||||
self.appliance = self.coordinator.appliance
|
||||
self._attr_available = self.appliance.online
|
||||
if not self.coordinator.available:
|
||||
self.on_online(False)
|
||||
elif not self._was_online_registered:
|
||||
self.on_online(True)
|
||||
|
||||
if self.appliance.online:
|
||||
self.on_update()
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _set_enabled_for_capability(self) -> None:
|
||||
capability = self._capability_attr
|
||||
if not capability:
|
||||
return
|
||||
if capability == ENTITY_ENABLED_BY_DEFAULT:
|
||||
enabled = True
|
||||
elif capability == ENTITY_DISABLED_BY_DEFAULT:
|
||||
enabled = False
|
||||
elif capabilities := self.appliance.state.capabilities:
|
||||
enabled = capabilities.get(capability, False)
|
||||
elif hasattr(self, "_attr_entity_registry_enabled_default"):
|
||||
return
|
||||
else:
|
||||
enabled = False
|
||||
self._attr_entity_registry_enabled_default = enabled
|
||||
|
||||
def on_update(self) -> None:
|
||||
"""Allows additional processing after the coordinator updates data"""
|
||||
if self._add_extra_attrs:
|
||||
state = self.appliance.state
|
||||
_error_code = state.error_code
|
||||
|
||||
self._attr_extra_state_attributes |= {
|
||||
"capabilities": str(state.capabilities),
|
||||
"capabilities_data": state.capabilities_data.hex(),
|
||||
"error_code": _error_code,
|
||||
"last_data": state.latest_data.hex(),
|
||||
}
|
||||
if _error_code:
|
||||
self._attr_extra_state_attributes |= {
|
||||
"last_error_code": _error_code,
|
||||
"last_error_time": datetime.now(),
|
||||
}
|
||||
|
||||
def on_online(self, update: bool) -> None:
|
||||
"""To be called when appliance comes online for the first time"""
|
||||
|
||||
if update:
|
||||
self.on_update()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@final
|
||||
def dehumidifier(self) -> DehumidifierAppliance:
|
||||
"""Returns state as dehumidifier"""
|
||||
return cast(DehumidifierAppliance, self.appliance.state)
|
||||
|
||||
@final
|
||||
def airconditioner(self) -> AirConditionerAppliance:
|
||||
"""Returns state as air conditioner"""
|
||||
return cast(AirConditionerAppliance, self.appliance.state)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
if not self.coordinator.available:
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@property
|
||||
def name_suffix(self) -> str:
|
||||
"""Suffix to append to entity name"""
|
||||
return self._name_suffix
|
||||
|
||||
@property
|
||||
def unique_id_prefix(self) -> str:
|
||||
"""Prefix for entity id"""
|
||||
strip = self.name_suffix.strip()
|
||||
if len(strip) == 0:
|
||||
return self._unique_id_prefx
|
||||
slug = slugify(strip)
|
||||
return f"{self._unique_id_prefx}{slug}_"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
identifier = str(self.appliance.serial_number or self.appliance.serial_number)
|
||||
mac = self.appliance.mac
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, str(identifier))},
|
||||
name=self.appliance.name,
|
||||
manufacturer="Midea",
|
||||
model=str(self.appliance.model),
|
||||
sw_version=self.appliance.firmware_version,
|
||||
)
|
||||
|
||||
def apply(self, *args, **kwargs) -> None:
|
||||
"""Applies changes to device"""
|
||||
if len(args) % 2 != 0:
|
||||
raise ValueError(f"Expecting attribute/value pairs, had {len(args)} items")
|
||||
aargs = {}
|
||||
for i in range(0, len(args), 2):
|
||||
aargs[args[i]] = args[i + 1]
|
||||
for key, value in kwargs.items():
|
||||
aargs[key] = value
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.coordinator.async_apply(aargs), self.hass.loop
|
||||
).result()
|
||||
@@ -0,0 +1,402 @@
|
||||
"""The custom component for local network access to Midea appliances"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import ipaddress
|
||||
from itertools import chain, cycle
|
||||
import logging
|
||||
from typing import Any, Iterator, cast
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE
|
||||
from homeassistant.components.network import async_get_ipv4_broadcast_addresses
|
||||
from homeassistant.const import (
|
||||
CONF_API_VERSION,
|
||||
CONF_BROADCAST_ADDRESS,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_ID,
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_NAME,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TOKEN,
|
||||
CONF_TYPE,
|
||||
CONF_UNIQUE_ID,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from midea_beautiful.lan import LanDevice
|
||||
|
||||
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
|
||||
ApplianceUpdateCoordinator,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.const import (
|
||||
CONF_TOKEN_KEY,
|
||||
DEFAULT_DISCOVERY_MODE,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DISCOVERY_BATCH_SIZE,
|
||||
DISCOVERY_IGNORE,
|
||||
DISCOVERY_LAN,
|
||||
DISCOVERY_MODE_EXPLANATION,
|
||||
DISCOVERY_WAIT,
|
||||
LOCAL_BROADCAST,
|
||||
NAME,
|
||||
UNKNOWN_IP,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.util import (
|
||||
AbstractHub,
|
||||
RedactedConf,
|
||||
address_ok,
|
||||
supported_appliance,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def empty_address_iterator():
|
||||
"""No addresses to iterate"""
|
||||
yield from ()
|
||||
|
||||
|
||||
def _add_if_discoverable(conf_addresses: list[str], device: dict[str, Any]):
|
||||
if device.get(CONF_DISCOVERY) != DISCOVERY_LAN:
|
||||
if address_ok(device[CONF_IP_ADDRESS]):
|
||||
conf_addresses.append(device[CONF_IP_ADDRESS])
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ChangedDevice:
|
||||
device: LanDevice
|
||||
coordinator: ApplianceUpdateCoordinator
|
||||
|
||||
|
||||
class ApplianceDiscoveryHelper: # pylint: disable=too-many-instance-attributes
|
||||
"""Utility class to discover Midea appliances on local network"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hub: AbstractHub,
|
||||
) -> None:
|
||||
self.hass = hub.hass
|
||||
self.hub = hub
|
||||
self.new_devices: list[LanDevice] = []
|
||||
self.changed_devices: list[_ChangedDevice] = []
|
||||
self.broadcast_addresses: list[str] = []
|
||||
self.address_iterator: Iterator[list[str]] = empty_address_iterator()
|
||||
self.notifed_addresses: set[str] = set()
|
||||
self.remove_discovery: CALLBACK_TYPE | None = None
|
||||
self.conf_addresses: list[str] = []
|
||||
|
||||
def _admit_new(self) -> bool:
|
||||
"""Admits new devices into configurations"""
|
||||
need_reload = False
|
||||
added_devices: list[dict[str, Any]] = []
|
||||
dev_confs = self.hub.config[CONF_DEVICES]
|
||||
for new in self.new_devices:
|
||||
for known in dev_confs:
|
||||
if self._admitted_known_device(known, new):
|
||||
need_reload = True
|
||||
break
|
||||
else:
|
||||
added_devices.append(self._admit_not_known_device(new))
|
||||
need_reload = True
|
||||
|
||||
if added_devices:
|
||||
dev_confs += added_devices
|
||||
return need_reload
|
||||
|
||||
def _admit_not_known_device(self, new: LanDevice) -> dict[str, Any]:
|
||||
name = f"{new.model} {new.mac[-4] if new.mac else new.serial_number}"
|
||||
new_device = {
|
||||
CONF_DISCOVERY: DISCOVERY_IGNORE,
|
||||
CONF_API_VERSION: new.version,
|
||||
CONF_ID: new.appliance_id,
|
||||
CONF_IP_ADDRESS: new.address,
|
||||
CONF_NAME: name,
|
||||
CONF_TOKEN_KEY: new.key,
|
||||
CONF_TOKEN: new.token,
|
||||
CONF_TYPE: new.type,
|
||||
CONF_UNIQUE_ID: new.serial_number,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Found unknown device %s at %s.", name, new.address)
|
||||
msg = (
|
||||
f"Found previously unknown device {name} found on {new.address}."
|
||||
f" [Check it out.](/config/integrations)"
|
||||
)
|
||||
self.hass.components.persistent_notification.async_create(
|
||||
title=NAME,
|
||||
message=msg,
|
||||
notification_id=f"midea_unknown_{new.serial_number}",
|
||||
)
|
||||
return new_device
|
||||
|
||||
def _admitted_known_device(self, known: dict[str, Any], new: LanDevice) -> bool:
|
||||
need_reload = False
|
||||
if known[CONF_UNIQUE_ID] == new.serial_number:
|
||||
if known[CONF_DISCOVERY] == DISCOVERY_WAIT:
|
||||
update = {
|
||||
CONF_DISCOVERY: DISCOVERY_LAN,
|
||||
CONF_API_VERSION: new.version,
|
||||
CONF_ID: new.appliance_id,
|
||||
CONF_IP_ADDRESS: new.address,
|
||||
CONF_TOKEN_KEY: new.key,
|
||||
CONF_TOKEN: new.token,
|
||||
CONF_TYPE: new.type,
|
||||
CONF_UNIQUE_ID: new.serial_number,
|
||||
}
|
||||
_LOGGER.debug(
|
||||
"Updating discovered device %s, previous conf %s, conf update %s",
|
||||
new,
|
||||
known,
|
||||
update,
|
||||
)
|
||||
|
||||
msg = (
|
||||
"Device %(name)s,"
|
||||
" which was waiting to be discovered,"
|
||||
" was found on address %(address)s."
|
||||
" It will now be activated."
|
||||
) % {
|
||||
"name": known[CONF_NAME],
|
||||
"address": new.address,
|
||||
}
|
||||
self.hass.components.persistent_notification.async_create(
|
||||
title=NAME,
|
||||
message=msg,
|
||||
notification_id=f"midea_wait_discovery_{new.serial_number}",
|
||||
)
|
||||
known |= update
|
||||
need_reload = True
|
||||
elif new.address and known[CONF_DISCOVERY] != DISCOVERY_LAN:
|
||||
self._possible_lan_notification(new, known, new.address)
|
||||
|
||||
return need_reload
|
||||
|
||||
def _possible_lan_notification(
|
||||
self, device: LanDevice, known: dict[str, Any], address: str
|
||||
):
|
||||
if address not in self.notifed_addresses:
|
||||
_LOGGER.warning(
|
||||
"Device %s in mode %s found on address %s. "
|
||||
" It can be configured for local network access.",
|
||||
known[CONF_NAME],
|
||||
known[CONF_DISCOVERY],
|
||||
address,
|
||||
)
|
||||
self.notifed_addresses.add(address)
|
||||
|
||||
discovery_label = DISCOVERY_MODE_EXPLANATION.get(
|
||||
known[CONF_DISCOVERY], known[CONF_DISCOVERY]
|
||||
)
|
||||
msg = (
|
||||
"Device %(name)s,"
|
||||
" which is %(discovery_label)s,"
|
||||
" was found on address %(address)s."
|
||||
" It can be configured for local network access."
|
||||
" [Check it out.](/config/integrations)"
|
||||
) % {
|
||||
"name": known[CONF_NAME],
|
||||
"discovery_label": discovery_label,
|
||||
"address": address,
|
||||
}
|
||||
|
||||
self.hass.components.persistent_notification.async_create(
|
||||
title=NAME,
|
||||
message=msg,
|
||||
notification_id=f"midea_non_lan_discovery_{device.serial_number}",
|
||||
)
|
||||
|
||||
def _address_generator(self, batch_size: int = DISCOVERY_BATCH_SIZE):
|
||||
"""Generator for one batch of ip addresses to scan"""
|
||||
net_addrs = []
|
||||
addr_count = 0
|
||||
for addr in self.conf_addresses:
|
||||
# If local broadcast address we don't need to expand it
|
||||
if addr == LOCAL_BROADCAST:
|
||||
continue
|
||||
# Get network corresponding to address
|
||||
net = ipaddress.IPv4Network(addr)
|
||||
# If network references a block:
|
||||
if net.num_addresses > 1:
|
||||
_LOGGER.debug("Block %s with %d addresses", net, net.num_addresses)
|
||||
# collect all hosts from the block
|
||||
net_addrs.append(net.hosts())
|
||||
addr_count += net.num_addresses
|
||||
|
||||
# If we do have addresses to scan
|
||||
if net_addrs:
|
||||
# we will iterate over all of available addresses in batches
|
||||
# having batch_size items
|
||||
all_addrs = chain(*net_addrs)
|
||||
for _ in range(0, addr_count, batch_size):
|
||||
yield list(
|
||||
# We use filter to remove empty addresses
|
||||
filter(
|
||||
None,
|
||||
map(
|
||||
(lambda _: (x := next(all_addrs)) and str(x)),
|
||||
range(batch_size),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async def _async_run_discovery(self, devices: list[LanDevice]) -> None:
|
||||
"""Trigger config flows for discovered devices."""
|
||||
|
||||
dev_confs: list[dict[str, Any]] = self.hub.config[CONF_DEVICES]
|
||||
for dev_conf in dev_confs:
|
||||
dev_conf.setdefault(CONF_DISCOVERY, DEFAULT_DISCOVERY_MODE)
|
||||
dev_conf.setdefault(CONF_IP_ADDRESS, UNKNOWN_IP)
|
||||
|
||||
self._iterate_devices(devices)
|
||||
|
||||
need_reload = self._admit_new()
|
||||
devices_changed = self._merge_with_configuration()
|
||||
|
||||
if devices_changed or need_reload:
|
||||
_LOGGER.debug("Config entry needs to be updated")
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry=self.hub.config_entry,
|
||||
data=self.hub.config,
|
||||
)
|
||||
if need_reload:
|
||||
_LOGGER.debug("Config entry needs to be reloaded")
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(self.hub.config_entry.entry_id)
|
||||
)
|
||||
|
||||
def _iterate_devices(self, devices: list[LanDevice]):
|
||||
self.new_devices.clear()
|
||||
self.changed_devices.clear()
|
||||
for device in devices:
|
||||
if not device.address:
|
||||
continue
|
||||
coordinator = next(
|
||||
(
|
||||
cast(ApplianceUpdateCoordinator, coord)
|
||||
for coord in self.hub.coordinators
|
||||
if coord.appliance.serial_number == device.serial_number
|
||||
),
|
||||
None,
|
||||
)
|
||||
if coordinator:
|
||||
# If address changed, we need to handle it
|
||||
if device.address and device.address != coordinator.appliance.address:
|
||||
_LOGGER.debug(
|
||||
"Device %s changed address to %s",
|
||||
coordinator.name,
|
||||
device.address,
|
||||
)
|
||||
self.changed_devices.append(_ChangedDevice(device, coordinator))
|
||||
elif supported_appliance(self.hub.config, device):
|
||||
_LOGGER.debug("Discovered new device %s", device)
|
||||
self.new_devices.append(device)
|
||||
|
||||
def _merge_with_configuration(self: ApplianceDiscoveryHelper) -> bool:
|
||||
"""Merges list of changed devices with existing config entry configuration"""
|
||||
dev_confs: list[dict[str, Any]] = self.hub.config[CONF_DEVICES]
|
||||
updated_conf = False
|
||||
for changed in self.changed_devices:
|
||||
for known in dev_confs:
|
||||
coordinator = changed.coordinator
|
||||
device = changed.device
|
||||
if known[CONF_UNIQUE_ID] == coordinator.appliance.serial_number:
|
||||
coordinator.appliance.address = device.address
|
||||
known[CONF_IP_ADDRESS] = device.address
|
||||
updated_conf = True
|
||||
if device.address and known[CONF_DISCOVERY] != DISCOVERY_LAN:
|
||||
self._possible_lan_notification(
|
||||
coordinator.appliance,
|
||||
known,
|
||||
device.address,
|
||||
)
|
||||
break
|
||||
|
||||
return updated_conf
|
||||
|
||||
def _setup(self) -> None:
|
||||
"""Initializes address iterator.
|
||||
Address iterator allows iterating over adresses to broadcast to.
|
||||
It will iterate over all addresses in specified ranges.
|
||||
"""
|
||||
self.notifed_addresses.clear()
|
||||
self.conf_addresses.clear()
|
||||
has_discoverable = False
|
||||
device: dict[str, Any]
|
||||
for device in self.hub.config[CONF_DEVICES]:
|
||||
if _add_if_discoverable(self.conf_addresses, device):
|
||||
has_discoverable = True
|
||||
for coordinator in self.hub.coordinators:
|
||||
if not coordinator.available:
|
||||
if _add_if_discoverable(self.conf_addresses, coordinator.device):
|
||||
has_discoverable = True
|
||||
self.conf_addresses += [
|
||||
item
|
||||
for item in self.hub.config.get(CONF_BROADCAST_ADDRESS, []) or []
|
||||
if item and item != LOCAL_BROADCAST
|
||||
]
|
||||
self.broadcast_addresses = [LOCAL_BROADCAST]
|
||||
for addr in self.conf_addresses:
|
||||
net = ipaddress.IPv4Network(addr)
|
||||
self.broadcast_addresses.append(str(net.broadcast_address))
|
||||
|
||||
if has_discoverable and self.conf_addresses:
|
||||
_LOGGER.debug("Discovery via configured addresses %s", self.conf_addresses)
|
||||
self.address_iterator = cycle(self._address_generator())
|
||||
else:
|
||||
self.address_iterator = empty_address_iterator()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Starts periodic disovery of devices"""
|
||||
self.stop()
|
||||
try:
|
||||
self._setup()
|
||||
scan_interval = self.hub.config.get(
|
||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||
)
|
||||
if scan_interval:
|
||||
_LOGGER.debug(
|
||||
"Starting periodic discovery with interval %s minute(s),"
|
||||
" broadcast %s, configured %s",
|
||||
scan_interval,
|
||||
self.broadcast_addresses,
|
||||
self.conf_addresses,
|
||||
)
|
||||
self.remove_discovery = async_track_time_interval(
|
||||
self.hass, self._async_discover, timedelta(minutes=scan_interval)
|
||||
)
|
||||
except Exception as ex:
|
||||
_LOGGER.error(
|
||||
"Unable to setup up periodic discovery."
|
||||
" Please remove integration and then reinstall it to check if problem"
|
||||
" can be fixed."
|
||||
" Cause: %s"
|
||||
" Configuration: %s",
|
||||
ex,
|
||||
RedactedConf(self.hub.config),
|
||||
)
|
||||
self.stop()
|
||||
raise ex
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stops periodic disovery of devices"""
|
||||
if self.remove_discovery:
|
||||
_LOGGER.debug("Stopping periodic discovery")
|
||||
|
||||
self.remove_discovery()
|
||||
self.remove_discovery = None
|
||||
|
||||
async def _async_discover(self, _: datetime) -> None:
|
||||
"""Discover Midea appliances on configured network interfaces."""
|
||||
|
||||
addresses = list(address for address in self.broadcast_addresses)
|
||||
if new_addresses := next(self.address_iterator, None):
|
||||
addresses += new_addresses
|
||||
if not addresses:
|
||||
iface_broadcast = await async_get_ipv4_broadcast_addresses(self.hass)
|
||||
addresses += [str(address) for address in iface_broadcast]
|
||||
_LOGGER.debug("Initiated discovery via %s", addresses)
|
||||
result = self.hub.client.find_appliances(None, addresses, retries=1, timeout=1)
|
||||
if result:
|
||||
await self._async_run_discovery(result)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Adds binary sensors for appliances."""
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from midea_beautiful.midea import ERROR_CODE_BUCKET_FULL, ERROR_CODE_BUCKET_REMOVED
|
||||
|
||||
from custom_components.midea_dehumidifier_lan.const import (
|
||||
DOMAIN,
|
||||
UNIQUE_DEHUMIDIFIER_PREFIX,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
|
||||
ApplianceEntity,
|
||||
ApplianceUpdateCoordinator,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.hub import Hub
|
||||
from custom_components.midea_dehumidifier_lan.util import is_enabled_by_capabilities
|
||||
|
||||
|
||||
def _is_enabled(coordinator: ApplianceUpdateCoordinator, capability: str) -> bool:
|
||||
return is_enabled_by_capabilities(
|
||||
coordinator.appliance.state.capabilities, capability
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Sets up appliance binary sensors"""
|
||||
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
# Dehumidifier sensors
|
||||
async_add_entities(
|
||||
TankFullSensor(c) for c in hub.coordinators if c.is_dehumidifier()
|
||||
)
|
||||
# Add tank removed sensor if pump is supported
|
||||
async_add_entities(
|
||||
TankRemovedSensor(c)
|
||||
for c in hub.coordinators
|
||||
if c.is_dehumidifier() and _is_enabled(c, "pump")
|
||||
)
|
||||
async_add_entities(
|
||||
FilterReplacementSensor(c)
|
||||
for c in hub.coordinators
|
||||
if c.is_dehumidifier() and _is_enabled(c, "filter")
|
||||
)
|
||||
async_add_entities(
|
||||
DefrostingSensor(c) for c in hub.coordinators if c.is_dehumidifier()
|
||||
)
|
||||
|
||||
|
||||
class TankFullSensor(ApplianceEntity, BinarySensorEntity):
|
||||
"""
|
||||
Describes full tank binary sensors (indicated as problem as it prevents
|
||||
dehumidifier from operating)
|
||||
"""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
_name_suffix = " Tank Full"
|
||||
|
||||
def on_update(self) -> None:
|
||||
self._attr_is_on = (
|
||||
self.dehumidifier().tank_full
|
||||
or self.dehumidifier().error_code == ERROR_CODE_BUCKET_FULL
|
||||
)
|
||||
|
||||
|
||||
class TankRemovedSensor(ApplianceEntity, BinarySensorEntity):
|
||||
"""
|
||||
Shows that tank has been removed binary sensors (indicated as problem as it prevents
|
||||
dehumidifier from operating)
|
||||
"""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
_name_suffix = " Tank Removed"
|
||||
_capability_attr = "pump"
|
||||
|
||||
def on_update(self) -> None:
|
||||
self._attr_is_on = self.dehumidifier().error_code == ERROR_CODE_BUCKET_REMOVED
|
||||
|
||||
|
||||
class FilterReplacementSensor(ApplianceEntity, BinarySensorEntity):
|
||||
"""
|
||||
Describes filter replacement binary sensors (indicated as problem)
|
||||
"""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_name_suffix = " Replace Filter"
|
||||
_capability_attr = "filter"
|
||||
|
||||
@property
|
||||
def unique_id_prefix(self) -> str:
|
||||
"""Prefix for entity id"""
|
||||
return f"{UNIQUE_DEHUMIDIFIER_PREFIX}filter_"
|
||||
|
||||
def on_update(self) -> None:
|
||||
self._attr_is_on = self.dehumidifier().filter_indicator
|
||||
|
||||
|
||||
class DefrostingSensor(ApplianceEntity, BinarySensorEntity):
|
||||
"""
|
||||
Describes defrosting mode binary sensors (indicated as cold)
|
||||
"""
|
||||
|
||||
_attr_device_class = BinarySensorDeviceClass.COLD
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_name_suffix = " Defrosting"
|
||||
|
||||
def on_update(self) -> None:
|
||||
self._attr_is_on = self.dehumidifier().defrosting
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Adds climate entity for each air conditioner appliance."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.climate import ClimateEntity
|
||||
from homeassistant.components.climate.const import (
|
||||
ATTR_FAN_MODE,
|
||||
ATTR_HVAC_MODE,
|
||||
ATTR_SWING_MODE,
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
PRESET_BOOST,
|
||||
PRESET_ECO,
|
||||
PRESET_NONE,
|
||||
PRESET_SLEEP,
|
||||
PRESET_AWAY,
|
||||
PRESET_COMFORT,
|
||||
ClimateEntityFeature,
|
||||
SWING_BOTH,
|
||||
SWING_HORIZONTAL,
|
||||
SWING_OFF,
|
||||
SWING_VERTICAL,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
|
||||
ApplianceEntity,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.const import (
|
||||
ATTR_RUNNING,
|
||||
DOMAIN,
|
||||
MAX_TARGET_TEMPERATURE,
|
||||
MIN_TARGET_TEMPERATURE,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.hub import Hub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FAN_SILENT = "Silent"
|
||||
FAN_FULL = "Full"
|
||||
|
||||
HVAC_MODES: Final = [
|
||||
HVACMode.OFF,
|
||||
HVACMode.AUTO,
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.DRY,
|
||||
HVACMode.FAN_ONLY,
|
||||
]
|
||||
FAN_MODES: Final = [
|
||||
FAN_SILENT,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
FAN_HIGH,
|
||||
FAN_FULL,
|
||||
FAN_AUTO,
|
||||
]
|
||||
|
||||
SWING_MODES: Final = [SWING_OFF, SWING_HORIZONTAL, SWING_VERTICAL, SWING_BOTH]
|
||||
|
||||
PRESET_MODES: Final = [PRESET_NONE, PRESET_ECO, PRESET_BOOST, PRESET_SLEEP, PRESET_AWAY, PRESET_COMFORT]
|
||||
|
||||
_FAN_SPEEDS = {
|
||||
FAN_AUTO: 102,
|
||||
FAN_FULL: 100,
|
||||
FAN_HIGH: 80,
|
||||
FAN_MEDIUM: 60,
|
||||
FAN_LOW: 40,
|
||||
FAN_SILENT: 20,
|
||||
}
|
||||
|
||||
_MODES_TO_MIDEA = {
|
||||
HVACMode.AUTO: 1,
|
||||
HVACMode.COOL: 2,
|
||||
HVACMode.DRY: 3,
|
||||
HVACMode.HEAT: 4,
|
||||
HVACMode.FAN_ONLY: 5,
|
||||
}
|
||||
|
||||
_MIDEA_TO_MODES = {
|
||||
1: HVACMode.AUTO,
|
||||
2: HVACMode.COOL,
|
||||
3: HVACMode.DRY,
|
||||
4: HVACMode.HEAT,
|
||||
5: HVACMode.FAN_ONLY,
|
||||
}
|
||||
|
||||
_HVAC_ACTIONS = {
|
||||
HVACMode.OFF: HVACAction.OFF,
|
||||
HVACMode.AUTO: None,
|
||||
HVACMode.COOL: HVACAction.COOLING,
|
||||
HVACMode.DRY: HVACAction.DRYING,
|
||||
HVACMode.HEAT: HVACAction.HEATING,
|
||||
HVACMode.FAN_ONLY: HVACAction.FAN,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Sets up air conditioner entites"""
|
||||
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
AirConditionerEntity(c) for c in hub.coordinators if c.is_climate()
|
||||
)
|
||||
|
||||
|
||||
class AirConditionerEntity(ApplianceEntity, ClimateEntity):
|
||||
"""Climate entity for Midea air conditioner"""
|
||||
|
||||
_attr_hvac_modes = HVAC_MODES
|
||||
_attr_fan_modes = FAN_MODES
|
||||
_attr_preset_modes = PRESET_MODES
|
||||
_attr_swing_modes = SWING_MODES
|
||||
_attr_max_temp = MAX_TARGET_TEMPERATURE
|
||||
_attr_min_temp = MIN_TARGET_TEMPERATURE
|
||||
_attr_precision = PRECISION_HALVES
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.SWING_MODE
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
_name_suffix = ""
|
||||
_add_extra_attrs = True
|
||||
|
||||
def on_update(self) -> None:
|
||||
aircon = self.airconditioner()
|
||||
self._attr_current_temperature = aircon.indoor_temperature
|
||||
self._attr_target_temperature = aircon.target_temperature
|
||||
self._attr_fan_mode = self._fan_mode()
|
||||
self._attr_preset_mode = self._preset_mode()
|
||||
self._attr_swing_mode = self._swing_mode()
|
||||
self._attr_hvac_mode = self._hvac_mode()
|
||||
self._attr_hvac_action = _HVAC_ACTIONS.get(self._attr_hvac_mode)
|
||||
super().on_update()
|
||||
|
||||
def _fan_mode(self) -> str:
|
||||
fan_speed = self.airconditioner().fan_speed
|
||||
for mode, mode_speed in _FAN_SPEEDS.items():
|
||||
if fan_speed <= mode_speed:
|
||||
return mode
|
||||
return FAN_AUTO
|
||||
|
||||
def _preset_mode(self) -> str:
|
||||
if self.airconditioner().turbo:
|
||||
return PRESET_BOOST
|
||||
if self.airconditioner().eco_mode:
|
||||
return PRESET_ECO
|
||||
if self.airconditioner().comfort_sleep:
|
||||
return PRESET_SLEEP
|
||||
if self.airconditioner().frost_protect:
|
||||
return PRESET_AWAY
|
||||
if self.airconditioner().comfort_mode:
|
||||
return PRESET_COMFORT
|
||||
return PRESET_NONE
|
||||
|
||||
def _swing_mode(self) -> str:
|
||||
if self.airconditioner().vertical_swing:
|
||||
if self.airconditioner().horizontal_swing:
|
||||
return SWING_BOTH
|
||||
return SWING_VERTICAL
|
||||
if self.airconditioner().horizontal_swing:
|
||||
return SWING_HORIZONTAL
|
||||
return SWING_OFF
|
||||
|
||||
def _hvac_mode(self) -> str:
|
||||
if not self.airconditioner().running:
|
||||
return HVACMode.OFF
|
||||
|
||||
curr_mode = self.airconditioner().mode
|
||||
mode = _MIDEA_TO_MODES.get(curr_mode)
|
||||
if mode is None:
|
||||
mode = HVACMode.AUTO
|
||||
_LOGGER.warning("Unknown mode %d, reporting %s", curr_mode, mode)
|
||||
|
||||
return mode
|
||||
|
||||
def turn_on(self, **kwargs) -> None: # pylint: disable=unused-argument
|
||||
"""Turn the entity on."""
|
||||
self.apply(ATTR_RUNNING, True)
|
||||
|
||||
def turn_off(self, **kwargs) -> None: # pylint: disable=unused-argument
|
||||
"""Turn the entity off."""
|
||||
self.apply(ATTR_RUNNING, False)
|
||||
|
||||
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
self.turn_off()
|
||||
return
|
||||
midea_mode = _MODES_TO_MIDEA.get(hvac_mode)
|
||||
if midea_mode is None:
|
||||
_LOGGER.warning("Unsupported climate mode %s", hvac_mode)
|
||||
return
|
||||
# Make sure we are running
|
||||
if not self.airconditioner().running:
|
||||
self.turn_on()
|
||||
self.apply("mode", midea_mode)
|
||||
|
||||
def set_temperature(self, **kwargs) -> None:
|
||||
"""Set new target temperature."""
|
||||
if kwargs.get(ATTR_TEMPERATURE):
|
||||
self.apply("target_temperature", kwargs.get(ATTR_TEMPERATURE))
|
||||
if kwargs.get(ATTR_HVAC_MODE):
|
||||
self.set_hvac_mode(kwargs.get(ATTR_HVAC_MODE))
|
||||
if kwargs.get(ATTR_SWING_MODE):
|
||||
self.set_swing_mode(kwargs.get(ATTR_SWING_MODE))
|
||||
if kwargs.get(ATTR_FAN_MODE):
|
||||
self.set_fan_mode(kwargs.get(ATTR_FAN_MODE))
|
||||
|
||||
def set_swing_mode(self, swing_mode: str) -> None:
|
||||
if swing_mode == SWING_VERTICAL:
|
||||
self.apply(vertical_swing=True, horizontal_swing=False)
|
||||
elif swing_mode == SWING_HORIZONTAL:
|
||||
self.apply(vertical_swing=False, horizontal_swing=True)
|
||||
elif swing_mode == SWING_BOTH:
|
||||
self.apply(vertical_swing=True, horizontal_swing=True)
|
||||
else:
|
||||
self.apply(vertical_swing=False, horizontal_swing=False)
|
||||
|
||||
def set_fan_mode(self, fan_mode: str) -> None:
|
||||
self.apply(fan_speed=_FAN_SPEEDS.get(fan_mode, 20))
|
||||
|
||||
def set_preset_mode(self, preset_mode: str) -> None:
|
||||
if preset_mode == PRESET_BOOST:
|
||||
self.apply(turbo=True, eco_mode=False, comfort_sleep=False, frost_protect=False, comfort_mode=False)
|
||||
elif preset_mode == PRESET_ECO:
|
||||
self.apply(turbo=False, eco_mode=True, comfort_sleep=False, frost_protect=False, comfort_mode=False)
|
||||
elif preset_mode == PRESET_SLEEP:
|
||||
self.apply(turbo=False, eco_mode=False, comfort_sleep=True, frost_protect=False, comfort_mode=False)
|
||||
elif preset_mode == PRESET_AWAY:
|
||||
self.apply(turbo=False, eco_mode=False, comfort_sleep=False, frost_protect=True, comfort_mode=False)
|
||||
elif preset_mode == PRESET_SLEEP:
|
||||
self.apply(turbo=False, eco_mode=False, comfort_sleep=False, frost_protect=False, comfort_mode=True)
|
||||
else:
|
||||
self.apply(turbo=False, eco_mode=False, comfort_sleep=False, frost_protect=False, comfort_mode=False)
|
||||
@@ -0,0 +1,727 @@
|
||||
"""Config flow for Midea Air Appliance (Local) integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
||||
from homeassistant.const import (
|
||||
ATTR_ID,
|
||||
ATTR_NAME,
|
||||
CONF_API_VERSION,
|
||||
CONF_BROADCAST_ADDRESS,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_ID,
|
||||
CONF_INCLUDE,
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TOKEN,
|
||||
CONF_TTL,
|
||||
CONF_TYPE,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowHandler, FlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
import voluptuous as vol
|
||||
|
||||
from midea_beautiful.cloud import MideaCloud
|
||||
from midea_beautiful.exceptions import (
|
||||
AuthenticationError,
|
||||
CloudAuthenticationError,
|
||||
CloudError,
|
||||
MideaError,
|
||||
MideaNetworkError,
|
||||
ProtocolError,
|
||||
RetryLaterError,
|
||||
)
|
||||
from midea_beautiful.lan import LanDevice
|
||||
from midea_beautiful.midea import (
|
||||
APPLIANCE_TYPE_DEHUMIDIFIER,
|
||||
SUPPORTED_APPS,
|
||||
)
|
||||
|
||||
from custom_components.midea_dehumidifier_lan import Hub
|
||||
from custom_components.midea_dehumidifier_lan.const import (
|
||||
NAME,
|
||||
CURRENT_CONFIG_VERSION,
|
||||
SUPPORTED_APPLIANCES,
|
||||
CONF_ADVANCED_SETTINGS,
|
||||
CONF_DEBUG,
|
||||
CONF_MOBILE_APP,
|
||||
CONF_TOKEN_KEY,
|
||||
DEFAULT_APP,
|
||||
DEFAULT_DISCOVERY_MODE,
|
||||
DEFAULT_PASSWORD,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_TTL,
|
||||
DEFAULT_USERNAME,
|
||||
DISCOVERY_CLOUD,
|
||||
DISCOVERY_IGNORE,
|
||||
DISCOVERY_LAN,
|
||||
DISCOVERY_MODE_LABELS,
|
||||
DISCOVERY_WAIT,
|
||||
DOMAIN,
|
||||
LOCAL_BROADCAST,
|
||||
UNKNOWN_IP,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.util import (
|
||||
MideaClient,
|
||||
RedactedConf,
|
||||
address_ok,
|
||||
supported_appliance,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _appliance_schema( # pylint: disable=too-many-arguments
|
||||
name: str,
|
||||
address: str = UNKNOWN_IP,
|
||||
ttl: int = DEFAULT_TTL,
|
||||
token: str = "",
|
||||
token_key: str = "",
|
||||
discovery_mode=DISCOVERY_WAIT,
|
||||
) -> vol.Schema:
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_DISCOVERY, default=str(discovery_mode)): vol.In(
|
||||
DISCOVERY_MODE_LABELS
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_IP_ADDRESS,
|
||||
default=address or UNKNOWN_IP,
|
||||
): cv.string,
|
||||
vol.Required(CONF_NAME, default=name): cv.string,
|
||||
vol.Required(
|
||||
CONF_TTL,
|
||||
msg="Test",
|
||||
default=ttl,
|
||||
description={"suffix": "minutes"},
|
||||
): cv.positive_int,
|
||||
vol.Optional(CONF_TOKEN, default=token or ""): cv.string,
|
||||
vol.Optional(CONF_TOKEN_KEY, default=token_key or ""): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def _advanced_settings_schema(
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
app: str = DEFAULT_APP,
|
||||
broadcast_address: str = "",
|
||||
appliances: list[str] = None,
|
||||
debug: bool = False,
|
||||
) -> vol.Schema:
|
||||
appliances = appliances or [APPLIANCE_TYPE_DEHUMIDIFIER]
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default=username): cv.string,
|
||||
vol.Required(CONF_PASSWORD, default=password): cv.string,
|
||||
vol.Optional(CONF_MOBILE_APP, default=app): vol.In(SUPPORTED_APPS.keys()),
|
||||
vol.Optional(CONF_BROADCAST_ADDRESS, default=broadcast_address): cv.string,
|
||||
vol.Required(
|
||||
CONF_SCAN_INTERVAL,
|
||||
msg="Test",
|
||||
default=DEFAULT_SCAN_INTERVAL,
|
||||
description={"suffix": "minutes"},
|
||||
): cv.positive_int,
|
||||
vol.Required(CONF_INCLUDE, default=appliances): vol.All(
|
||||
cv.multi_select(SUPPORTED_APPLIANCES),
|
||||
vol.Length(min=1, msg="Must select at least one appliance category"),
|
||||
),
|
||||
vol.Required(CONF_DEBUG, default=debug): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _reauth_schema(
|
||||
username: str,
|
||||
password: str,
|
||||
) -> vol.Schema:
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default=username): cv.string,
|
||||
vol.Required(CONF_PASSWORD, default=password): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _user_schema(username: str, password: str, app: str) -> vol.Schema:
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default=username): cv.string,
|
||||
vol.Required(CONF_PASSWORD, default=password): cv.string,
|
||||
vol.Optional(CONF_MOBILE_APP, default=app): vol.In(SUPPORTED_APPS.keys()),
|
||||
vol.Required(CONF_ADVANCED_SETTINGS, default=False): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class _MideaFlow(FlowHandler):
|
||||
"""Base class for Midea data flows"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.appliance_idx = -1
|
||||
self.appliances: list[LanDevice] = []
|
||||
self._client: MideaClient | None = None
|
||||
self.cloud: MideaCloud | None = None # type: ignore
|
||||
self.conf = {}
|
||||
self.config_entry: ConfigEntry | None = None
|
||||
self.devices_conf: list[dict[str, Any]] = []
|
||||
self.discovered_appliances: list[LanDevice | None] = []
|
||||
self.error_cause: str = ""
|
||||
self.errors: dict[str, Any] = {}
|
||||
self.indexes_to_process = []
|
||||
|
||||
@property
|
||||
def client(self) -> MideaClient:
|
||||
"""Returns instance of MideaClient."""
|
||||
if not self._client:
|
||||
self._client = MideaClient(self.hass)
|
||||
return self._client
|
||||
|
||||
def _process_exception(self: _MideaFlow, ex: Exception) -> None:
|
||||
if isinstance(ex, _FlowException):
|
||||
_LOGGER.warning(
|
||||
"Caught flow exception during appliance step %s", ex, exc_info=True
|
||||
)
|
||||
self.error_cause = str(ex.cause)
|
||||
self.errors["base"] = ex.message
|
||||
elif isinstance(ex, CloudAuthenticationError):
|
||||
self.error_cause = f"{ex.error_code} - {ex.message}"
|
||||
self.errors["base"] = "invalid_auth"
|
||||
elif isinstance(ex, CloudError):
|
||||
self.error_cause = f"{ex.error_code} - {ex.message}"
|
||||
self.errors["base"] = "midea_client"
|
||||
elif isinstance(ex, RetryLaterError):
|
||||
self.error_cause = f"{ex.error_code} - {ex.message}"
|
||||
self.errors["base"] = "retry_later"
|
||||
elif isinstance(ex, MideaError):
|
||||
self.error_cause = f"{ex.message}"
|
||||
self.errors["base"] = "midea_client"
|
||||
else:
|
||||
raise ex
|
||||
|
||||
def _connect_to_cloud(self: _MideaFlow, extra_conf: dict[str, Any] = None) -> None:
|
||||
"""Validates that cloud credentials are valid"""
|
||||
cfg = self.conf | (extra_conf or {})
|
||||
try:
|
||||
self.cloud = self.client.connect_to_cloud(cfg)
|
||||
except MideaError as ex:
|
||||
raise _FlowException("no_cloud", str(ex)) from ex
|
||||
|
||||
def _validate_appliance(
|
||||
self: _MideaFlow, appliance: LanDevice, device_conf: dict
|
||||
) -> LanDevice | None:
|
||||
"""
|
||||
Validates that appliance configuration is correct and matches physical
|
||||
device
|
||||
"""
|
||||
discovery_mode = device_conf.get(CONF_DISCOVERY, DEFAULT_DISCOVERY_MODE)
|
||||
if discovery_mode == DISCOVERY_IGNORE:
|
||||
_LOGGER.debug("Ignoring appliance %s", appliance)
|
||||
return None
|
||||
if discovery_mode == DISCOVERY_WAIT:
|
||||
_LOGGER.debug(
|
||||
"Attempt to discover appliance %s will be made later",
|
||||
appliance,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
if discovery_mode == DISCOVERY_CLOUD:
|
||||
discovered = self.client.appliance_state(
|
||||
appliance_id=appliance.appliance_id,
|
||||
cloud=self.cloud,
|
||||
use_cloud=True,
|
||||
)
|
||||
else: # DISCOVERY_LAN
|
||||
ip_address = appliance.address
|
||||
if not address_ok(ip_address):
|
||||
raise _FlowException("invalid_ip_address", ip_address)
|
||||
try:
|
||||
IPv4Address(ip_address)
|
||||
except Exception as ex:
|
||||
_LOGGER.debug("Invalid appliance address %s: %s", ip_address, ex)
|
||||
raise _FlowException("invalid_ip_address", ip_address) from ex
|
||||
discovered = self.client.appliance_state(
|
||||
address=ip_address, cloud=self.cloud
|
||||
)
|
||||
except ProtocolError as ex:
|
||||
raise _FlowException("connection_error", str(ex)) from ex
|
||||
except AuthenticationError as ex:
|
||||
raise _FlowException("invalid_auth", str(ex)) from ex
|
||||
except MideaNetworkError as ex:
|
||||
raise _FlowException("cannot_connect", str(ex)) from ex
|
||||
except MideaError as ex:
|
||||
raise _FlowException("not_discovered", str(ex)) from ex
|
||||
if discovered is None:
|
||||
raise _FlowException("not_discovered", appliance.address)
|
||||
return discovered
|
||||
|
||||
async def _async_add_entry(self: _MideaFlow) -> FlowResult:
|
||||
supported_devices_conf = []
|
||||
for i, appliance in enumerate(self.appliances):
|
||||
if not supported_appliance(self.conf, appliance):
|
||||
continue
|
||||
device_conf = self.devices_conf[i]
|
||||
|
||||
if device_conf.get(CONF_DISCOVERY) != DISCOVERY_IGNORE:
|
||||
device_conf |= {
|
||||
CONF_API_VERSION: appliance.version,
|
||||
CONF_ID: appliance.appliance_id,
|
||||
CONF_IP_ADDRESS: (
|
||||
appliance.address or device_conf[CONF_IP_ADDRESS] or UNKNOWN_IP
|
||||
),
|
||||
CONF_NAME: appliance.name,
|
||||
CONF_TOKEN_KEY: appliance.key,
|
||||
CONF_TOKEN: appliance.token,
|
||||
CONF_TYPE: appliance.type,
|
||||
CONF_UNIQUE_ID: appliance.serial_number,
|
||||
}
|
||||
suggested_discovery = (
|
||||
DISCOVERY_LAN
|
||||
if address_ok(device_conf[CONF_IP_ADDRESS])
|
||||
else DISCOVERY_WAIT
|
||||
)
|
||||
device_conf.get(CONF_DISCOVERY, suggested_discovery)
|
||||
supported_devices_conf.append(device_conf)
|
||||
self.devices_conf = supported_devices_conf
|
||||
self.conf[CONF_DEVICES] = self.devices_conf
|
||||
|
||||
# Remove not used elements
|
||||
self.conf.pop(CONF_ADVANCED_SETTINGS, None)
|
||||
if self.config_entry:
|
||||
_LOGGER.debug("Updating configuration data %s", RedactedConf(self.conf))
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry=self.config_entry, data=self.conf
|
||||
)
|
||||
# Reload the config entry otherwise devices will remain unavailable
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
||||
)
|
||||
|
||||
if not self.devices_conf:
|
||||
_LOGGER.debug("No configured appliances %s", RedactedConf(self.conf))
|
||||
return self.async_abort(reason="no_configured_devices")
|
||||
_LOGGER.debug("Creating configuration data %s", RedactedConf(self.conf))
|
||||
return self.async_create_entry(title=NAME, data=self.conf)
|
||||
|
||||
async def _async_step_appliance( # pylint: disable=too-many-locals
|
||||
self: _MideaFlow,
|
||||
step_id: str,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> FlowResult:
|
||||
"""Manage an appliances"""
|
||||
|
||||
self.errors.clear()
|
||||
self.error_cause = ""
|
||||
appliance = self.appliances[self.appliance_idx]
|
||||
device_conf = self.devices_conf[self.appliance_idx]
|
||||
discovery_mode = device_conf.get(CONF_DISCOVERY, DEFAULT_DISCOVERY_MODE)
|
||||
ttl = device_conf.get(CONF_TTL, DEFAULT_TTL)
|
||||
ip_address = appliance.address or UNKNOWN_IP
|
||||
if user_input is not None:
|
||||
try:
|
||||
|
||||
ip_address = user_input.get(
|
||||
CONF_IP_ADDRESS, device_conf.get(CONF_IP_ADDRESS, UNKNOWN_IP)
|
||||
)
|
||||
self._check_ip_address_unique(ip_address)
|
||||
|
||||
discovery_mode = user_input.get(CONF_DISCOVERY, discovery_mode)
|
||||
if discovery_mode not in [
|
||||
DISCOVERY_WAIT,
|
||||
DISCOVERY_LAN,
|
||||
DISCOVERY_IGNORE,
|
||||
DISCOVERY_CLOUD,
|
||||
]:
|
||||
discovery_mode = (
|
||||
DISCOVERY_LAN if address_ok(ip_address) else DISCOVERY_CLOUD
|
||||
)
|
||||
device_conf[CONF_DISCOVERY] = discovery_mode
|
||||
device_conf[CONF_TTL] = user_input.get(CONF_TTL, ttl)
|
||||
appliance.address = ip_address
|
||||
appliance.name = user_input.get(CONF_NAME, appliance.name)
|
||||
appliance.token = user_input.get(CONF_TOKEN, "")
|
||||
appliance.key = user_input.get(CONF_TOKEN_KEY, "")
|
||||
|
||||
if not self.cloud:
|
||||
await self.hass.async_add_executor_job(self._connect_to_cloud)
|
||||
|
||||
discovered = await self.hass.async_add_executor_job(
|
||||
self._validate_appliance,
|
||||
appliance,
|
||||
device_conf,
|
||||
)
|
||||
self.discovered_appliances[self.appliance_idx] = discovered
|
||||
|
||||
if not self.indexes_to_process:
|
||||
self._update_appliances_after_flow()
|
||||
|
||||
return await self._async_add_entry()
|
||||
|
||||
self.appliance_idx = self.indexes_to_process.pop(0)
|
||||
appliance = self.appliances[self.appliance_idx]
|
||||
device_conf = self.devices_conf[self.appliance_idx]
|
||||
ip_address = appliance.address or UNKNOWN_IP
|
||||
user_input = None
|
||||
discovery_mode = DEFAULT_DISCOVERY_MODE
|
||||
ttl = DEFAULT_TTL
|
||||
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
self._process_exception(ex)
|
||||
|
||||
name = appliance.name
|
||||
extra = {
|
||||
"index": str(self.appliance_idx + 1),
|
||||
"count": str(len(self.appliances)),
|
||||
"serial_number": appliance.serial_number,
|
||||
}
|
||||
placeholders = self._placeholders(appliance, extra)
|
||||
schema_arg = {
|
||||
"name": name,
|
||||
"address": device_conf.get(CONF_IP_ADDRESS, ip_address),
|
||||
"token": device_conf.get(CONF_TOKEN, appliance.token),
|
||||
"token_key": device_conf.get(CONF_TOKEN_KEY, appliance.key),
|
||||
"ttl": device_conf.get(CONF_TTL, ttl),
|
||||
"discovery_mode": device_conf.get(CONF_DISCOVERY, discovery_mode),
|
||||
}
|
||||
schema = _appliance_schema(**schema_arg)
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=schema,
|
||||
description_placeholders=placeholders,
|
||||
errors=self.errors,
|
||||
last_step=len(self.indexes_to_process) == 0,
|
||||
)
|
||||
|
||||
def _check_ip_address_unique(self, ip_address) -> None:
|
||||
if address_ok(ip_address):
|
||||
for i in range(self.appliance_idx):
|
||||
if (
|
||||
self.devices_conf[i].get(CONF_IP_ADDRESS) == ip_address
|
||||
or ip_address == self.appliances[i].address
|
||||
):
|
||||
raise _FlowException(
|
||||
"duplicate_ip_provided", self.appliances[i].name
|
||||
)
|
||||
|
||||
def _update_appliances_after_flow(self) -> None:
|
||||
for i, discovered in enumerate(self.discovered_appliances):
|
||||
if discovered:
|
||||
old_address = self.appliances[i].address
|
||||
self.appliances[i].update(discovered)
|
||||
if not discovered.address:
|
||||
self.appliances[i].address = old_address
|
||||
|
||||
def _placeholders(
|
||||
self: _MideaFlow, appliance: LanDevice = None, extra: dict[str, str] = None
|
||||
) -> dict[str, str]:
|
||||
extra = extra or {}
|
||||
placeholders = {
|
||||
"cause": self.error_cause or "",
|
||||
**extra,
|
||||
}
|
||||
if appliance:
|
||||
placeholders[ATTR_ID] = (
|
||||
appliance.serial_number or f"{appliance.appliance_id} (Missing S/N)"
|
||||
)
|
||||
placeholders[ATTR_NAME] = appliance.name
|
||||
|
||||
return placeholders
|
||||
|
||||
|
||||
def _get_broadcast_addresses(user_input: dict[str, Any]) -> list[str]:
|
||||
address_entry = str(user_input.get(CONF_BROADCAST_ADDRESS, ""))
|
||||
addresses = [LOCAL_BROADCAST]
|
||||
specified_addresses = [
|
||||
addr.strip() for addr in address_entry.split(",") if addr.strip()
|
||||
]
|
||||
for addr in specified_addresses:
|
||||
_LOGGER.debug("Trying IPv4 %s", addr)
|
||||
try:
|
||||
IPv4Network(addr)
|
||||
addresses.append(addr)
|
||||
except ValueError as ex:
|
||||
raise _FlowException("invalid_ip_range", str(ex)) from ex
|
||||
except Exception as ex:
|
||||
_LOGGER.debug("Invalid IP address %s", addr, exc_info=True)
|
||||
raise _FlowException("invalid_ip_range", addr) from ex
|
||||
return addresses
|
||||
|
||||
|
||||
class _FlowException(Exception):
|
||||
def __init__(self, message, cause: str = None) -> None:
|
||||
super().__init__()
|
||||
self.message = message
|
||||
self.cause = cause
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class MideaConfigFlow(ConfigFlow, _MideaFlow, domain=DOMAIN):
|
||||
"""Configuration flow for Midea dehumidifiers on local network uses
|
||||
discovery based on Midea cloud, so it first requires credentials for it.
|
||||
If some appliances are registered in the cloud, but not discovered, configuration
|
||||
flow will prompt for additional information.
|
||||
"""
|
||||
|
||||
VERSION = CURRENT_CONFIG_VERSION
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.discovered_appliances: list[LanDevice | None] = []
|
||||
self.appliances: list[LanDevice] = []
|
||||
self.config_entry: ConfigEntry | None = None
|
||||
self.advanced_settings = False
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlow:
|
||||
"""Define the config flow to handle options."""
|
||||
return MideaOptionsFlow(config_entry)
|
||||
|
||||
def _connect_and_discover(self: MideaConfigFlow) -> None:
|
||||
"""Validates that cloud credentials are valid and discovers local appliances"""
|
||||
|
||||
self._connect_to_cloud()
|
||||
conf_addresses = self.conf.get(CONF_BROADCAST_ADDRESS, [])
|
||||
if isinstance(conf_addresses, str):
|
||||
conf_addresses = [conf_addresses]
|
||||
addresses = [
|
||||
str(IPv4Network(addr).broadcast_address) for addr in conf_addresses
|
||||
]
|
||||
self.appliances.clear()
|
||||
self.appliances += self.client.find_appliances(self.cloud, addresses)
|
||||
self.devices_conf = [{} for _ in self.appliances]
|
||||
|
||||
async def _validate_discovery_phase(
|
||||
self, user_input: dict[str, Any] | None
|
||||
) -> FlowResult:
|
||||
assert user_input is not None
|
||||
self.conf[CONF_USERNAME] = user_input[CONF_USERNAME]
|
||||
self.conf[CONF_PASSWORD] = user_input[CONF_PASSWORD]
|
||||
|
||||
if self.advanced_settings:
|
||||
assert self.conf is not None
|
||||
self.conf[CONF_MOBILE_APP] = user_input.get(CONF_MOBILE_APP, DEFAULT_APP)
|
||||
self.conf[CONF_INCLUDE] = user_input[CONF_INCLUDE]
|
||||
self.conf[CONF_SCAN_INTERVAL] = user_input[CONF_SCAN_INTERVAL]
|
||||
self.conf[CONF_DEBUG] = user_input[CONF_DEBUG]
|
||||
self.conf[CONF_BROADCAST_ADDRESS] = _get_broadcast_addresses(user_input)
|
||||
|
||||
else:
|
||||
self.conf[CONF_MOBILE_APP] = user_input.get(CONF_MOBILE_APP, DEFAULT_APP)
|
||||
if user_input.get(CONF_ADVANCED_SETTINGS):
|
||||
return await self.async_step_advanced_settings()
|
||||
|
||||
self.conf[CONF_BROADCAST_ADDRESS] = []
|
||||
self.conf[CONF_INCLUDE] = [APPLIANCE_TYPE_DEHUMIDIFIER]
|
||||
self.conf[CONF_SCAN_INTERVAL] = DEFAULT_SCAN_INTERVAL
|
||||
|
||||
if self.conf.get(CONF_DEBUG, False):
|
||||
await self.client.async_debug_mode(True)
|
||||
await self.hass.async_add_executor_job(self._connect_and_discover)
|
||||
|
||||
self.indexes_to_process = [
|
||||
index
|
||||
for index, appliance in enumerate(self.appliances)
|
||||
if supported_appliance(self.conf, appliance)
|
||||
and not address_ok(appliance.address)
|
||||
]
|
||||
if self.indexes_to_process:
|
||||
self.appliance_idx = self.indexes_to_process.pop(0)
|
||||
self.discovered_appliances = [None] * len(self.devices_conf)
|
||||
return await self.async_step_unreachable_appliance()
|
||||
|
||||
return await self._async_add_entry()
|
||||
|
||||
async def _do_validate(self, user_input: dict[str, Any]) -> FlowResult | None:
|
||||
try:
|
||||
return await self._validate_discovery_phase(user_input)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
self._process_exception(ex)
|
||||
return None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
self.advanced_settings = False
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
self.errors.clear()
|
||||
self.error_cause = ""
|
||||
|
||||
username = DEFAULT_USERNAME
|
||||
password = DEFAULT_PASSWORD
|
||||
app = DEFAULT_APP
|
||||
if user_input is not None:
|
||||
username = user_input.get(CONF_USERNAME, username)
|
||||
password = user_input.get(CONF_PASSWORD, password)
|
||||
app = user_input.get(CONF_MOBILE_APP, app)
|
||||
res = await self._do_validate(user_input)
|
||||
if res:
|
||||
return res
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=_user_schema(username=username, password=password, app=app),
|
||||
description_placeholders=self._placeholders(),
|
||||
errors=self.errors,
|
||||
)
|
||||
|
||||
async def async_step_advanced_settings(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Step for managing advanced settings"""
|
||||
self.errors = {}
|
||||
self.error_cause = ""
|
||||
self.advanced_settings = True
|
||||
if user_input is not None:
|
||||
if res := await self._do_validate(user_input):
|
||||
return res
|
||||
else:
|
||||
user_input = {}
|
||||
|
||||
username = user_input.get(
|
||||
CONF_USERNAME, self.conf.get(CONF_USERNAME, DEFAULT_USERNAME)
|
||||
)
|
||||
password = user_input.get(
|
||||
CONF_PASSWORD, self.conf.get(CONF_PASSWORD, DEFAULT_PASSWORD)
|
||||
)
|
||||
app = user_input.get(CONF_MOBILE_APP, DEFAULT_APP)
|
||||
broadcast_addresses = user_input.get(
|
||||
CONF_BROADCAST_ADDRESS, ",".join(self.conf.get(CONF_BROADCAST_ADDRESS, []))
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="advanced_settings",
|
||||
data_schema=_advanced_settings_schema(
|
||||
username=username,
|
||||
password=password,
|
||||
app=app,
|
||||
broadcast_address=broadcast_addresses,
|
||||
),
|
||||
description_placeholders=self._placeholders(),
|
||||
errors=self.errors,
|
||||
)
|
||||
|
||||
async def async_step_unreachable_appliance(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the appliances that were not discovered automatically on LAN."""
|
||||
|
||||
return await self._async_step_appliance(
|
||||
step_id="unreachable_appliance",
|
||||
user_input=user_input,
|
||||
)
|
||||
|
||||
async def _async_add_entry(self) -> FlowResult:
|
||||
assert self.conf is not None
|
||||
self.config_entry = await self.async_set_unique_id(self.conf[CONF_USERNAME])
|
||||
return await super()._async_add_entry()
|
||||
|
||||
async def async_step_reauth(self, config) -> FlowResult:
|
||||
"""Handle reauthorization request from Abode."""
|
||||
self.conf = {**config}
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle reauthorization flow."""
|
||||
self.errors.clear()
|
||||
password = ""
|
||||
username = self.conf.get(CONF_USERNAME, "")
|
||||
app = self.conf.get(CONF_MOBILE_APP, DEFAULT_APP)
|
||||
if user_input is not None:
|
||||
extra_conf = {
|
||||
CONF_USERNAME: user_input.get(CONF_USERNAME, ""),
|
||||
CONF_PASSWORD: user_input.get(CONF_PASSWORD, ""),
|
||||
CONF_MOBILE_APP: user_input.get(CONF_MOBILE_APP, app),
|
||||
}
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._connect_to_cloud, extra_conf
|
||||
)
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
self._process_exception(ex)
|
||||
else:
|
||||
self.conf[CONF_USERNAME] = username
|
||||
self.conf[CONF_PASSWORD] = password
|
||||
self.conf[CONF_MOBILE_APP] = app
|
||||
return await self._async_add_entry()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=_reauth_schema(
|
||||
username=username,
|
||||
password=password,
|
||||
),
|
||||
description_placeholders=self._placeholders(),
|
||||
errors=self.errors,
|
||||
)
|
||||
|
||||
|
||||
class MideaOptionsFlow(OptionsFlow, _MideaFlow):
|
||||
"""Handle Midea options flow."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize Midea options flow."""
|
||||
super().__init__()
|
||||
self.config_entry = config_entry
|
||||
self.conf = {**config_entry.data}
|
||||
self.devices_conf = self.conf.get(CONF_DEVICES, [])
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Starts options flow"""
|
||||
self._build_appliance_list()
|
||||
return await self.async_step_appliance(user_input)
|
||||
|
||||
async def async_step_appliance(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Options for an appliance"""
|
||||
|
||||
return await self._async_step_appliance(
|
||||
step_id="appliance",
|
||||
user_input=user_input,
|
||||
)
|
||||
|
||||
def _build_appliance_list(self) -> None:
|
||||
assert self.config_entry
|
||||
hub: Hub = self.hass.data[DOMAIN][self.config_entry.entry_id]
|
||||
self.appliances.clear()
|
||||
self.devices_conf = self.conf[CONF_DEVICES]
|
||||
for device in self.devices_conf:
|
||||
for coord in hub.coordinators:
|
||||
if device[CONF_UNIQUE_ID] == coord.appliance.serial_number:
|
||||
self.appliances.append(coord.appliance)
|
||||
break
|
||||
else:
|
||||
appliance = LanDevice(
|
||||
appliance_id=device[CONF_ID],
|
||||
serial_number=device[CONF_UNIQUE_ID],
|
||||
appliance_type=device[CONF_TYPE],
|
||||
)
|
||||
appliance.name = device[CONF_NAME]
|
||||
appliance.address = device.get(CONF_IP_ADDRESS, UNKNOWN_IP)
|
||||
self.appliances.append(appliance)
|
||||
self.indexes_to_process = list(range(len(self.appliances)))
|
||||
self.appliance_idx = self.indexes_to_process.pop(0)
|
||||
self.discovered_appliances = [None] * len(self.devices_conf)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Constants for Midea Air Appliance custom component"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
from midea_beautiful.midea import (
|
||||
APPLIANCE_TYPE_AIRCON,
|
||||
APPLIANCE_TYPE_DEHUMIDIFIER,
|
||||
DEFAULT_APP as DEFAULT_APP_FROM_LIB,
|
||||
)
|
||||
|
||||
__version__ = "0.9.6"
|
||||
|
||||
# Base component constants
|
||||
NAME: Final = "Midea Air Appliance (LAN)"
|
||||
UNIQUE_ID_PRE_PREFIX: Final = "midea_"
|
||||
UNIQUE_DEHUMIDIFIER_PREFIX: Final = "midea_dehumidifier_"
|
||||
UNIQUE_CLIMATE_PREFIX: Final = "midea_climate_"
|
||||
DOMAIN: Final = f"{UNIQUE_DEHUMIDIFIER_PREFIX}lan"
|
||||
# pylint: disable=line-too-long
|
||||
ISSUE_URL: Final = "https://github.com/nbogojevic/homeassistant-midea-air-appliances-lan/issues/new/choose" # noqa: E501
|
||||
|
||||
CONF_ADVANCED_SETTINGS: Final = "advanced_settings"
|
||||
OBSOLETE_CONF_APPID: Final = "appid"
|
||||
OBSOLETE_CONF_APPKEY: Final = "appkey"
|
||||
CONF_DEBUG: Final = "debug"
|
||||
CONF_MOBILE_APP: Final = "mobile_app"
|
||||
CONF_TOKEN_KEY: Final = "token_key"
|
||||
CONF_USE_CLOUD_OBSOLETE: Final = "use_cloud"
|
||||
|
||||
MAX_TARGET_HUMIDITY: Final = 85
|
||||
MIN_TARGET_HUMIDITY: Final = 35
|
||||
|
||||
MAX_TARGET_TEMPERATURE: Final = 32
|
||||
MIN_TARGET_TEMPERATURE: Final = 16
|
||||
|
||||
CURRENT_CONFIG_VERSION: Final = 3
|
||||
|
||||
# Wait half a second between successive refresh calls
|
||||
APPLIANCE_REFRESH_COOLDOWN: Final = 0.5
|
||||
APPLIANCE_REFRESH_INTERVAL: Final = 60
|
||||
DEFAULT_SCAN_INTERVAL: Final = 15
|
||||
MIN_SCAN_INTERVAL: Final = 2
|
||||
|
||||
ATTR_FAN_SPEED: Final = "fan_speed"
|
||||
ATTR_RUNNING: Final = "running"
|
||||
|
||||
PLATFORMS: Final = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.FAN,
|
||||
Platform.HUMIDIFIER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
ENTITY_DISABLED_BY_DEFAULT: Final = ":disabled:"
|
||||
ENTITY_ENABLED_BY_DEFAULT: Final = ":enabled:"
|
||||
_ALWAYS_CREATE: Final = [ENTITY_DISABLED_BY_DEFAULT, ENTITY_ENABLED_BY_DEFAULT]
|
||||
|
||||
UNKNOWN_IP: Final = "0.0.0.0"
|
||||
LOCAL_BROADCAST: Final = "255.255.255.255"
|
||||
|
||||
# What to do with configured appliance
|
||||
DISCOVERY_IGNORE = "IGNORE"
|
||||
DISCOVERY_LAN = "LAN"
|
||||
DISCOVERY_CLOUD = "CLOUD"
|
||||
DISCOVERY_WAIT = "WAIT"
|
||||
DEFAULT_DISCOVERY_MODE = DISCOVERY_LAN
|
||||
|
||||
DISCOVERY_BATCH_SIZE: Final = 64
|
||||
|
||||
DEFAULT_APP: Final = DEFAULT_APP_FROM_LIB
|
||||
|
||||
DEFAULT_USERNAME: Final = ""
|
||||
DEFAULT_PASSWORD: Final = ""
|
||||
|
||||
STARTUP_MESSAGE: Final = f"""
|
||||
-------------------------------------------------------------------
|
||||
{NAME}
|
||||
Version: {__version__}
|
||||
This is a custom integration!
|
||||
If you have any issues with this you need to open an issue here:
|
||||
{ISSUE_URL}
|
||||
-------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
DISCOVERY_MODE_LABELS = {
|
||||
DISCOVERY_IGNORE: "Exclude appliance",
|
||||
DISCOVERY_LAN: "Provide appliance's IPv4 address",
|
||||
DISCOVERY_WAIT: "Wait for appliance to come online",
|
||||
DISCOVERY_CLOUD: "Use cloud API to poll appliance",
|
||||
}
|
||||
|
||||
DISCOVERY_MODE_EXPLANATION = {
|
||||
DISCOVERY_IGNORE: "excluded from polling",
|
||||
DISCOVERY_LAN: "assigned local network address",
|
||||
DISCOVERY_WAIT: "waiting to be disovered",
|
||||
DISCOVERY_CLOUD: "polled using cloud",
|
||||
}
|
||||
|
||||
SUPPORTED_APPLIANCES = {
|
||||
APPLIANCE_TYPE_AIRCON: "Air conditioner (BETA)",
|
||||
APPLIANCE_TYPE_DEHUMIDIFIER: "Dehumidifier",
|
||||
}
|
||||
|
||||
# Default period of failed updates before appliance is declared unavailable
|
||||
# 5 minutes
|
||||
DEFAULT_TTL: Final = 5
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Adds fan entity for each dehumidifer appliance."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntityFeature,
|
||||
FanEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from custom_components.midea_dehumidifier_lan.const import ATTR_FAN_SPEED, DOMAIN
|
||||
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
|
||||
ApplianceEntity,
|
||||
ApplianceUpdateCoordinator,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.hub import Hub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MODE_NONE: Final = "None"
|
||||
MODE_AUTO: Final = "Auto"
|
||||
MODE_LOW: Final = "Low"
|
||||
MODE_MEDIUM: Final = "Medium"
|
||||
MODE_HIGH: Final = "High"
|
||||
|
||||
PRESET_MODES_7: Final = [MODE_LOW, MODE_MEDIUM, MODE_HIGH]
|
||||
PRESET_MODES_3: Final = [MODE_LOW, MODE_HIGH]
|
||||
PRESET_MODES_2: Final = [MODE_AUTO]
|
||||
|
||||
_FAN_SPEEDS = {2: PRESET_MODES_2, 3: PRESET_MODES_3, 7: PRESET_MODES_7}
|
||||
_ON_SPEED = {2: MODE_AUTO, 3: MODE_HIGH, 7: MODE_HIGH}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Sets up fan entity for dehumidifer"""
|
||||
|
||||
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
DehumidiferFan(c) for c in hub.coordinators if c.is_dehumidifier()
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
class DehumidiferFan(ApplianceEntity, FanEntity):
|
||||
"""Entity for managing dehumidifer fan"""
|
||||
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.PRESET_MODE |
|
||||
FanEntityFeature.TURN_OFF |
|
||||
FanEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
_attr_preset_modes = PRESET_MODES_7
|
||||
_attr_speed_count = len(PRESET_MODES_7)
|
||||
_name_suffix = " Fan"
|
||||
_on_speed = MODE_MEDIUM
|
||||
|
||||
def __init__(self, coordinator: ApplianceUpdateCoordinator) -> None:
|
||||
|
||||
super().__init__(coordinator)
|
||||
self._fan_speeds = {
|
||||
MODE_NONE: 0,
|
||||
MODE_LOW: 40,
|
||||
MODE_MEDIUM: 60,
|
||||
MODE_HIGH: 80,
|
||||
MODE_AUTO: 101,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
# Override parent logic
|
||||
return self._attr_is_on
|
||||
|
||||
def on_online(self, update: bool) -> None:
|
||||
supports = self.dehumidifier().capabilities
|
||||
fan_capability = supports.get("fan_speed", 0)
|
||||
self._attr_preset_modes = _FAN_SPEEDS.get(fan_capability, PRESET_MODES_7)
|
||||
self._on_speed = _ON_SPEED.get(fan_capability, MODE_HIGH)
|
||||
self._attr_speed_count = len(self._attr_preset_modes)
|
||||
return super().on_online(update)
|
||||
|
||||
def on_update(self) -> None:
|
||||
fan_speed = self.dehumidifier().fan_speed
|
||||
self._attr_percentage = fan_speed
|
||||
self._attr_is_on = fan_speed > self._fan_speeds[MODE_LOW]
|
||||
for mode, mode_speed in self._fan_speeds.items():
|
||||
if fan_speed <= mode_speed:
|
||||
self._attr_preset_mode = mode
|
||||
break
|
||||
else:
|
||||
self._attr_preset_mode = MODE_NONE
|
||||
|
||||
def set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode of the fan."""
|
||||
speed = self._fan_speeds.get(preset_mode, None)
|
||||
_LOGGER.debug("Setting speed to %s", speed)
|
||||
if speed is not None:
|
||||
self.apply(ATTR_FAN_SPEED, speed)
|
||||
else:
|
||||
_LOGGER.warning("Unsupported fan mode %s", preset_mode)
|
||||
|
||||
def set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed percentage of the fan."""
|
||||
_LOGGER.debug("Setting percentage to %s", percentage)
|
||||
|
||||
self.apply(ATTR_FAN_SPEED, percentage)
|
||||
|
||||
def turn_on(
|
||||
self,
|
||||
speed: str = None,
|
||||
percentage: int = None,
|
||||
preset_mode: str = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Turns fan to medium speed."""
|
||||
updated = False
|
||||
if preset_mode is not None:
|
||||
self.set_preset_mode(preset_mode)
|
||||
updated = True
|
||||
if percentage is not None:
|
||||
self.set_percentage(percentage)
|
||||
updated = True
|
||||
if speed is not None:
|
||||
self.set_speed(speed)
|
||||
updated = True
|
||||
# _LOGGER.debug("turn_on percentage=%s was_updated=%s", self._attr_percentage, updated)
|
||||
if (
|
||||
not updated
|
||||
and (self._attr_percentage or 0) < self._fan_speeds[self._on_speed]
|
||||
):
|
||||
self.set_preset_mode(self._on_speed)
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turns fan to silent speed."""
|
||||
self.set_preset_mode(MODE_LOW)
|
||||
@@ -0,0 +1,303 @@
|
||||
"""The custom component for local network access to Midea appliances
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Tuple
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_API_VERSION,
|
||||
CONF_DEVICES,
|
||||
CONF_DISCOVERY,
|
||||
CONF_ID,
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_TYPE,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from midea_beautiful.exceptions import AuthenticationError
|
||||
from midea_beautiful.lan import LanDevice
|
||||
|
||||
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
|
||||
ApplianceUpdateCoordinator,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.appliance_discovery import (
|
||||
ApplianceDiscoveryHelper,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.const import (
|
||||
CONF_TOKEN_KEY,
|
||||
DISCOVERY_CLOUD,
|
||||
DISCOVERY_IGNORE,
|
||||
DISCOVERY_LAN,
|
||||
DISCOVERY_WAIT,
|
||||
NAME,
|
||||
UNKNOWN_IP,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.util import (
|
||||
AbstractHub,
|
||||
RedactedConf,
|
||||
address_ok,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _assure_valid_device_configuration(
|
||||
conf: dict[str, Any], device: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Checks device configuration.
|
||||
If configuration is correct returns ``True``.
|
||||
If it is not complete, updates it and returns ``False``.
|
||||
For example, if discovery mode is not set-up corectly it will try to deduce
|
||||
correct setting."""
|
||||
discovery_mode = device.get(CONF_DISCOVERY)
|
||||
if discovery_mode in [
|
||||
DISCOVERY_IGNORE,
|
||||
DISCOVERY_WAIT,
|
||||
DISCOVERY_LAN,
|
||||
DISCOVERY_CLOUD,
|
||||
]:
|
||||
return True
|
||||
ip_address = device.get(CONF_IP_ADDRESS)
|
||||
token = device.get(CONF_TOKEN)
|
||||
key = device.get(CONF_TOKEN_KEY)
|
||||
if address_ok(ip_address):
|
||||
device[CONF_DISCOVERY] = DISCOVERY_LAN if token and key else DISCOVERY_WAIT
|
||||
elif token and key:
|
||||
device[CONF_DISCOVERY] = DISCOVERY_WAIT
|
||||
else:
|
||||
username = conf.get(CONF_USERNAME)
|
||||
password = conf.get(CONF_PASSWORD)
|
||||
device[CONF_DISCOVERY] = (
|
||||
DISCOVERY_CLOUD if username and password else DISCOVERY_IGNORE
|
||||
)
|
||||
_LOGGER.warning(
|
||||
"Updated discovery mode for device %s.",
|
||||
RedactedConf(device),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _get_placeholder_appliance(device: dict[str, Any]) -> LanDevice:
|
||||
appliance = LanDevice(
|
||||
appliance_id=device[CONF_ID],
|
||||
serial_number=device[CONF_UNIQUE_ID],
|
||||
appliance_type=device[CONF_TYPE],
|
||||
token=device.get(CONF_TOKEN),
|
||||
key=device.get(CONF_TOKEN_KEY) or "",
|
||||
address=device.get(CONF_IP_ADDRESS, UNKNOWN_IP),
|
||||
version=device.get(CONF_API_VERSION, 3),
|
||||
)
|
||||
appliance.name = device[CONF_NAME]
|
||||
return appliance
|
||||
|
||||
|
||||
class Hub(AbstractHub): # pylint: disable=too-many-instance-attributes
|
||||
"""Central class for interacting with appliances"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
super().__init__(hass, config_entry)
|
||||
self.discovery = ApplianceDiscoveryHelper(self)
|
||||
self.coordinators: list[ApplianceUpdateCoordinator] = []
|
||||
self.updated_conf = False
|
||||
|
||||
async def async_unload(self) -> None:
|
||||
"""Stops discovery and coordinators"""
|
||||
_LOGGER.debug("Unloading hub")
|
||||
|
||||
self.discovery.stop()
|
||||
for coordinator in self.coordinators:
|
||||
# Stop coordinators
|
||||
coordinator.update_interval = None
|
||||
|
||||
async def async_update_config(self) -> None:
|
||||
"""Updates config entry from Hub's data"""
|
||||
self.hass.config_entries.async_update_entry(self.config_entry, data=self.config)
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Sets up appliances and creates an update coordinator for
|
||||
each one
|
||||
"""
|
||||
self.discovery.stop()
|
||||
self.config = {**self.config_entry.data}
|
||||
devices = [{**device} for device in self.config.get(CONF_DEVICES, [])]
|
||||
self.config[CONF_DEVICES] = devices
|
||||
self.errors = {}
|
||||
self.updated_conf = False
|
||||
|
||||
devices = []
|
||||
for device in self.config[CONF_DEVICES]:
|
||||
if not _assure_valid_device_configuration(self.config, device):
|
||||
self.updated_conf = True
|
||||
coordinator = await self._process_appliance(device)
|
||||
if coordinator and coordinator.available:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
devices.append(device)
|
||||
|
||||
if self.updated_conf:
|
||||
await self.async_update_config()
|
||||
|
||||
self.discovery.start()
|
||||
|
||||
self._notify_setup_errors()
|
||||
|
||||
def _notify_setup_errors(self):
|
||||
if self.errors:
|
||||
if not self.coordinators:
|
||||
raise ConfigEntryNotReady(str(self.errors))
|
||||
for unique_id, error in self.errors.items():
|
||||
_LOGGER.warning("Device may be offline or unreachable, trying again later. %s", error)
|
||||
|
||||
async def _process_appliance(
|
||||
self, device: dict[str, Any]
|
||||
) -> ApplianceUpdateCoordinator | None:
|
||||
discovery_mode = device.get(CONF_DISCOVERY)
|
||||
# We are waiting for appliance to come online
|
||||
if discovery_mode == DISCOVERY_IGNORE:
|
||||
_LOGGER.debug("Ignored appliance for discovery %s", device)
|
||||
return None
|
||||
if discovery_mode == DISCOVERY_WAIT:
|
||||
_LOGGER.debug("Waiting for appliance discovery %s", device)
|
||||
return None
|
||||
need_token, appliance = await self.async_discover_device(
|
||||
device, initial_discovery=True
|
||||
)
|
||||
return self._create_coordinator(appliance, device, need_token)
|
||||
|
||||
async def async_discover_device(
|
||||
self, device: dict[str, Any], initial_discovery=False
|
||||
) -> Tuple[bool, LanDevice | None]:
|
||||
"""Finds device on local network or cloud"""
|
||||
discovery_mode = device.get(CONF_DISCOVERY)
|
||||
|
||||
use_cloud = discovery_mode == DISCOVERY_CLOUD
|
||||
need_cloud = use_cloud
|
||||
lan_mode = discovery_mode == DISCOVERY_LAN
|
||||
version = device.get(CONF_API_VERSION, 3)
|
||||
need_token = (
|
||||
discovery_mode == DISCOVERY_LAN
|
||||
and version >= 3
|
||||
and (not device.get(CONF_TOKEN) or not device.get(CONF_TOKEN_KEY))
|
||||
)
|
||||
if need_token:
|
||||
_LOGGER.debug(
|
||||
"Appliance %s %s has no token,"
|
||||
" trying to obtain it from Midea cloud API",
|
||||
device.get(CONF_NAME),
|
||||
device.get(CONF_UNIQUE_ID),
|
||||
)
|
||||
need_cloud = True
|
||||
if not await self._async_get_cloud_if_needed(device, need_cloud, need_token):
|
||||
return need_token, None
|
||||
ip_address = device[CONF_IP_ADDRESS] if lan_mode else None
|
||||
if not ip_address and not use_cloud:
|
||||
_LOGGER.error(
|
||||
"Missing ip_address and cloud discovery is not used for %s."
|
||||
"Will fall-back to cloud discovery, full configuration is %s",
|
||||
device.get(CONF_UNIQUE_ID),
|
||||
RedactedConf(self.config),
|
||||
)
|
||||
use_cloud = True
|
||||
appliance = None
|
||||
try:
|
||||
appliance = await self.hass.async_add_executor_job(
|
||||
self.client.appliance_state,
|
||||
device[CONF_IP_ADDRESS] if lan_mode else None,
|
||||
device.get(CONF_TOKEN),
|
||||
device.get(CONF_TOKEN_KEY),
|
||||
self.cloud,
|
||||
use_cloud,
|
||||
device[CONF_ID],
|
||||
)
|
||||
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
self.errors[
|
||||
device[CONF_UNIQUE_ID]
|
||||
] = f"Unable to get state of device {device[CONF_NAME]}: {ex}"
|
||||
if initial_discovery:
|
||||
_LOGGER.error(
|
||||
"Error '%s' while setting up appliance %s,"
|
||||
" full configuration %s",
|
||||
ex,
|
||||
device.get(CONF_UNIQUE_ID),
|
||||
RedactedConf(self.config),
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Error '%s' while setting up appliance %s",
|
||||
ex,
|
||||
RedactedConf(device),
|
||||
)
|
||||
return need_token, appliance
|
||||
|
||||
async def _async_get_cloud_if_needed(
|
||||
self, device: dict[str, Any], need_cloud: bool, need_token: bool
|
||||
) -> bool:
|
||||
if need_cloud and self.cloud is None:
|
||||
self._validate_auth_config_complete(device, need_token)
|
||||
try:
|
||||
self.cloud = await self.client.async_connect_to_cloud(self.config)
|
||||
except AuthenticationError as ex:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Unable to login to Midea cloud {ex}"
|
||||
) from ex
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
self.errors[device[CONF_UNIQUE_ID]] = str(ex)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _validate_auth_config_complete(self, device, need_token):
|
||||
if not self.config.get(CONF_USERNAME) or not self.config.get(CONF_PASSWORD):
|
||||
if not device:
|
||||
cause = ""
|
||||
elif need_token:
|
||||
cause = f" because {device.get(CONF_NAME)} is missing token,"
|
||||
else:
|
||||
cause = f" because {device.get(CONF_NAME)} uses cloud polling,"
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Integration needs to connect to Midea cloud,"
|
||||
f"{cause}"
|
||||
f" but username or password are not configured."
|
||||
)
|
||||
|
||||
def _create_coordinator(
|
||||
self, appliance: LanDevice | None, device: dict[str, Any], need_token: bool
|
||||
) -> ApplianceUpdateCoordinator:
|
||||
available = appliance is not None
|
||||
if not available:
|
||||
appliance = _get_placeholder_appliance(device)
|
||||
appliance.name = device[CONF_NAME]
|
||||
self._fix_version_if_missing(appliance, device)
|
||||
self._update_token(appliance, device, need_token)
|
||||
coordinator = ApplianceUpdateCoordinator(
|
||||
self.hass, self, appliance, device, available=available
|
||||
)
|
||||
|
||||
_LOGGER.debug("Created coordinator for %s", RedactedConf(device))
|
||||
self.coordinators.append(coordinator)
|
||||
return coordinator
|
||||
|
||||
def _update_token(
|
||||
self, appliance: LanDevice, device: dict[str, Any], need_token: bool
|
||||
) -> None:
|
||||
if need_token and appliance.token and appliance.key:
|
||||
device[CONF_TOKEN] = appliance.token
|
||||
device[CONF_TOKEN_KEY] = appliance.key
|
||||
self.updated_conf = True
|
||||
_LOGGER.debug("Updating token for %s", appliance)
|
||||
|
||||
def _fix_version_if_missing(
|
||||
self, appliance: LanDevice, device: dict[str, Any]
|
||||
) -> None:
|
||||
if not device.get(CONF_API_VERSION):
|
||||
device[CONF_API_VERSION] = appliance.version
|
||||
self.updated_conf = True
|
||||
_LOGGER.debug("Updating version for %s", appliance)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Adds dehumidifer entity for each dehumidifer appliance."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.humidifier import HumidifierDeviceClass, HumidifierEntity
|
||||
from homeassistant.components.humidifier.const import HumidifierEntityFeature
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from custom_components.midea_dehumidifier_lan.const import (
|
||||
ATTR_RUNNING,
|
||||
DOMAIN,
|
||||
MAX_TARGET_HUMIDITY,
|
||||
MIN_TARGET_HUMIDITY,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
|
||||
ApplianceEntity,
|
||||
ApplianceUpdateCoordinator,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.hub import Hub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MODE_SET: Final = "Set"
|
||||
MODE_DRY: Final = "Dry"
|
||||
MODE_SMART: Final = "Smart"
|
||||
MODE_CONTINOUS: Final = "Continuous"
|
||||
MODE_PURIFIER: Final = "Purifier"
|
||||
MODE_ANTIMOULD: Final = "Antimould"
|
||||
MODE_FAN: Final = "Fan"
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Sets up dehumidifier entites"""
|
||||
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
DehumidifierEntity(c) for c in hub.coordinators if c.is_dehumidifier()
|
||||
)
|
||||
|
||||
|
||||
_MODES = [
|
||||
(1, MODE_SET),
|
||||
(2, MODE_CONTINOUS),
|
||||
(3, MODE_SMART),
|
||||
(4, MODE_DRY),
|
||||
(6, MODE_PURIFIER),
|
||||
(7, MODE_ANTIMOULD),
|
||||
]
|
||||
|
||||
_MODES_FROM_CAPABILITY = {
|
||||
1: [MODE_PURIFIER],
|
||||
2: [MODE_ANTIMOULD],
|
||||
3: [MODE_PURIFIER, MODE_ANTIMOULD],
|
||||
4: [MODE_FAN],
|
||||
}
|
||||
|
||||
|
||||
# pylint: disable=too-many-ancestors,too-many-instance-attributes
|
||||
class DehumidifierEntity(ApplianceEntity, HumidifierEntity):
|
||||
"""(de)Humidifer entity for Midea dehumidifier"""
|
||||
|
||||
_attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER
|
||||
_attr_max_humidity = MAX_TARGET_HUMIDITY
|
||||
_attr_min_humidity = MIN_TARGET_HUMIDITY
|
||||
_attr_supported_features = HumidifierEntityFeature.MODES
|
||||
_name_suffix = ""
|
||||
_add_extra_attrs = True
|
||||
|
||||
def __init__(self, coordinator: ApplianceUpdateCoordinator) -> None:
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_mode = None
|
||||
self._attr_available_modes = [MODE_SET]
|
||||
|
||||
def on_online(self, update: bool) -> None:
|
||||
capabilities = self.coordinator.appliance.state.capabilities
|
||||
|
||||
self._attr_available_modes = [MODE_SET]
|
||||
if capabilities.get("auto"):
|
||||
self._attr_available_modes.append(MODE_SMART)
|
||||
self._attr_available_modes.append(MODE_CONTINOUS)
|
||||
if capabilities.get("dry_clothes"):
|
||||
self._attr_available_modes.append(MODE_DRY)
|
||||
|
||||
more_modes = capabilities.get("mode", 0)
|
||||
self._attr_available_modes += _MODES_FROM_CAPABILITY.get(more_modes, [])
|
||||
|
||||
super().on_online(update)
|
||||
|
||||
def on_update(self) -> None:
|
||||
dehumi = self.dehumidifier()
|
||||
self._attr_mode = next((i[1] for i in _MODES if i[0] == dehumi.mode), MODE_SET)
|
||||
self._attr_target_humidity = dehumi.target_humidity
|
||||
self._attr_current_humidity = dehumi.current_humidity # add new attribute current_humidity
|
||||
self._attr_is_on = dehumi.running
|
||||
super().on_update()
|
||||
|
||||
def turn_on(self, **kwargs) -> None: # pylint: disable=unused-argument
|
||||
"""Turn the entity on."""
|
||||
self.apply(ATTR_RUNNING, True)
|
||||
|
||||
def turn_off(self, **kwargs) -> None: # pylint: disable=unused-argument
|
||||
"""Turn the entity off."""
|
||||
self.apply(ATTR_RUNNING, False)
|
||||
|
||||
def set_mode(self, mode) -> None:
|
||||
"""Set new target preset mode."""
|
||||
midea_mode = next((i[0] for i in _MODES if i[1] == mode), None)
|
||||
if midea_mode is None:
|
||||
_LOGGER.debug("Unsupported dehumidifer mode %s", mode)
|
||||
midea_mode = 1
|
||||
self.apply("mode", midea_mode)
|
||||
|
||||
def set_humidity(self, humidity) -> None:
|
||||
"""Set new target humidity."""
|
||||
self.apply("target_humidity", humidity)
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"domain": "midea_dehumidifier_lan",
|
||||
"name": "Midea Air Appliances (LAN)",
|
||||
"after_dependencies": [
|
||||
"network",
|
||||
"logger"
|
||||
],
|
||||
"codeowners": [
|
||||
"@nbogojevic"
|
||||
],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/nbogojevic/homeassistant-midea-air-appliances-lan/blob/main/README.md",
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/nbogojevic/homeassistant-midea-air-appliances-lan/issues",
|
||||
"requirements": [
|
||||
"midea-beautiful-air==0.10.5"
|
||||
],
|
||||
"version": "0.9.6"
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Adds sensors for each appliance."""
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
SensorDeviceClass
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from custom_components.midea_dehumidifier_lan.appliance_coordinator import (
|
||||
ApplianceEntity,
|
||||
)
|
||||
from custom_components.midea_dehumidifier_lan.const import DOMAIN, UNIQUE_CLIMATE_PREFIX
|
||||
from custom_components.midea_dehumidifier_lan.hub import Hub
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Sets up current environment humidity and temperature sensors"""
|
||||
|
||||
hub: Hub = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
# Dehumidifier sensors
|
||||
async_add_entities(
|
||||
CurrentHumiditySensor(c) for c in hub.coordinators if c.is_dehumidifier()
|
||||
)
|
||||
async_add_entities(
|
||||
CurrentTemperatureSensor(c) for c in hub.coordinators if c.is_dehumidifier()
|
||||
)
|
||||
async_add_entities(
|
||||
TankLevelSensor(c)
|
||||
for c in hub.coordinators
|
||||
if c.is_dehumidifier() and c.dehumidifier().capabilities.get("water_level")
|
||||
)
|
||||
# Climate sensors
|
||||
async_add_entities(
|
||||
OutsideTemperatureSensor(c) for c in hub.coordinators if c.is_climate()
|
||||
)
|
||||
|
||||
|
||||
class CurrentHumiditySensor(ApplianceEntity, SensorEntity):
|
||||
"""Crrent environment humidity sensor"""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.HUMIDITY
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_name_suffix = " Humidity"
|
||||
|
||||
def on_update(self) -> None:
|
||||
self._attr_native_value = self.dehumidifier().current_humidity
|
||||
|
||||
|
||||
class CurrentTemperatureSensor(ApplianceEntity, SensorEntity):
|
||||
"""Current environment temperature sensor"""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_name_suffix = " Temperature"
|
||||
|
||||
def on_update(self) -> None:
|
||||
self._attr_native_value = self.dehumidifier().current_temperature
|
||||
|
||||
|
||||
class TankLevelSensor(ApplianceEntity, SensorEntity):
|
||||
"""Current tank water level sensor"""
|
||||
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_name_suffix = " Water Level"
|
||||
|
||||
def on_online(self, update: bool) -> None:
|
||||
self._attr_entity_registry_enabled_default = (
|
||||
self.dehumidifier().capabilities.get("water_level", False)
|
||||
)
|
||||
return super().on_online(update)
|
||||
|
||||
def on_update(self) -> None:
|
||||
self._attr_native_value = self.dehumidifier().tank_level
|
||||
|
||||
|
||||
class OutsideTemperatureSensor(ApplianceEntity, SensorEntity):
|
||||
"""Current outside temperature sensor"""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_unique_id_prefx = UNIQUE_CLIMATE_PREFIX
|
||||
_name_suffix = " Outdoor Temperature"
|
||||
|
||||
def on_update(self) -> None:
|
||||
self._attr_native_value = self.airconditioner().outdoor_temperature
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user