Skip to content

Commit

Permalink
Initial release
Browse files Browse the repository at this point in the history
  • Loading branch information
schmittx committed Jun 30, 2023
1 parent 4449f4a commit 7258a31
Show file tree
Hide file tree
Showing 182 changed files with 4,078 additions and 4,278 deletions.
15 changes: 1 addition & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,4 @@ Custom component to allow control of [GE Appliances (SmartHQ)](https://www.geapp
4. Follow the prompts.

## Supported Devices
- Fridge
- Oven
- Dishwasher / F&P Dual Dishwasher
- Laundry (Washer/Dryer)
- Whole Home Water Filter
- Whole Home Water Softener
- Whole Home Water Heater
- A/C (Portable, Split, Window, Built-In)
- Range Hood
- Advantium
- Microwave
- Opal Ice Maker
- Coffee Maker / Espresso Maker
- Beverage Center
- Dishwasher
4 changes: 2 additions & 2 deletions custom_components/ge_appliances/__init__.py
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""The ge_appliances integration."""

import logging
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
import voluptuous as vol

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_REGION, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_REGION
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady

from .const import DOMAIN
from .exceptions import HaAuthError, HaCannotConnect
from .update_coordinator import GeHomeUpdateCoordinator
Expand Down
9 changes: 9 additions & 0 deletions custom_components/ge_appliances/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""GE Home SDK"""

__version__ = "0.5.13"


from .clients import *
from .erd import *
from .exception import *
from .ge_appliance import GeAppliance
27 changes: 27 additions & 0 deletions custom_components/ge_appliances/api/clients/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""GE Client implementations"""

import logging
from .const import (
EVENT_ADD_APPLIANCE,
EVENT_APPLIANCE_INITIAL_UPDATE,
EVENT_APPLIANCE_STATE_CHANGE,
EVENT_APPLIANCE_AVAILABLE,
EVENT_APPLIANCE_UNAVAILABLE,
EVENT_APPLIANCE_UPDATE_RECEIVED,
EVENT_CONNECTED,
EVENT_DISCONNECTED,
EVENT_GOT_APPLIANCE_LIST,
EVENT_GOT_APPLIANCE_FEATURES,
EVENT_STATE_CHANGED
)
from .const import LOGIN_REGIONS
from .base_client import GeBaseClient
from .websocket_client import GeWebsocketClient
from .async_login_flows import async_get_oauth2_token, async_refresh_oauth2_token

_LOGGER = logging.getLogger(__name__)

try:
from .xmpp_client import GeXmppClient
except ImportError:
_LOGGER.info("XMPP client not avaible. You may need to install slximpp.")
20 changes: 20 additions & 0 deletions custom_components/ge_appliances/api/clients/async_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@

import asyncio
from typing import AsyncIterator


async def CancellableAsyncIterator(async_iterator: AsyncIterator, cancellation_event: asyncio.Event) -> AsyncIterator:
cancellation_task = asyncio.create_task(cancellation_event.wait())
result_iter = async_iterator.__aiter__()
while not cancellation_event.is_set():
done, pending = await asyncio.wait(
[cancellation_task, asyncio.create_task(result_iter.__anext__())],
return_when=asyncio.FIRST_COMPLETED
)
for done_task in done:
if done_task == cancellation_task:
for pending_task in pending:
await pending_task
break
else:
yield done_task.result()
206 changes: 206 additions & 0 deletions custom_components/ge_appliances/api/clients/async_login_flows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
from http.cookies import SimpleCookie
from aiohttp import BasicAuth, ClientSession
from lxml import etree
from urllib.parse import urlparse, parse_qs
import logging

from ..exception import *
from .const import (
LOGIN_COOKIE_DOMAIN,
LOGIN_REGION_COOKIE_NAME,
LOGIN_REGIONS,
LOGIN_URL,
OAUTH2_CLIENT_ID,
OAUTH2_CLIENT_SECRET,
OAUTH2_REDIRECT_URI
)

try:
import re2 as re
except ImportError:
import re

_LOGGER = logging.getLogger(__name__)

def set_login_cookie(session: ClientSession, account_region: str):
c = SimpleCookie()
c[LOGIN_REGION_COOKIE_NAME] = LOGIN_REGIONS[account_region]
c[LOGIN_REGION_COOKIE_NAME]["domain"] = LOGIN_COOKIE_DOMAIN
c[LOGIN_REGION_COOKIE_NAME]["path"] = "/"
c[LOGIN_REGION_COOKIE_NAME]["httponly"] = True
c[LOGIN_REGION_COOKIE_NAME]["secure"] = True

session.cookie_jar.update_cookies(c)

async def async_get_authorization_code(session: ClientSession, account_username: str, account_password: str, account_region: str):
params = {
'client_id': OAUTH2_CLIENT_ID,
'response_type': 'code',
'access_type': 'offline',
'redirect_uri': OAUTH2_REDIRECT_URI,
}

set_login_cookie(session, account_region)

async with session.get(f'{LOGIN_URL}/oauth2/auth', params=params) as resp:
if 400 <= resp.status < 500:
raise GeAuthFailedError(await resp.text())
if resp.status >= 500:
raise GeGeneralServerError(await resp.text())
resp_text = await resp.text()

email_regex = (
r'^\s*(\w+(?:(?:-\w+)|(?:\.\w+)|(?:\+\w+))*\@'
r'[A-Za-z0-9]+(?:(?:\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9][A-Za-z0-9]+)\s*$'
)
clean_username = re.sub(email_regex, r'\1', account_username)

etr = etree.HTML(resp_text)
post_data = {
i.attrib['name']: i.attrib['value']
for i in etr.xpath("//form[@id = 'frmsignin']//input")
if 'value' in i.keys()
}
post_data['username'] = clean_username
post_data['password'] = account_password

async with session.post(f'{LOGIN_URL}/oauth2/g_authenticate', data=post_data, allow_redirects=False) as resp:
if 400 <= resp.status < 500:
raise GeAuthFailedError(f"Problem with request, code: {resp.status}")
if resp.status >= 500:
raise GeGeneralServerError(f"Server error, code: {resp.status}")
try:
if resp.status == 200:
#if we have an OK response, probably need to "authorize", but could also
#be an authentication issue
code = await async_handle_ok_response(session, await resp.text())
else:
#assume response has a location header from which we can get a code
code = parse_qs(urlparse(resp.headers['Location']).query)['code'][0]
except Exception as exc:
resp_text = await resp.text()
_LOGGER.exception(f"There was a problem getting the authorization code, response details: {resp.__dict__}")
raise GeAuthFailedError(f'Could not obtain authorization code') from exc
return code

async def async_handle_ok_response(session: ClientSession, resp_text: str) -> str:
"""Handles an OK 200 response from the login process"""

#parse the response into html
etr = etree.HTML(resp_text)
post_data = {}

try:
#first try to pull all the form values
post_data = {
i.attrib['name']: i.attrib['value']
for i in etr.xpath("//form[@id = 'frmsignin']//input")
if 'value' in i.keys()
}
except:
pass

#if we have an authorized key, try to authorize the application
if "authorized" in post_data:
code = await async_authorize_application(session, post_data)
return code

#try to get the error based on the known responses
try:
reason = etr.find(".//div[@id='alert_pane']").text.translate({ord(c):"" for c in "\t\n"})
raise GeAuthFailedError(f"Authentication failed, reason: {reason}")
except GeAuthFailedError:
raise #re-raise only auth failed errors, all others are irrelevant at this point
except:
pass

#throw an exception by default
raise GeAuthFailedError("Authentication failed for unknown reason, please review response text for clues.")

async def async_authorize_application(session: ClientSession, post_data: dict) -> str:
"""Authorizes the application if needed"""

_LOGGER.info(
"The application requires authentication and will attempt to grant consent automatically. " + \
"Visit https://accounts.brillion.geappliances.com/consumer/active/applications to deauthorize.")

post_data["authorized"] = "yes"

async with session.post(f'{LOGIN_URL}/oauth2/code', data=post_data, allow_redirects=False) as resp:
if 400 <= resp.status < 500:
raise GeAuthFailedError(f"Problem with request, code: {resp.status}")
if resp.status >= 500:
raise GeGeneralServerError(f"Server error, code: {resp.status}")
try:
#oauth2/code appears to give the same header as we expect in the normal case, so let's try to use it
code = parse_qs(urlparse(resp.headers['Location']).query)['code'][0]
except Exception as exc:
resp_text = await resp.text()
_LOGGER.exception(f"There was a problem authorizing the application, response details: {resp.__dict__}")
raise GeAuthFailedError(f'Could not authorize application') from exc
return code

async def async_get_oauth2_token(session: ClientSession, account_username: str, account_password: str, account_region: str):
"""Hackily get an oauth2 token until I can be bothered to do this correctly"""

#attempt to get the authorization code
code = await async_get_authorization_code(session, account_username, account_password, account_region)

#get the token
post_data = {
'code': code,
'client_id': OAUTH2_CLIENT_ID,
'client_secret': OAUTH2_CLIENT_SECRET,
'redirect_uri': OAUTH2_REDIRECT_URI,
'grant_type': 'authorization_code',
}
try:
auth = BasicAuth(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
async with session.post(f'{LOGIN_URL}/oauth2/token', data=post_data, auth=auth) as resp:
if 400 <= resp.status < 500:
raise GeAuthFailedError(f"Problem with request, code: {resp.status}")
if resp.status >= 500:
raise GeGeneralServerError(f"Server error, code: {resp.status}")
oauth_token = await resp.json()
try:
access_token = oauth_token['access_token']
return oauth_token
except KeyError:
raise GeAuthFailedError(f'Failed to get a token: {oauth_token}')
except Exception as exc:
resp_text = await resp.text()
_LOGGER.exception(f"Could not get OAuth token, response details: {resp.__dict__}")
raise GeAuthFailedError(f'Could not get OAuth token') from exc
except Exception as exc:
raise GeAuthFailedError(f'Could not get OAuth token') from exc

async def async_refresh_oauth2_token(session: ClientSession, refresh_token: str):
""" Refreshes an OAuth2 Token based on a refresh token """

post_data = {
'redirect_uri': OAUTH2_REDIRECT_URI,
'client_id': OAUTH2_CLIENT_ID,
'client_secret': OAUTH2_CLIENT_SECRET,
'grant_type': 'refresh_token',
'refresh_token': refresh_token
}
try:
auth = BasicAuth(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET)
async with session.post(f'{LOGIN_URL}/oauth2/token', data=post_data, auth=auth) as resp:
if 400 <= resp.status < 500:
raise GeAuthFailedError(f"Problem with request, code: {resp.status}")
if resp.status >= 500:
raise GeGeneralServerError(f"Server error, code: {resp.status}")
oauth_token = await resp.json()
try:
access_token = oauth_token['access_token']
return oauth_token
except KeyError:
raise GeAuthFailedError(f'Failed to get a token: {oauth_token}')
except Exception as exc:
resp_text = await resp.text()
_LOGGER.exception(f"Could not refresh OAuth token, response details: {resp.__dict__}")
raise GeAuthFailedError(f'Could not refresh OAuth token') from exc
except Exception as exc:
raise GeAuthFailedError(f'Could not refresh OAuth token') from exc

Loading

0 comments on commit 7258a31

Please sign in to comment.