Initialize docker stack repo

This commit is contained in:
g4st3r
2026-03-07 17:28:59 +00:00
commit 319d4eeb3e
458 changed files with 67215 additions and 0 deletions

View 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)

File diff suppressed because it is too large Load Diff

View 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))

View 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",
]

View 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()

View 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)

View 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",))

View 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.
"""

View 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"

View 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)

View 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()

View File

@@ -0,0 +1,12 @@
{
"entity": {
"switch": {
"pre-release": {
"state": {
"on": "mdi:test-tube",
"off": "mdi:test-tube-off"
}
}
}
}
}

View 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,
})
)
};

View 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"
}

View 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

View File

@@ -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,
}

View File

@@ -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,
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
},
)

View File

@@ -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

View 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()

View 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

View File

@@ -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"
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
"""Custom HACS types."""
from typing import TypedDict
class DownloadableContent(TypedDict):
"""Downloadable content."""
url: str
name: str

View 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()

View File

@@ -0,0 +1 @@
"""Initialize HACS utils."""

View 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)

View File

@@ -0,0 +1,9 @@
"""HACS Configuration Schemas."""
# Configuration:
SIDEPANEL_TITLE = "sidepanel_title"
SIDEPANEL_ICON = "sidepanel_icon"
APPDAEMON = "appdaemon"
# Options:
COUNTRY = "country"

View 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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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
}
}
}
}
"""

View File

@@ -0,0 +1,5 @@
"""JSON utils."""
from homeassistant.util.json import json_loads
__all__ = ["json_loads"]

View File

@@ -0,0 +1,7 @@
"""Custom logger for HACS."""
import logging
from ..const import PACKAGE_NAME
LOGGER: logging.Logger = logging.getLogger(PACKAGE_NAME)

View 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,
)

View File

@@ -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

View 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()

View 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()

View 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"

View 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,
)
]
)

View 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)

View File

@@ -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)

View File

@@ -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")
```

View File

@@ -0,0 +1 @@
"""Initialize validation."""

View File

@@ -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")

View 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)

View File

@@ -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"
)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View 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")

View 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

View File

@@ -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")

View File

@@ -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)

View File

@@ -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")

View File

@@ -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,
},
)
)

View File

@@ -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))

View File

@@ -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"], {}))

View File

@@ -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
],
)
)

View 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)

View File

@@ -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
)

View 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)

View 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"

View 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)

View 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."""

View 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"

View 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)

View File

@@ -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

View File

@@ -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

View 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)

View 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)

View File

@@ -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"
}

View 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)

File diff suppressed because it is too large Load Diff

View 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)

View 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)

View File

@@ -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

View 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"
}

View 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)

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View 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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"
}

View File

@@ -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