class DaikinApi: """Daikin Residential API.""" def __init__(self, hass, entry): """Initialize a new Daikin Residential API.""" _LOGGER.debug("Initialing Daikin Residential API...") self.hass = hass self._config_entry = entry self.tokenSet = None if entry is not None: self.tokenSet = entry.data[CONF_TOKENSET].copy() configuration = { "issuer": DAIKIN_ISSUER, "authorization_endpoint": DAIKIN_CLOUD_URL + "/oauth2/authorize", "userinfo_endpoint": "userinfo_endpoint", "token_endpoint": DAIKIN_CLOUD_URL + "/oauth2/token", "token_endpoint_auth_methods_supported": ["none"], } self.openIdClientId = OPENID_CLIENT_ID self.openIdClient = Client(client_id=self.openIdClientId, config=configuration) self.openIdStore = {} _LOGGER.info("Daikin Residential API initialized.") async def doBearerRequest(self, resourceUrl, options=None, refreshed=False): if self.tokenSet is None: raise Exception( "Missing TokenSet. Please repeat Authentication process.") if not resourceUrl.startswith("http"): resourceUrl = "https://api.prod.unicloud.edc.dknadmin.be" + resourceUrl headers = { "user-agent": "Daikin/1.6.1.4681 CFNetwork/1209 Darwin/20.2.0", "x-api-key": "xw6gvOtBHq5b1pyceadRp6rujSNSZdjx2AqT03iC", "Authorization": "Bearer " + self.tokenSet["access_token"], "Content-Type": "application/json", } _LOGGER.debug("BEARER REQUEST URL: %s", resourceUrl) _LOGGER.debug("BEARER REQUEST HEADERS: %s", headers) if options is not None and "method" in options and options[ "method"] == "PATCH": _LOGGER.debug("BEARER REQUEST JSON: %s", options["json"]) func = functools.partial(requests.patch, resourceUrl, headers=headers, data=options["json"]) # res = requests.patch(resourceUrl, headers=headers, data=options["json"]) else: func = functools.partial(requests.get, resourceUrl, headers=headers) # res = requests.get(resourceUrl, headers=headers) try: res = await self.hass.async_add_executor_job(func) except Exception as e: _LOGGER.error("REQUEST FAILED: %s", e) _LOGGER.debug("BEARER RESPONSE CODE: %s", res.status_code) if res.status_code == 200: try: return res.json() except Exception: return res.text if res.status_code == 204: return True if not refreshed and res.status_code == 401: _LOGGER.debug("TOKEN EXPIRED: will refresh it (%s)", res.status_code) await self.refreshAccessToken() return await self.doBearerRequest(resourceUrl, options, True) raise Exception("Communication failed! Status: " + str(res.status_code)) async def refreshAccessToken(self): """Attempt to refresh the Access Token.""" url = "https://cognito-idp.eu-west-1.amazonaws.com" headers = { "Content-Type": "application/x-amz-json-1.1", "x-amz-target": "AWSCognitoIdentityProviderService.InitiateAuth", "x-amz-user-agent": "aws-amplify/0.1.x react-native", "User-Agent": "Daikin/1.6.1.4681 CFNetwork/1220.1 Darwin/20.3.0", } ref_json = { "ClientId": OPENID_CLIENT_ID, "AuthFlow": "REFRESH_TOKEN_AUTH", "AuthParameters": { "REFRESH_TOKEN": self.tokenSet["refresh_token"] }, } try: func = functools.partial(requests.post, url, headers=headers, json=ref_json) res = await self.hass.async_add_executor_job(func) # res = requests.post(url, headers=headers, json=ref_json) except Exception as e: _LOGGER.error("REQUEST FAILED: %s", e) _LOGGER.debug("refreshAccessToken response code: %s", res.status_code) _LOGGER.debug("refreshAccessToken response: %s", res.json()) res_json = res.json() data = self._config_entry.data.copy() if ("AuthenticationResult" in res_json and res_json["AuthenticationResult"]["AccessToken"] is not None and res_json["AuthenticationResult"]["TokenType"] == "Bearer"): self.tokenSet["access_token"] = res_json["AuthenticationResult"][ "AccessToken"] self.tokenSet["id_token"] = res_json["AuthenticationResult"][ "IdToken"] self.tokenSet["expires_at"] = int( datetime.datetime.now().timestamp()) + int( res_json["AuthenticationResult"]["ExpiresIn"]) data[CONF_TOKENSET] = self.tokenSet self.hass.config_entries.async_update_entry( entry=self._config_entry, data=data) _LOGGER.debug("TokenSet refreshed.") # _LOGGER.debug("TOKENSET REFRESHED: %s", self._config_entry.data) return self.tokenSet _LOGGER.warning( "CANNOT REFRESH TOKENSET (%s): will login again " + "and retrieve a new tokenSet.", res.status_code, ) try: await self.retrieveAccessToken(data[CONF_EMAIL], data[CONF_PASSWORD]) data[CONF_TOKENSET] = self.tokenSet self.hass.config_entries.async_update_entry( entry=self._config_entry, data=data) except Exception: raise Exception("Token refresh was not successful! Status: " + str(res.status_code)) async def _doAuthorizationRequest(self): func = functools.partial(self.openIdClient.provider_config, DAIKIN_ISSUER) await self.hass.async_add_executor_job(func) # self.openIdClient.provider_config(DAIKIN_ISSUER) args = {"response_type": ["code"], "scope": "openid"} state = (base64.urlsafe_b64encode( os.urandom(32)).decode("utf-8").replace("=", "")) _LOGGER.debug("STATE: %s", state) args = { "authorization_endpoint": DAIKIN_CLOUD_URL + "/oauth2/authorize", "userinfo_endpoint": "userinfo_endpoint", "response_type": ["code"], "scopes": "email,openid,profile", } self.openIdClient.redirect_uris = ["daikinunified://login"] _args, code_verifier = self.openIdClient.add_code_challenge() args.update(_args) self.openIdStore[state] = {"code_verifier": code_verifier} func = functools.partial(self.openIdClient.do_authorization_request, request_args=args, state=state) auth_resp = await self.hass.async_add_executor_job(func) self.state = state return auth_resp async def _doAccessTokenRequest(self, callbackUrl): # _LOGGER.debug('_doAccessTokenRequest: %s', callbackUrl) params = dict(parse.parse_qsl(parse.urlsplit(callbackUrl).query)) _LOGGER.debug("VERIFIER: %s", self.openIdStore[params["state"]]["code_verifier"]) state = self.state args = { "authorization_endpoint": DAIKIN_CLOUD_URL + "/oauth2/authorize", "token_endpoint": DAIKIN_CLOUD_URL + "/oauth2/token", "token_endpoint_auth_methods_supported": ["none"], # 'userinfo_endpoint': 'userinfo_endpoint', "response_type": ["code"], "scopes": "email,openid,profile", "state": state, "token_endpoint_auth_method": "none", # (default 'client_secret_basic') } if self.openIdStore[state] is None: raise Exception("Cannot decode response for State " + state + ". Please reload start page and try again!") if params["code"] is not None: callbackParams = { "code_verifier": self.openIdStore[state]["code_verifier"], "state": state, } # _LOGGER.debug('CB PARAMS: %s', callbackParams) # _LOGGER.debug('PROVIDER_INFO: %s', self.openIdClient.provider_info) func = functools.partial( self.openIdClient.do_access_token_request, request_args=args, extra_args=callbackParams, state=state, authn_method=None, ) rtk_resp = await self.hass.async_add_executor_job(func) # _LOGGER.debug('_RETRIEVETOKENS RESP: %s', rtk_resp) new_tokenset = { "access_token": rtk_resp["access_token"], "refresh_token": rtk_resp["refresh_token"], "expires_in": rtk_resp["expires_in"], "token_type": rtk_resp["token_type"], } _LOGGER.debug("TOKENSET RETRIEVED: %s", new_tokenset) return new_tokenset else: raise Exception("Daikin-Cloud: ERROR.") async def retrieveAccessToken(self, userName, password): _LOGGER.info("Retrieving new TokenSet...") # Extract csrf state cookies try: response = await self._doAuthorizationRequest() cookies = response.headers["set-cookie"] # _LOGGER.debug('COOKIES: %s', cookies) csrfStateCookie = "" for cookie in cookies.split(", "): for field in cookie.split(";"): if "csrf-state" in field: if csrfStateCookie != "": csrfStateCookie += "; " csrfStateCookie += field.strip() # _LOGGER.debug('CSRFSTATECOOKIE COOKIES: %s', csrfStateCookie) location = response.headers["location"] # _LOGGER.debug('LOCATION: %s', location) except Exception as e: raise Exception("Error trying to reach Authorization URL: %s", e) # Extract SAML Context try: func = functools.partial(requests.get, location, allow_redirects=False) response = await self.hass.async_add_executor_job(func) location = response.headers["location"] # _LOGGER.debug('LOCATION2: %s', location) regex = "samlContext=([^&]+)" ms = re.search(regex, location) samlContext = ms[1] except Exception as e: raise Exception("Error trying to follow redirect: %s", e) _LOGGER.debug("SAMLCONTEXT: %s", samlContext) # Extract API version try: func = functools.partial(requests.get, "https://cdns.gigya.com/js/gigya.js", {"apiKey": API_KEY}) resp = await self.hass.async_add_executor_job(func) body = resp.text # _LOGGER.debug('BODY: %s', body) regex = "(\d+-\d-\d+)" ms = re.search(regex, body) version = ms[0] _LOGGER.debug("VERSION: %s", version) except Exception as e: raise Exception("Error trying to extract API version: %s", e) # Extract the cookies used for the Single Sign On try: func = functools.partial( requests.get, "https://cdc.daikin.eu/accounts.webSdkBootstrap", { "apiKey": API_KEY, "sdk": "js_latest", "format": "json" }, ) resp = await self.hass.async_add_executor_job(func) ssoCookies = resp.headers["set-cookie"] # _LOGGER.debug('SSOCOOKIES: %s', ssoCookies) except Exception as e: raise Exception("Error trying to extract SSO cookies: %s", e) ssoCookies_arr = ssoCookies.split(", ") cookies = (ssoCookies_arr[0].split(";")[0].strip() + "; " + ssoCookies_arr[2].split(";")[0].strip() + "; " + ssoCookies_arr[4].split(";")[0].strip()) cookies += "; gig_bootstrap_" + API_KEY + "=cdc_ver4; " cookies += "gig_canary_" + API_KEY2 + "=false; " cookies += "gig_canary_ver_" + API_KEY2 + "=" + version + "; " cookies += "apiDomain_" + API_KEY2 + "=cdc.daikin.eu; " # _LOGGER.debug('COOKIES: %s', cookies) # OK, now let's try to Login login_token = "" try: headers = { "content-type": "application/x-www-form-urlencoded", "cookie": cookies, } req_json = { "loginID": userName, "password": password, "sessionExpiration": "31536000", "targetEnv": "jssdk", "include": "profile,", "loginMode": "standard", "riskContext": '{"b0":7527,"b2":4,"b5":1', "APIKey": API_KEY, "sdk": "js_latest", "authMode": "cookie", "pageURL": "https://my.daikin.eu/content/daikinid-cdc-saml/en/" + "login.html?samlContext=" + samlContext, "sdkBuild": "12208", "format": "json", } http_args = {} http_args["headers"] = headers func = functools.partial( requests.post, "https://cdc.daikin.eu/accounts.login", headers=headers, data=req_json, ) resp = await self.hass.async_add_executor_job(func) response = resp.json() _LOGGER.debug("LOGIN REPLY: %s", response) if (response is not None and response["errorCode"] == 0 and response["sessionInfo"] is not None and "login_token" in response["sessionInfo"]): login_token = response["sessionInfo"]["login_token"] else: raise Exception("Unknown Login error: " + response["errorDetails"]) except Exception as e: raise Exception("Login failed: %s", e) # _LOGGER.debug('LOGIN TOKEN: %s', login_token) samlResponse = "" relayState = "" expiry = str(int(time.time()) + 3600000) cookies = cookies + "glt_" + API_KEY + "=" + login_token + "; " cookies += "gig_loginToken_" + API_KEY2 + "=" + login_token + "; " cookies += "gig_loginToken_" + API_KEY2 + "_exp=" + expiry + "; " cookies += "gig_loginToken_" + API_KEY2 + "_visited=%2C" + API_KEY + ";" # _LOGGER.debug('COOKIES: %s', cookies) try: headers = {"cookie": cookies} req_json = {"samlContext": samlContext, "loginToken": login_token} url = "https://cdc.daikin.eu/saml/v2.0/" + API_KEY + "/idp/sso/continue" func = functools.partial(requests.post, url, headers=headers, data=req_json) resp = await self.hass.async_add_executor_job(func) response = resp.text # _LOGGER.debug('SAML: %s', response) regex = 'name="SAMLResponse" value="([^"]+)"' ms = re.search(regex, response) samlResponse = ms[1] regex = 'name="RelayState" value="([^"]+)"' ms = re.search(regex, response) relayState = ms[1] except Exception as e: raise Exception( "Authentication on SAML Identity Provider failed: %s", e) # _LOGGER.debug('SAMLRESPONSE: %s', samlResponse) # _LOGGER.debug('RELAYSTATE: %s', relayState) # Fetch the daikinunified URL daikinunified_url = "" try: headers = { "content-type": "application/x-www-form-urlencoded", "cookie": csrfStateCookie, } req_json = {"SAMLResponse": samlResponse, "RelayState": relayState} body = "SAMLResponse=" + samlResponse + "&RelayState=" + relayState url = DAIKIN_CLOUD_URL + "/saml2/idpresponse" func = functools.partial( requests.post, url, headers=headers, data=req_json, allow_redirects=False, ) response = await self.hass.async_add_executor_job(func) daikinunified_url = response.headers["location"] # _LOGGER.debug('DAIKINUNIFIED1: %s',response) # _LOGGER.debug('DAIKINUNIFIED2: %s',daikinunified) if "daikinunified" not in daikinunified_url: raise Exception( "Invalid final Authentication redirect. Location is " + daikinunified_url) except Exception as e: raise Exception( "Impossible to retrieve SAML Identity Provider's response: %s", e) try: self.openIdClient.parse_response( response=self.openIdClient.message_factory.get_response_type( "authorization_endpoint"), info=daikinunified_url, sformat="urlencoded", state=self.state, ) except Exception as e: raise Exception("Failed to parse response: %s", e) try: self.tokenSet = await self._doAccessTokenRequest(daikinunified_url) except Exception as e: raise Exception("Failed to retrieve access token: %s", e) _LOGGER.info("New TokenSet successfully retrieved.") async def getApiInfo(self): """Get Daikin API Info.""" return await self.doBearerRequest("/v1/info") async def getCloudDeviceDetails(self): """Get pure Device Data from the Daikin cloud devices.""" return await self.doBearerRequest("/v1/gateway-devices") async def getCloudDevices(self): """Get array of DaikinResidentialDevice objects and get their data.""" json_data = await self.getCloudDeviceDetails() res = {} for dev_data in json_data or []: device = Appliance(dev_data, self) device_model = device.get_value("gateway", "modelInfo") if device_model is None: _LOGGER.warning("Device '%s' is filtered out", device_model) else: res[dev_data["id"]] = device return res @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self, **kwargs): """Pull the latest data from Daikin.""" _LOGGER.debug("API UPDATE") json_data = await self.getCloudDeviceDetails() for dev_data in json_data or []: if dev_data["id"] in self.hass.data[DOMAIN][DAIKIN_DEVICES]: self.hass.data[DOMAIN][DAIKIN_DEVICES][ dev_data["id"]].setJsonData(dev_data)