diff --git a/custom_components/dreo/__init__.py b/custom_components/dreo/__init__.py index 4b6f2e8..c76a2df 100644 --- a/custom_components/dreo/__init__.py +++ b/custom_components/dreo/__init__.py @@ -11,43 +11,22 @@ from .const import ( DOMAIN, - SERVICE_UPDATE_DEVS, - DREO_DISCOVERY, DREO_FANS, DREO_MANAGER ) _LOGGER = logging.getLogger("dreo") - DOMAIN = "dreo" -COMPONENT_DOMAIN = "dreo" -COMPONENT_DATA = "dreo-data" -COMPONENT_ATTRIBUTION = "Data provided by Dreo servers." -COMPONENT_BRAND = "Dreo" - -CONFIG_SCHEMA = vol.Schema( - { - COMPONENT_DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_REGION, default='us'): cv.string - } - ), - }, - extra=vol.ALLOW_EXTRA -) -async def async_setup(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: _LOGGER.debug("async_setup") - """Set up dreo as config entry.""" - username = config_entry[COMPONENT_DOMAIN].get(CONF_USERNAME) - password = config_entry[COMPONENT_DOMAIN].get(CONF_PASSWORD) - region = config_entry[COMPONENT_DOMAIN].get(CONF_REGION) - _LOGGER.debug(username) + _LOGGER.debug(config_entry.data.get(CONF_USERNAME)) + username = config_entry.data.get(CONF_USERNAME) + password = config_entry.data.get(CONF_PASSWORD) + region = "us" from .pydreo import PyDreo manager = PyDreo(username, password, region) @@ -64,15 +43,11 @@ async def async_setup(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: _LOGGER.error("Unable to load devices from the dreo server") return False - # TODO: What's the difference? - #device_dict = await hass.async_add_executor_job(async_process_devices, hass, manager) - device_dict = await async_process_devices(hass, manager) + device_dict = process_devices(manager) - # TODO: Move all of this into the manager init? manager.start_monitoring() - #forward_setup = hass.config_entries.async_forward_entry_setup - hass.data[COMPONENT_DATA] = manager + forward_setup = hass.config_entries.async_forward_entry_setup hass.data[DOMAIN] = {} hass.data[DOMAIN][DREO_MANAGER] = manager @@ -84,32 +59,10 @@ async def async_setup(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: fans.extend(device_dict[DREO_FANS]) platforms.append(Platform.FAN) - #await hass.config_entries.async_forward_entry_setups(config_entry, platforms) - - async def async_new_device_discovery(service: ServiceCall) -> None: - """Discover if new devices should be added.""" - manager = hass.data[DOMAIN][DREO_MANAGER] - fans = hass.data[DOMAIN][DREO_FANS] - - dev_dict = await async_process_devices(hass, manager) - fan_devs = dev_dict.get(DREO_FANS, []) - - fan_set = set(fan_devs) - new_fans = list(fan_set.difference(fans)) - if new_fans and fans: - fans.extend(new_fans) - async_dispatcher_send(hass, DREO_DISCOVERY.format(DREO_FANS), new_fans) - return - if new_fans and not fans: - fans.extend(new_fans) - hass.async_create_task(forward_setup(config_entry, Platform.FAN)) - - - manager.start_monitoring() - + await hass.config_entries.async_forward_entry_setups(config_entry, platforms) return True -async def async_process_devices(hass, manager): +def process_devices(manager) -> dict: """Assign devices to proper component.""" devices = {} devices[DREO_FANS] = [] diff --git a/custom_components/dreo/config_flow.py b/custom_components/dreo/config_flow.py new file mode 100644 index 0000000..dccbeac --- /dev/null +++ b/custom_components/dreo/config_flow.py @@ -0,0 +1,68 @@ +import logging +from typing import Any, Dict, Optional +from collections import OrderedDict + +from homeassistant import config_entries, core +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_REGION +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry +) +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +import voluptuous as vol +from .const import * + +from .pydreo import PyDreo + +_LOGGER = logging.getLogger("dreo") + +class DreoFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Dreo Custom config flow.""" + + # The schema version of the entries that it creates + # Home Assistant will call your migrate method if the version changes + VERSION = 1 + + def __init__(self) -> None: + """Instantiate config flow.""" + self._username = None + self._password = None + self.data_schema = OrderedDict() + self.data_schema[vol.Required(CONF_USERNAME)] = str + self.data_schema[vol.Required(CONF_PASSWORD)] = str + + @callback + def _show_form(self, errors=None): + """Show form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if not user_input: + return self._show_form() + + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + + manager = PyDreo(self._username, self._password, "us") + login = await self.hass.async_add_executor_job(manager.login) + if not login: + return self._show_form(errors={"base": "invalid_auth"}) + + return self.async_create_entry( + title=self._username, + data={CONF_USERNAME: self._username, CONF_PASSWORD: self._password}, + ) \ No newline at end of file diff --git a/custom_components/dreo/fan.py b/custom_components/dreo/fan.py index 51c5d11..c1c354c 100644 --- a/custom_components/dreo/fan.py +++ b/custom_components/dreo/fan.py @@ -17,21 +17,13 @@ ) from .basedevice import DreoBaseDeviceHA -from .const import (DOMAIN, DREO_DISCOVERY, DREO_FANS) +from .const import (DOMAIN, DREO_DISCOVERY, DREO_FANS, DREO_MANAGER) from .pydreo.constant import * from .pydreo.pydreofan import PyDreoFan _LOGGER = logging.getLogger("dreo") -DEV_TYPE_TO_HA = { - "DR-HTF008S": "fan", -} - -from . import ( - COMPONENT_DATA -) - -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, @@ -41,10 +33,10 @@ async def async_setup_platform( _LOGGER.info("Starting Dreo Fan Platform") _LOGGER.debug("Dreo Fan:async_setup_platform") - dreoData = hass.data[COMPONENT_DATA] + manager = hass.data[DOMAIN][DREO_MANAGER] fansHAs = [] - for fanEntity in dreoData.fans: + for fanEntity in manager.fans: fansHAs.append(DreoFanHA(fanEntity)) async_add_entities(fansHAs) diff --git a/custom_components/dreo/manifest.json b/custom_components/dreo/manifest.json index af89541..e9fcf72 100644 --- a/custom_components/dreo/manifest.json +++ b/custom_components/dreo/manifest.json @@ -2,10 +2,9 @@ "domain": "dreo", "name": "Dreo", "codeowners": ["@jeffsteinbok"], - "config_flow": false, + "config_flow": true, "dependencies": [], "documentation": "https://github.com/jeffsteinbok/hass-dreo/blob/master/README.md", - "integration_type": "hub", "iot_class": "cloud_push", "issue_tracker": "https://github.com/jeffsteinbok/hass-dreo/issues", "requirements": ["websockets"], diff --git a/custom_components/dreo/pydreo/__init__.py b/custom_components/dreo/pydreo/__init__.py index e6514e4..b01c130 100644 --- a/custom_components/dreo/pydreo/__init__.py +++ b/custom_components/dreo/pydreo/__init__.py @@ -14,7 +14,7 @@ import logging import time from itertools import chain -from typing import Tuple +from typing import Tuple, Optional from .pydreobasedevice import PyDreoBaseDevice from .pydreofan import PyDreoFan @@ -23,7 +23,7 @@ import asyncio import websockets -from .constant import LOGGER_NAME +from .constant import * __version__ = "0.0.1" @@ -33,7 +33,6 @@ DEFAULT_ENER_UP_INT: int = 21600 - def object_factory(dev_type, config, dreo : "PyDreo") -> Tuple[str, PyDreoBaseDevice]: """Get device type and instantiate class.""" def fans(dev_type, config, manager): @@ -56,16 +55,15 @@ def fans(dev_type, config, manager): class PyDreo: # pylint: disable=function-redefined """Dreo API functions.""" - def __init__(self, username, password, region, redact=True): + def __init__(self, username, password, redact=True): """Initialize Dreo class with username, password and time zone.""" + self.auth_region = DREO_AUTH_REGION_NA # Will get the region from the auth call self._redact = redact if redact: self.redact = redact self.username = username self.password = password - self.region = region - self.api_url = f"https://app-api-{region}.dreo-cloud.com" self.token = None self.account_id = None self.devices = None @@ -81,6 +79,16 @@ def __init__(self, username, password, region, redact=True): 'fans': self.fans, } + @property + def apiServerRegion(self) -> str: + """Return region.""" + if (self.auth_region == DREO_AUTH_REGION_NA): + return DREO_API_REGION_US + elif (self.auth_region == DREO_AUTH_REGION_EU): + return DREO_API_REGION_EU + else: + _LOGGER.error("Invalid Auth Region:", self.auth_region) + @property def redact(self) -> bool: """Return debug flag.""" @@ -102,7 +110,7 @@ def add_dev_test(self, new_dev: dict) -> bool: for _, v in self._dev_list.items(): for dev in v: if ( - dev.deviceId == new_dev.get('deviceId') + dev.deviceId == new_dev.get(DEVICEID_KEY) ): return False return True @@ -113,8 +121,8 @@ def set_dev_id(devices: list) -> list: dev_num = 0 dev_rem = [] for dev in devices: - if dev.get('deviceId') is not None: - dev['deviceId'] = dev['deviceId'] + if dev.get(DEVICEID_KEY) is not None: + dev[DEVICEID_KEY] = dev[DEVICEID_KEY] dev_num += 1 if dev_rem: devices = [i for j, i in enumerate( @@ -166,23 +174,16 @@ def process_devices(self, dev_list: list) -> bool: return True def load_devices(self) -> bool: - """Return tuple listing outlets, switches, and fans of devices.""" if not self.enabled: return False self.in_process = True proc_return = False - response, _ = Helpers.call_api( - self.api_url, - '/api/v2/user-device/device/list', - 'get', - headers=Helpers.req_headers(self), - json_object=Helpers.req_body(self, 'devicelist'), - ) + response, _ = self.call_dreo_api(DREO_API_DEVICELIST) if response and Helpers.code_check(response): - if 'data' in response and 'list' in response['data']: - device_list = response['data']['list'] + if DATA_KEY in response and LIST_KEY in response[DATA_KEY]: + device_list = response[DATA_KEY][LIST_KEY] proc_return = self.process_devices(device_list) else: _LOGGER.error('Device list in response not found') @@ -200,23 +201,17 @@ def load_device_state(self, device: PyDreoBaseDevice) -> bool: self.in_process = True proc_return = False - response, _ = Helpers.call_api( - self.api_url, - '/api/user-device/device/state', - 'get', - headers=Helpers.req_headers(self), - json_object={ **Helpers.req_body(self, 'devicestate'), - 'deviceSn': device.sn }, - ) + response, _ = self.call_dreo_api(DREO_API_DEVICESTATE, { DEVICESN_KEY: device.sn }) if response and Helpers.code_check(response): - if 'data' in response and 'mixed' in response['data']: - device_state = response['data']['mixed'] + if DATA_KEY in response and MIXED_KEY in response[DATA_KEY]: + device_state = response[DATA_KEY][MIXED_KEY] device.update_state(device_state) + proc_return = True else: _LOGGER.error('Mixed state in response not found') else: - _LOGGER.warning('Error retrieving device state') + _LOGGER.error('Error retrieving device state') self.in_process = False @@ -233,22 +228,21 @@ def login(self) -> bool: if pass_check is False: _LOGGER.error('Password invalid') return False - response, _ = Helpers.call_api( - self.api_url, - '/api/oauth/login', 'post', - headers=Helpers.req_headers(self), - json_object=Helpers.req_body(self, 'login') - ) - print (response) - if Helpers.code_check(response) and 'data' in response: - self.token = response["data"]["access_token"] - print("TOKEN") - print(self.token) - self.enabled = True - _LOGGER.debug('Login successful') - _LOGGER.debug('token %s', self.token) - - return True + response, _ = self.call_dreo_api(DREO_API_LOGIN) + + if Helpers.code_check(response) and DATA_KEY in response: + # get the region code from auth + authRegion = response[DATA_KEY][REGION_KEY] + _LOGGER.info("Dreo Auth reports user region as: {0}".format(authRegion)) + if (authRegion != self.auth_region): + _LOGGER.info("Dreo Auth reports different region than current; retrying.") + self.auth_region = authRegion + return self.login() + else: + self.token = response[DATA_KEY][ACCESS_TOKEN_KEY] + self.enabled = True + _LOGGER.debug('Login successful') + return True _LOGGER.error('Error logging in with username and password') return False @@ -261,22 +255,42 @@ def device_time_check(self) -> bool: return True return False + def call_dreo_api(self, + api: str, + json_object: Optional[dict] = None, + headers: Optional[dict] = None) -> tuple: + _LOGGER.debug("Calling Dreo API: {0}".format(api)) + api_url = DREO_API_URL_FORMAT.format(self.apiServerRegion) + + if (json_object is None): + json_object = {} + + json_object_full = { **Helpers.req_body(self, api), + **json_object } + + return Helpers.call_api(api_url, + DREO_APIS[api][DREO_API_LIST_PATH], + DREO_APIS[api][DREO_API_LIST_METHOD], + json_object_full, + Helpers.req_headers(self)) + def start_monitoring(self): + '''Initialize the websocket and start monitoring''' + + def start_ws_wrapper() : + asyncio.run(self.start_websocket()) + self._event_thread = threading.Thread( - name="DreoWebSocketStream", target=self.start_test, args=() + name="DreoWebSocketStream", target=start_ws_wrapper, args=() ) self._event_thread.setDaemon(True) self._event_thread.start() return True - - def start_test(self): - asyncio.run(self.start_websocket()) - async def start_websocket(self): _LOGGER.info("Starting WebSocket for incoming changes.") # open websocket - url = f"wss://wsb-{self.region}.dreo-cloud.com/websocket?accessToken={self.token}×tamp={str(int(time.time() * 1000))}" + url = f"wss://wsb-{self.apiServerRegion}.dreo-cloud.com/websocket?accessToken={self.token}×tamp={str(int(time.time() * 1000))}" async with websockets.connect(url) as ws: self.ws = ws _LOGGER.info('WebSocket successfully opened') @@ -323,5 +337,5 @@ def send_command(self, device : PyDreoBaseDevice, params): 'timestamp': str(int(time.time() * 1000)) } content = json.dumps(fullParams) - print(content) + _LOGGER.debug(content) asyncio.run(self.ws.send(content)) \ No newline at end of file diff --git a/custom_components/dreo/pydreo/constant.py b/custom_components/dreo/pydreo/constant.py index cf111e0..d043ef3 100644 --- a/custom_components/dreo/pydreo/constant.py +++ b/custom_components/dreo/pydreo/constant.py @@ -1,6 +1,13 @@ LOGGER_NAME = "pydreo" +ACCESS_TOKEN_KEY = "access_token" +REGION_KEY = "region" +DATA_KEY = "data" +LIST_KEY = "list" +MIXED_KEY = "mixed" +DEVICEID_KEY = "deviceid" +DEVICESN_KEY = "deviceSn" REPORTED_KEY = "reported" STATE_KEY = "state" POWERON_KEY = "poweron" @@ -11,6 +18,30 @@ VOICEON_KEY = "voiceon" LEDALWAYSON_KEY = "ledalwayson" +DREO_API_URL_FORMAT = "https://app-api-{0}.dreo-cloud.com" # {0} is the 2 letter region code + +DREO_API_LIST_PATH = "path" +DREO_API_LIST_METHOD = "method" + +DREO_API_LOGIN = "login" +DREO_API_DEVICELIST = "devicelist" +DREO_API_DEVICESTATE = "devicestate" + +DREO_APIS = { + DREO_API_LOGIN : { DREO_API_LIST_PATH: '/api/oauth/login', + DREO_API_LIST_METHOD: 'post' }, + DREO_API_DEVICELIST: { DREO_API_LIST_PATH: '/api/v2/user-device/device/list', + DREO_API_LIST_METHOD: 'get' }, + DREO_API_DEVICESTATE: { DREO_API_LIST_PATH: '/api/user-device/device/state', + DREO_API_LIST_METHOD: 'get' } +} + +DREO_AUTH_REGION_NA = "NA" +DREO_AUTH_REGION_EU = "EU" + +DREO_API_REGION_US = "us" +DREO_API_REGION_EU = "eu" + FAN_MODE_NORMAL = "normal" FAN_MODE_NATURAL = "natural" FAN_MODE_AUTO = "auto" diff --git a/custom_components/dreo/pydreo/helpers.py b/custom_components/dreo/pydreo/helpers.py index 1391aeb..c4ee6a5 100644 --- a/custom_components/dreo/pydreo/helpers.py +++ b/custom_components/dreo/pydreo/helpers.py @@ -56,7 +56,6 @@ def req_body(cls, manager, type_) -> dict: body['himei'] = "faede31549d649f58864093158787ec9" body['password'] = cls.hash_password(manager.password) body['scope'] = "all" - print("HI THERE") print(body) elif type_ == 'devicelist': @@ -105,7 +104,10 @@ def redactor(cls, stringvalue: str) -> str: return stringvalue @staticmethod - def call_api(url: str, api: str, method: str, json_object: Optional[dict] = None, + def call_api(url: str, + api: str, + method: str, + json_object: Optional[dict] = None, headers: Optional[dict] = None) -> tuple: """Make API calls by passing endpoint, header and body.""" response = None @@ -135,8 +137,6 @@ def call_api(url: str, api: str, method: str, json_object: Optional[dict] = Non ) except requests.exceptions.RequestException as e: _LOGGER.debug(e) - except Exception as e: # pylint: disable=broad-except - _LOGGER.debug(e) else: if r.status_code == 200: status_code = 200 @@ -156,42 +156,4 @@ def code_check(r: dict) -> bool: return False if isinstance(r, dict) and r.get('code') == 0: return True - return False - - @staticmethod - def build_details_dict(r: dict) -> dict: - """Build details dictionary from API response.""" - return { - 'active_time': r.get('activeTime', 0), - 'energy': r.get('energy', 0), - 'night_light_status': r.get('nightLightStatus', None), - 'night_light_brightness': r.get('nightLightBrightness', None), - 'night_light_automode': r.get('nightLightAutomode', None), - 'power': r.get('power', 0), - 'voltage': r.get('voltage', 0), - } - - @staticmethod - def build_config_dict(r: dict) -> dict: - """Build configuration dictionary from API response.""" - if r.get('threshold') is not None: - threshold = r.get('threshold') - else: - threshold = r.get('threshHold') - return { - 'current_firmware_version': r.get('currentFirmVersion'), - 'latest_firmware_version': r.get('latestFirmVersion'), - 'maxPower': r.get('maxPower'), - 'threshold': threshold, - 'power_protection': r.get('powerProtectionStatus'), - 'energy_saving_status': r.get('energySavingStatus'), - } - - @staticmethod - def named_tuple_to_str(named_tuple: NamedTuple) -> str: - """Convert named tuple to string.""" - tuple_str = '' - for key, val in named_tuple._asdict().items(): - tuple_str += f'{key}: {val}, ' - return tuple_str - + return False \ No newline at end of file diff --git a/custom_components/dreo/strings.json b/custom_components/dreo/strings.json new file mode 100644 index 0000000..131a560 --- /dev/null +++ b/custom_components/dreo/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "title": "Enter Dreo Username and Password", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } + } \ No newline at end of file diff --git a/custom_components/dreo/translations/en.json b/custom_components/dreo/translations/en.json new file mode 100644 index 0000000..2124319 --- /dev/null +++ b/custom_components/dreo/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "title": "Enter Dreo Username and Password", + "data": { + "username": "Email Address", + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Authentication failed." + }, + "abort": { + "single_instance_allowed": "Only one instance is allowed." + } + } + } \ No newline at end of file