diff --git a/custom_components/sonoff/__init__.py b/custom_components/sonoff/__init__.py index 794ae656..6e9ae2e7 100644 --- a/custom_components/sonoff/__init__.py +++ b/custom_components/sonoff/__init__.py @@ -3,7 +3,7 @@ import voluptuous as vol from homeassistant.components import zeroconf -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_DEVICES, @@ -165,137 +165,68 @@ async def send_command(call: ServiceCall): 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. +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + if config_entry.options.get("debug") and not _LOGGER.handlers: + await system_health.setup_debug(hass, _LOGGER) - 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) + registry: XRegistry = hass.data[DOMAIN].get(config_entry.entry_id) if not registry: session = async_get_clientsession(hass) - hass.data[DOMAIN][entry.entry_id] = registry = XRegistry(session) + hass.data[DOMAIN][config_entry.entry_id] = registry = XRegistry(session) - if entry.options.get("debug") and not _LOGGER.handlers: - await system_health.setup_debug(hass, _LOGGER) - - mode = entry.options.get(CONF_MODE, "auto") + mode = config_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(**entry.data) - 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 entry.data.get(CONF_PASSWORD): + # if has cloud password and not auth + if not registry.cloud.auth and config_entry.data.get(CONF_PASSWORD): try: - await registry.cloud.login(**entry.data) + await registry.cloud.login(**config_entry.data) 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"): + _LOGGER.warning(f"Can't login in {mode} mode: {repr(e)}") + if mode == "cloud": + # can't continue in cloud mode 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) + devices: list[dict] | None = None + store = Store(hass, 1, f"{DOMAIN}/{config_entry.data['username']}.json") -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") + # if auth OK - load devices from cloud + if registry.cloud.auth: + try: + homes = config_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") + # store devices to cache 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 - ] - ) + except Exception as e: + _LOGGER.warning("Can't load devices", exc_info=e) - 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 + if not devices: + if devices := await store.async_load(): _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) + if not config_entry.update_listeners: + config_entry.add_update_listener(async_update_options) - entry.async_on_unload( + config_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": + # this may only happen if async_setup_entry will fail + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + if mode in ("auto", "cloud") and config_entry.data.get(CONF_PASSWORD): + registry.cloud.start(**config_entry.data) + + if mode in ("auto", "local"): 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` + # at this moment we hold EVENT_HOMEASSISTANT_START event if registry.cloud.task: # we get cloud connected signal even with a cloud error, so we won't # hold Hass start event forever @@ -308,10 +239,27 @@ async def internal_cache_setup( # 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: + if devices: + devices = internal_unique_devices(config_entry.entry_id, devices) + entities = registry.setup_devices(devices) _LOGGER.debug(f"Add {len(entities)} entities") registry.dispatcher_send(SIGNAL_ADD_ENTITIES, entities) + 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): + ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + registry: XRegistry = hass.data[DOMAIN][entry.entry_id] + await registry.stop() + + return ok + def internal_unique_devices(uid: str, devices: list) -> list: """For support multiple integrations - bind each device to one integraion. diff --git a/custom_components/sonoff/core/ewelink/cloud.py b/custom_components/sonoff/core/ewelink/cloud.py index 503950f9..76b58d00 100644 --- a/custom_components/sonoff/core/ewelink/cloud.py +++ b/custom_components/sonoff/core/ewelink/cloud.py @@ -1,6 +1,7 @@ """ https://coolkit-technologies.github.io/eWeLink-API/#/en/PlatformOverview """ + import asyncio import base64 import hashlib @@ -16,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) -RETRY_DELAYS = [15, 60, 5 * 60, 15 * 60, 60 * 60] +RETRY_DELAYS = [15, 30, 60, 5 * 60, 15 * 60, 30 * 60, 60 * 60] # https://coolkit-technologies.github.io/eWeLink-API/#/en/APICenterV2?id=interface-domain-name API = { @@ -244,8 +245,6 @@ DATA_ERROR = {0: "online", 503: "offline", 504: "timeout", None: "unknown"} APP = [ - # ("oeVkj2lYFGnJu5XUtWisfW4utiN4u9Mq", "6Nz4n0xA8s8qdxQf2GqurZj2Fs55FUvM"), - # ("KOBxGJna5qkk3JLXw3LHLX3wSNiPjAVi", "4v0sv6X5IM2ASIBiNDj6kGmSfxo40w7n"), ("4s1FXKC9FaGfoqXhmXSJneb3qcm1gOak", "oKvCM06gvwkRbfetd6qWRrbC3rFrbIpV"), ("R8Oq3y0eSZSYdKccHlrQzT1ACCOUT9Gv", "1ve5Qk9GXfUhKAn1svnKwpAlxXkMarru"), ] @@ -290,6 +289,13 @@ async def _wait_response(self, sequence: str, timeout: float): # noinspection PyProtectedMember class WebSocket: + """Default asyncio.WebSocket keep-alive only incoming messages with heartbeats. + This is helpful if messages from the server don't come very often. + + With this changes we also keep-alive outgoing messages with heartbeats. + This is helpful if our messages to the server are not sent very often. + """ + def __init__(self, ws: ClientWebSocketResponse): self._heartbeat: float = ws._heartbeat self._heartbeat_cb: asyncio.TimerHandle | None = None @@ -372,7 +378,7 @@ async def login( "X-CK-Appid": appid, } r = await self.session.post( - self.host + "/v2/user/login", data=data, headers=headers, timeout=30 + self.host + "/v2/user/login", data=data, headers=headers, timeout=10 ) resp = await r.json() @@ -380,7 +386,7 @@ async def login( if resp["error"] == 10004: self.region = resp["data"]["region"] r = await self.session.post( - self.host + "/v2/user/login", data=data, headers=headers, timeout=30 + self.host + "/v2/user/login", data=data, headers=headers, timeout=10 ) resp = await r.json() @@ -486,8 +492,8 @@ async def send( _LOGGER.error(log, exc_info=e) return "E#???" - def start(self): - self.task = asyncio.create_task(self.run_forever()) + def start(self, **kwargs): + self.task = asyncio.create_task(self.run_forever(**kwargs)) async def stop(self): if self.task: @@ -497,28 +503,32 @@ async def stop(self): self.set_online(None) def set_online(self, value: Optional[bool]): - _LOGGER.debug(f"CLOUD {self.online} => {value}") + _LOGGER.debug(f"CLOUD change state old={self.online}, new={value}") if self.online == value: return self.online = value self.dispatcher_send(SIGNAL_CONNECTED) - async def run_forever(self): + async def run_forever(self, **kwargs): fails = 0 while not self.session.closed: - if not await self.connect(): + if fails: self.set_online(False) - delay = RETRY_DELAYS[fails] + delay = RETRY_DELAYS[min(fails, len(RETRY_DELAYS)) - 1] _LOGGER.debug(f"Cloud connection retrying in {delay} seconds") - if fails + 1 < len(RETRY_DELAYS): - fails += 1 await asyncio.sleep(delay) + + if not self.auth and not await self.login(**kwargs): + fails += 1 continue - fails = 0 + if not await self.connect(): + fails += 1 + continue + fails = 0 self.set_online(True) try: @@ -557,7 +567,14 @@ async def connect(self) -> bool: await self.ws.send_json(payload) resp = await self.ws.receive_json() - if "error" in resp and resp["error"] != 0: + if (error := resp.get("error", 0)) != 0: + # {'error': 406, 'reason': 'Authentication Failed'} + # can happened when login from another place with same user/appid + if error == 406: + _LOGGER.warning("You logged in from another place") + self.auth = None + return False + raise Exception(resp) if (config := resp.get("config")) and config.get("hb"):