homeassistant/custom_components/sonoff/__init__.py
2025-01-10 21:08:35 -08:00

333 lines
11 KiB
Python

import asyncio
import logging
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_DEVICES,
CONF_MODE,
CONF_NAME,
CONF_PASSWORD,
CONF_PAYLOAD_OFF,
CONF_SENSORS,
CONF_TIMEOUT,
CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP,
MAJOR_VERSION,
MINOR_VERSION,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import async_get as device_registry
from homeassistant.helpers.storage import Store
from . import system_health
from .core import devices as core_devices
from .core.const import (
CONF_APPID,
CONF_APPSECRET,
CONF_DEFAULT_CLASS,
CONF_DEVICEKEY,
CONF_RFBRIDGE,
DOMAIN,
)
from .core.ewelink import SIGNAL_ADD_ENTITIES, SIGNAL_CONNECTED, XRegistry
from .core.ewelink.camera import XCameras
from .core.ewelink.cloud import APP, AuthError
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
"binary_sensor",
"button",
"climate",
"cover",
"fan",
"light",
"remote",
"sensor",
"switch",
"number",
]
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Optional(CONF_APPID): cv.string,
vol.Optional(CONF_APPSECRET): cv.string,
vol.Optional(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
vol.Optional(CONF_DEFAULT_CLASS): cv.string,
vol.Optional(CONF_SENSORS): cv.ensure_list,
vol.Optional(CONF_RFBRIDGE): {
cv.string: vol.Schema(
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_DEVICE_CLASS): cv.string,
vol.Optional(CONF_TIMEOUT, default=120): cv.positive_int,
vol.Optional(CONF_PAYLOAD_OFF): cv.string,
},
extra=vol.ALLOW_EXTRA,
),
},
vol.Optional(CONF_DEVICES): {
cv.string: vol.Schema(
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_DEVICE_CLASS): vol.Any(str, list),
vol.Optional(CONF_DEVICEKEY): cv.string,
},
extra=vol.ALLOW_EXTRA,
),
},
},
extra=vol.ALLOW_EXTRA,
),
},
extra=vol.ALLOW_EXTRA,
)
UNIQUE_DEVICES = {}
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
if (MAJOR_VERSION, MINOR_VERSION) < (2023, 1):
raise Exception("unsupported hass version")
# init storage for registries
hass.data[DOMAIN] = {}
# load optional global registry config
if DOMAIN in config:
XRegistry.config = conf = config[DOMAIN]
if CONF_APPID in conf and CONF_APPSECRET in conf:
APP[0] = (conf[CONF_APPID], conf[CONF_APPSECRET])
if CONF_DEFAULT_CLASS in conf:
core_devices.set_default_class(conf.pop(CONF_DEFAULT_CLASS))
if CONF_SENSORS in conf:
core_devices.get_spec = core_devices.get_spec_wrapper(
core_devices.get_spec, conf.pop(CONF_SENSORS)
)
# cameras starts only on first command to it
cameras = XCameras()
try:
# import ewelink account from YAML (first time)
data = {
CONF_USERNAME: XRegistry.config[CONF_USERNAME],
CONF_PASSWORD: XRegistry.config[CONF_PASSWORD],
}
if not hass.config_entries.async_entries(DOMAIN):
coro = hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=data
)
hass.async_create_task(coro)
except Exception:
pass
async def send_command(call: ServiceCall):
"""Service for send raw command to device.
:param call: `device` - required param, all other params - optional
"""
params = dict(call.data)
deviceid = str(params.pop("device"))
if len(deviceid) == 10:
registry: XRegistry = next(
r for r in hass.data[DOMAIN].values() if deviceid in r.devices
)
device = registry.devices[deviceid]
# for debugging purposes
if v := params.get("set_device"):
device.update(v)
return
params_lan = params.pop("params_lan", None)
command_lan = params.pop("command_lan", None)
await registry.send(device, params, params_lan, command_lan)
elif len(deviceid) == 6:
await cameras.send(deviceid, params["cmd"])
else:
_LOGGER.error(f"Wrong deviceid {deviceid}")
hass.services.async_register(DOMAIN, "send_command", send_command)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""
AUTO mode. If there is a login error to the cloud - it starts in LOCAL
mode with devices list from cache. Trying to reconnect to the cloud.
CLOUD mode. If there is a login error to the cloud - trying to reconnect to
the cloud.
LOCAL mode. If there is a login error to the cloud - it starts with
devices list from cache.
"""
registry = hass.data[DOMAIN].get(entry.entry_id)
if not registry:
session = async_get_clientsession(hass)
hass.data[DOMAIN][entry.entry_id] = registry = XRegistry(session)
if entry.options.get("debug") and not _LOGGER.handlers:
await system_health.setup_debug(hass, _LOGGER)
username = entry.data.get(CONF_USERNAME)
password = entry.data.get(CONF_PASSWORD)
mode = entry.options.get(CONF_MODE, "auto")
# retry only when can't login first time
if entry.state == ConfigEntryState.SETUP_RETRY:
assert mode in ("auto", "cloud")
try:
await registry.cloud.login(username, password)
except Exception as e:
_LOGGER.warning(f"Can't login with mode: {mode}", exc_info=e)
raise ConfigEntryNotReady(e)
if mode == "auto":
registry.cloud.start()
elif mode == "cloud":
hass.async_create_task(internal_normal_setup(hass, entry))
return True
if registry.cloud.auth is None and username and password:
try:
await registry.cloud.login(username, password)
except Exception as e:
_LOGGER.warning(f"Can't login with mode: {mode}", exc_info=e)
if mode in ("auto", "local"):
hass.async_create_task(internal_cache_setup(hass, entry))
if mode in ("auto", "cloud"):
if isinstance(e, AuthError):
raise ConfigEntryAuthFailed(e)
raise ConfigEntryNotReady(e)
assert mode == "local"
return True
hass.async_create_task(internal_normal_setup(hass, entry))
return True
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry):
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
registry: XRegistry = hass.data[DOMAIN][entry.entry_id]
await registry.stop()
return True
async def internal_normal_setup(hass: HomeAssistant, entry: ConfigEntry):
devices = None
try:
registry: XRegistry = hass.data[DOMAIN][entry.entry_id]
if registry.cloud.auth:
homes = entry.options.get("homes")
devices = await registry.cloud.get_devices(homes)
_LOGGER.debug(f"{len(devices)} devices loaded from Cloud")
store = Store(hass, 1, f"{DOMAIN}/{entry.data['username']}.json")
await store.async_save(devices)
except Exception as e:
_LOGGER.warning("Can't load devices", exc_info=e)
await internal_cache_setup(hass, entry, devices)
async def internal_cache_setup(
hass: HomeAssistant, entry: ConfigEntry, devices: list = None
):
registry: XRegistry = hass.data[DOMAIN][entry.entry_id]
# this may only happen if async_setup_entry will fail
if registry.online:
await async_unload_entry(hass, entry)
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_setup(entry, domain)
for domain in PLATFORMS
]
)
if devices is None:
store = Store(hass, 1, f"{DOMAIN}/{entry.data['username']}.json")
devices = await store.async_load()
if devices:
# 16 devices loaded from the Cloud Server
_LOGGER.debug(f"{len(devices)} devices loaded from Cache")
if devices:
devices = internal_unique_devices(entry.entry_id, devices)
entities = registry.setup_devices(devices)
else:
entities = None
if not entry.update_listeners:
entry.add_update_listener(async_update_options)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, registry.stop)
)
mode = entry.options.get(CONF_MODE, "auto")
if mode != "local" and registry.cloud.auth:
registry.cloud.start()
if mode != "cloud":
registry.local.start(await zeroconf.async_get_instance(hass))
_LOGGER.debug(mode.upper() + " mode start")
# at this moment we hold EVENT_HOMEASSISTANT_START event, because run this
# coro with `hass.async_create_task` from `async_setup_entry`
if registry.cloud.task:
# we get cloud connected signal even with a cloud error, so we won't
# hold Hass start event forever
await registry.cloud.dispatcher_wait(SIGNAL_CONNECTED)
elif registry.local.online:
# we hope that most of local devices will be discovered in 3 seconds
await asyncio.sleep(3)
# 1. We need add_entities after cloud or local init, so they won't be
# unavailable at init state
# 2. We need add_entities before Hass start event, so Hass won't push
# unavailable state with restored=True attribute to history
if entities:
_LOGGER.debug(f"Add {len(entities)} entities")
registry.dispatcher_send(SIGNAL_ADD_ENTITIES, entities)
def internal_unique_devices(uid: str, devices: list) -> list:
"""For support multiple integrations - bind each device to one integraion.
To avoid duplicates.
"""
return [
device
for device in devices
if UNIQUE_DEVICES.setdefault(device["deviceid"], uid) == uid
]
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: ConfigEntry, device
) -> bool:
device_registry(hass).async_remove_device(device.id)
return True