diff --git a/custom_components/sonoff/__init__.py b/custom_components/sonoff/__init__.py index 4fcfaa2f..794ae656 100644 --- a/custom_components/sonoff/__init__.py +++ b/custom_components/sonoff/__init__.py @@ -184,15 +184,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) + 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) @@ -202,9 +200,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.async_create_task(internal_normal_setup(hass, entry)) return True - if registry.cloud.auth is None and username and password: + if registry.cloud.auth is None and entry.data.get(CONF_PASSWORD): try: - await registry.cloud.login(username, password) + await registry.cloud.login(**entry.data) except Exception as e: _LOGGER.warning(f"Can't login with mode: {mode}", exc_info=e) if mode in ("auto", "local"): diff --git a/custom_components/sonoff/config_flow.py b/custom_components/sonoff/config_flow.py index bd72c9dc..86992ec1 100644 --- a/custom_components/sonoff/config_flow.py +++ b/custom_components/sonoff/config_flow.py @@ -3,44 +3,18 @@ import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow -from homeassistant.const import CONF_MODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY_CODE, + CONF_MODE, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowHandler from homeassistant.helpers.aiohttp_client import async_get_clientsession from .core.const import CONF_DEBUG, CONF_MODES, DOMAIN from .core.ewelink import XRegistryCloud - - -def form( - flow: FlowHandler, - step_id: str, - schema: dict, - defaults: dict = None, - template: dict = None, - error: str = None, -): - """Suppport: - - overwrite schema defaults from dict (user_input or entry.options) - - set base error code (translations > config > error > code) - - set custom error via placeholders ("template": "{error}") - """ - if defaults: - for key in schema: - if key.schema in defaults: - key.default = vol.default_factory(defaults[key.schema]) - - if template and "error" in template: - error = {"base": "template"} - elif error: - error = {"base": error} - - return flow.async_show_form( - step_id=step_id, - data_schema=vol.Schema(schema), - description_placeholders=template, - errors=error, - ) +from .core.ewelink.cloud import REGIONS class SonoffLANFlowHandler(ConfigFlow, domain=DOMAIN): @@ -53,59 +27,72 @@ def cloud(self): async def async_step_import(self, user_input=None): return await self.async_step_user(user_input) - async def async_step_user(self, data=None, error=None): - schema = {vol.Required(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + async def async_step_user(self, user_input: dict = None): + codes = {k: f"{v[0]} | {k}" for k, v in REGIONS.items()} - if data is not None: - username = data.get(CONF_USERNAME) - password = data.get(CONF_PASSWORD) + data_schema = vol_schema( + { + vol.Required(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_COUNTRY_CODE): vol.In(codes), + }, + user_input, + ) + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input.get(CONF_PASSWORD) try: - entry = await self.async_set_unique_id(username) - if entry and password == "token": + config_entry = await self.async_set_unique_id(username) + if config_entry and password == "token": # a special way to share a user's token - await self.cloud.login( - entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], 1 - ) - return form( - self, - "user", - schema, - data, - template={"error": "Token: " + self.cloud.token}, + await self.cloud.login(**config_entry.data, app=1) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors={"base": "template"}, + description_placeholders={ + "error": "Token: " + self.cloud.token + }, ) - if username and password: - await self.cloud.login(username, password) + if password: + await self.cloud.login(**user_input) - if entry: + if config_entry: self.hass.config_entries.async_update_entry( - entry, data=data, unique_id=self.unique_id + config_entry, data=user_input, unique_id=self.unique_id ) # entry will reload automatically because # `entry.update_listeners` linked to `async_update_options` return self.async_abort(reason="reauth_successful") - return self.async_create_entry(title=username, data=data) + return self.async_create_entry(title=username, data=user_input) except Exception as e: - return form(self, "user", schema, data, template={"error": str(e)}) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors={"base": "template"}, + description_placeholders={"error": str(e)}, + ) - return form(self, "user", schema) + return self.async_show_form(step_id="user", data_schema=data_schema) async def async_step_reauth(self, user_input=None): return await self.async_step_user() @staticmethod @callback - def async_get_options_flow(entry: ConfigEntry): - return OptionsFlowHandler(entry) + def async_get_options_flow(config_entry: ConfigEntry): + return OptionsFlowHandler(config_entry) # noinspection PyUnusedLocal class OptionsFlowHandler(OptionsFlow): - def __init__(self, entry: ConfigEntry): - self.entry = entry + def __init__(self, config_entry: ConfigEntry): + self.config_entry = config_entry async def async_step_init(self, data: dict = None): if data is not None: @@ -113,29 +100,34 @@ async def async_step_init(self, data: dict = None): homes = {} - username = self.entry.data.get(CONF_USERNAME) - password = self.entry.data.get(CONF_PASSWORD) - if username and password: + if self.config_entry.data.get(CONF_PASSWORD): try: # important to use another accout for get user homes session = async_get_clientsession(self.hass) cloud = XRegistryCloud(session) - await cloud.login(username, password, app=1) + await cloud.login(**self.config_entry.data, app=1) homes = await cloud.get_homes() except: pass - for home in self.entry.options.get("homes", []): + for home in self.config_entry.options.get("homes", []): if home not in homes: homes[home] = home - return form( - self, - "init", + data = vol_schema( { vol.Optional(CONF_MODE, default="auto"): vol.In(CONF_MODES), vol.Optional(CONF_DEBUG, default=False): bool, vol.Optional("homes"): cv.multi_select(homes), }, - self.entry.options, + dict(self.config_entry.options), ) + return self.async_show_form(step_id="init", data_schema=data) + + +def vol_schema(schema: dict, defaults: dict | None) -> vol.Schema: + if defaults: + for key in schema: + if (value := defaults.get(key.schema)) is not None: + key.default = vol.default_factory(value) + return vol.Schema(schema) diff --git a/custom_components/sonoff/core/ewelink/cloud.py b/custom_components/sonoff/core/ewelink/cloud.py index 3ba3d8e7..503950f9 100644 --- a/custom_components/sonoff/core/ewelink/cloud.py +++ b/custom_components/sonoff/core/ewelink/cloud.py @@ -33,6 +33,214 @@ "eu": "https://eu-dispa.coolkit.cc/dispatch/app", } +REGIONS = { + "+93": ("Afghanistan", "as"), + "+355": ("Albania", "eu"), + "+213": ("Algeria", "eu"), + "+376": ("Andorra", "eu"), + "+244": ("Angola", "eu"), + "+1264": ("Anguilla", "us"), + "+1268": ("Antigua and Barbuda", "as"), + "+54": ("Argentina", "us"), + "+374": ("Armenia", "as"), + "+297": ("Aruba", "eu"), + "+247": ("Ascension", "eu"), + "+61": ("Australia", "us"), + "+43": ("Austria", "eu"), + "+994": ("Azerbaijan", "as"), + "+1242": ("Bahamas", "us"), + "+973": ("Bahrain", "as"), + "+880": ("Bangladesh", "as"), + "+1246": ("Barbados", "us"), + "+375": ("Belarus", "eu"), + "+32": ("Belgium", "eu"), + "+501": ("Belize", "us"), + "+229": ("Benin", "eu"), + "+1441": ("Bermuda", "as"), + "+591": ("Bolivia", "us"), + "+387": ("Bosnia and Herzegovina", "eu"), + "+267": ("Botswana", "eu"), + "+55": ("Brazil", "us"), + "+673": ("Brunei", "as"), + "+359": ("Bulgaria", "eu"), + "+226": ("Burkina Faso", "eu"), + "+257": ("Burundi", "eu"), + "+855": ("Cambodia", "as"), + "+237": ("Cameroon", "eu"), + "+238": ("Cape Verde Republic", "eu"), + "+1345": ("Cayman Islands", "as"), + "+236": ("Central African Republic", "eu"), + "+235": ("Chad", "eu"), + "+56": ("Chile", "us"), + "+86": ("China", "cn"), + "+57": ("Colombia", "us"), + "+682": ("Cook Islands", "us"), + "+506": ("Costa Rica", "us"), + "+385": ("Croatia", "eu"), + "+53": ("Cuba", "us"), + "+357": ("Cyprus", "eu"), + "+420": ("Czech", "eu"), + "+243": ("Democratic Republic of Congo", "eu"), + "+45": ("Denmark", "eu"), + "+253": ("Djibouti", "eu"), + "+1767": ("Dominica", "as"), + "+1809": ("Dominican Republic", "us"), + "+670": ("East Timor", "as"), + "+684": ("Eastern Samoa (US)", "us"), + "+593": ("Ecuador", "us"), + "+20": ("Egypt", "eu"), + "+503": ("El Salvador", "us"), + "+372": ("Estonia", "eu"), + "+251": ("Ethiopia", "eu"), + "+298": ("Faroe Islands", "eu"), + "+679": ("Fiji", "us"), + "+358": ("Finland", "eu"), + "+33": ("France", "eu"), + "+594": ("French Guiana", "us"), + "+689": ("French Polynesia", "as"), + "+241": ("Gabon", "eu"), + "+220": ("Gambia", "eu"), + "+995": ("Georgia", "as"), + "+49": ("Germany", "eu"), + "+233": ("Ghana", "eu"), + "+350": ("Gibraltar", "eu"), + "+30": ("Greece", "eu"), + "+299": ("Greenland", "us"), + "+1473": ("Grenada", "as"), + "+590": ("Guadeloupe", "us"), + "+1671": ("Guam", "us"), + "+502": ("Guatemala", "us"), + "+240": ("Guinea", "eu"), + "+224": ("Guinea", "eu"), + "+592": ("Guyana", "us"), + "+509": ("Haiti", "us"), + "+504": ("Honduras", "us"), + "+852": ("Hong Kong, China", "as"), + "+36": ("Hungary", "eu"), + "+354": ("Iceland", "eu"), + "+91": ("India", "as"), + "+62": ("Indonesia", "as"), + "+98": ("Iran", "as"), + "+353": ("Ireland", "eu"), + "+269": ("Islamic Federal Republic of Comoros", "eu"), + "+972": ("Israel", "as"), + "+39": ("Italian", "eu"), + "+225": ("Ivory Coast", "eu"), + "+1876": ("Jamaica", "us"), + "+81": ("Japan", "as"), + "+962": ("Jordan", "as"), + "+254": ("Kenya", "eu"), + "+975": ("Kingdom of Bhutan", "as"), + "+383": ("Kosovo", "eu"), + "+965": ("Kuwait", "as"), + "+996": ("Kyrgyzstan", "as"), + "+856": ("Laos", "as"), + "+371": ("Latvia", "eu"), + "+961": ("Lebanon", "as"), + "+266": ("Lesotho", "eu"), + "+231": ("Liberia", "eu"), + "+218": ("Libya", "eu"), + "+423": ("Liechtenstein", "eu"), + "+370": ("Lithuania", "eu"), + "+352": ("Luxembourg", "eu"), + "+853": ("Macau, China", "as"), + "+261": ("Madagascar", "eu"), + "+265": ("Malawi", "eu"), + "+60": ("Malaysia", "as"), + "+960": ("Maldives", "as"), + "+223": ("Mali", "eu"), + "+356": ("Malta", "eu"), + "+596": ("Martinique", "us"), + "+222": ("Mauritania", "eu"), + "+230": ("Mauritius", "eu"), + "+52": ("Mexico", "us"), + "+373": ("Moldova", "eu"), + "+377": ("Monaco", "eu"), + "+976": ("Mongolia", "as"), + "+382": ("Montenegro", "as"), + "+1664": ("Montserrat", "as"), + "+212": ("Morocco", "eu"), + "+258": ("Mozambique", "eu"), + "+95": ("Myanmar", "as"), + "+264": ("Namibia", "eu"), + "+977": ("Nepal", "as"), + "+31": ("Netherlands", "eu"), + "+599": ("Netherlands Antilles", "as"), + "+687": ("New Caledonia", "as"), + "+64": ("New Zealand", "us"), + "+505": ("Nicaragua", "us"), + "+227": ("Niger", "eu"), + "+234": ("Nigeria", "eu"), + "+47": ("Norway", "eu"), + "+968": ("Oman", "as"), + "+92": ("Pakistan", "as"), + "+970": ("Palestine", "as"), + "+507": ("Panama", "us"), + "+675": ("Papua New Guinea", "as"), + "+595": ("Paraguay", "us"), + "+51": ("Peru", "us"), + "+63": ("Philippines", "as"), + "+48": ("Poland", "eu"), + "+351": ("Portugal", "eu"), + "+974": ("Qatar", "as"), + "+242": ("Republic of Congo", "eu"), + "+964": ("Republic of Iraq", "as"), + "+389": ("Republic of Macedonia", "eu"), + "+262": ("Reunion", "eu"), + "+40": ("Romania", "eu"), + "+7": ("Russia", "eu"), + "+250": ("Rwanda", "eu"), + "+1869": ("Saint Kitts and Nevis", "as"), + "+1758": ("Saint Lucia", "us"), + "+1784": ("Saint Vincent", "as"), + "+378": ("San Marino", "eu"), + "+239": ("Sao Tome and Principe", "eu"), + "+966": ("Saudi Arabia", "as"), + "+221": ("Senegal", "eu"), + "+381": ("Serbia", "eu"), + "+248": ("Seychelles", "eu"), + "+232": ("Sierra Leone", "eu"), + "+65": ("Singapore", "as"), + "+421": ("Slovakia", "eu"), + "+386": ("Slovenia", "eu"), + "+27": ("South Africa", "eu"), + "+82": ("South Korea", "as"), + "+34": ("Spain", "eu"), + "+94": ("Sri Lanka", "as"), + "+249": ("Sultan", "eu"), + "+597": ("Suriname", "us"), + "+268": ("Swaziland", "eu"), + "+46": ("Sweden", "eu"), + "+41": ("Switzerland", "eu"), + "+963": ("Syria", "as"), + "+886": ("Taiwan, China", "as"), + "+992": ("Tajikistan", "as"), + "+255": ("Tanzania", "eu"), + "+66": ("Thailand", "as"), + "+228": ("Togo", "eu"), + "+676": ("Tonga", "us"), + "+1868": ("Trinidad and Tobago", "us"), + "+216": ("Tunisia", "eu"), + "+90": ("Turkey", "as"), + "+993": ("Turkmenistan", "as"), + "+1649": ("Turks and Caicos", "as"), + "+44": ("UK", "eu"), + "+256": ("Uganda", "eu"), + "+380": ("Ukraine", "eu"), + "+971": ("United Arab Emirates", "as"), + "+1": ("United States", "us"), + "+598": ("Uruguay", "us"), + "+998": ("Uzbekistan", "as"), + "+678": ("Vanuatu", "us"), + "+58": ("Venezuela", "us"), + "+84": ("Vietnam", "as"), + "+685": ("Western Samoa", "us"), + "+1340": ("Wilk Islands", "as"), + "+967": ("Yemen", "as"), + "+260": ("Zambia", "eu"), + "+263": ("Zimbabwe", "eu"), +} + DATA_ERROR = {0: "online", 503: "offline", 504: "timeout", None: "unknown"} APP = [ @@ -109,11 +317,11 @@ async def send_json(self, data: dict): class XRegistryCloud(ResponseWaiter, XRegistryBase): - auth: dict = None - devices: dict = None - last_ts = 0 - online = None - region = "eu" + auth: dict | None = None + devices: dict[str, dict] = None + last_ts: float = 0 + online: bool | None = None + region: str = None task: asyncio.Task | None = None ws: WebSocket = None @@ -134,16 +342,17 @@ def headers(self) -> dict: def token(self) -> str: return self.region + ":" + self.auth["at"] - async def login(self, username: str, password: str, app=0) -> bool: + async def login( + self, username: str, password: str, country_code: str = "+86", app: int = 0 + ) -> bool: if username == "token": self.region, token = password.split(":") return await self.login_token(token, 1) + self.region = REGIONS[country_code][1] + # https://coolkit-technologies.github.io/eWeLink-API/#/en/DeveloperGuideV2 - payload = { - "password": password, - "countryCode": "+86", - } + payload = {"password": password, "countryCode": country_code} if "@" in username: payload["email"] = username elif username.startswith("+"): diff --git a/custom_components/sonoff/translations/en.json b/custom_components/sonoff/translations/en.json index 7692f882..41d549bd 100644 --- a/custom_components/sonoff/translations/en.json +++ b/custom_components/sonoff/translations/en.json @@ -12,7 +12,8 @@ "description": "Enter your [eWeLink account](https://www.ewelink.cc/en/) credentials", "data": { "username": "Email or phone (use any for DIY mode)", - "password": "Password (leave blank for DIY mode)" + "password": "Password (leave blank for DIY mode)", + "country_code": "Country code (leave blank for auto select)" } } } @@ -22,7 +23,7 @@ "init": { "data": { "mode": "Mode (auto is recommended for most users)", - "homes": "Homes (leave unchecked for \"current\" Home)", + "homes": "Homes (leave blank for active home in mobile app)", "debug": "Debug page (Integration > Menu > Known issues)" } }