Compare commits

..

13 Commits

Author SHA1 Message Date
36079c3c77 Add oven and program filter 2023-04-08 04:06:36 +02:00
6d40e73881 Fix missing command attribute 2023-03-22 23:09:48 +01:00
6c3da96a03 Fix not showing device active when running 2023-03-22 22:42:40 +01:00
3c81c46c8b Bump version to v0.4.1 2023-03-21 10:10:49 +01:00
0d92d037a5 Merge pull request #8 from drudgebg/main
Create td.py
2023-03-21 09:58:28 +01:00
5e32dd76c0 Create td.py
Need to work with Tumble Dryers. Tested and work with Tumble Dryer haier HD80-A3959.
2023-03-21 08:39:20 +02:00
f8dde06510 get translation keys 2023-03-21 01:24:15 +01:00
5ec1a760f6 get translation keys 2023-03-21 01:11:33 +01:00
cd5a4c345e Add more api calls 2023-03-21 01:11:33 +01:00
d707342f8f Merge pull request #7 from alexandre-leites/main
Added WD params
2023-03-18 23:39:15 +01:00
e12edda588 Added WD params 2023-03-18 18:02:20 +01:00
409aa11fcb Clean up authentication 2023-03-17 01:56:04 +01:00
f73ef26e74 Restore last command value only if possible #6 2023-03-14 23:17:36 +01:00
12 changed files with 249 additions and 92 deletions

View File

@ -83,11 +83,26 @@ async with HonConnection(USER, PASSWORD) as hon:
setting.value = setting.min + setting.step setting.value = setting.min + setting.step
``` ```
## Translation
To get the translation of some keys like programs, you can use the translation command to see all of hOn's available translations
```commandline
$ pyhOn translate es
AC:
APPLIANCE_RENAME:
CONTENT_CHOOSE_NAME: Antes de continuar, debes elegir un nombre...
DEFAULT_NAME: Aire acondicionado
TITLE_CHOOSE_NAME: ¡Elije un nombre para tu aire acondicionado!
TITLE_SAVE_NAME: Para cambiar el nombre de tu aparato:
...
```
This generates a huge output. It is recommended to pipe this into a file
```commandline
$ pyhOn translate fr > hon_fr.yaml
$ pyhOn translate en --json > hon_en.json
```
## Tested devices ## Tested devices
- Haier Washing Machine HW90 - Haier Washing Machine HW90
_Unfortunately I don't have any more Haier appliances..._
## Usage example ## Usage example
This library is used for the custom [HomeAssistant Integration "Haier hOn"](https://github.com/Andre0512/hOn). This library is used for the custom [HomeAssistant Integration "Haier hOn"](https://github.com/Andre0512/hOn).

View File

@ -1,9 +1,9 @@
#!/usr/bin/env python #!/usr/bin/env python
import argparse import argparse
import asyncio import asyncio
import json
import logging import logging
import sys import sys
import time
from getpass import getpass from getpass import getpass
from pathlib import Path from pathlib import Path
from pprint import pprint from pprint import pprint
@ -25,6 +25,9 @@ def get_arguments():
keys = subparser.add_parser("keys", help="print as key format") keys = subparser.add_parser("keys", help="print as key format")
keys.add_argument("keys", help="print as key format", action="store_true") keys.add_argument("keys", help="print as key format", action="store_true")
keys.add_argument("--all", help="print also full keys", action="store_true") keys.add_argument("--all", help="print also full keys", action="store_true")
translate = subparser.add_parser("translate", help="print available translation keys")
translate.add_argument("translate", help="language (de, en, fr...)", metavar="LANGUAGE")
translate.add_argument("--json", help="print as json", action="store_true")
return vars(parser.parse_args()) return vars(parser.parse_args())
@ -81,8 +84,22 @@ def create_command(commands, concat=False):
return result return result
async def translate(language, json_output=False):
async with HonConnection() as hon:
keys = await hon.translation_keys(language)
if json_output:
print(json.dumps(keys, indent=4))
else:
clean_keys = json.dumps(keys).replace("\\n", "\\\\n").replace("\\\\r", "").replace("\\r", "")
keys = json.loads(clean_keys)
pretty_print(keys)
async def main(): async def main():
args = get_arguments() args = get_arguments()
if language := args.get("translate"):
await translate(language, json_output=args.get("json"))
return
if not (user := args["user"]): if not (user := args["user"]):
user = input("User for hOn account: ") user = input("User for hOn account: ")
if not (password := args["password"]): if not (password := args["password"]):
@ -95,7 +112,6 @@ async def main():
attr = "get" if args.get("all") else "pop" attr = "get" if args.get("all") else "pop"
key_print(data["attributes"].__getattribute__(attr)("parameters")) key_print(data["attributes"].__getattribute__(attr)("parameters"))
key_print(data.__getattribute__(attr)("appliance")) key_print(data.__getattribute__(attr)("appliance"))
key_print(data.__getattribute__(attr)("commands"))
key_print(data) key_print(data)
pretty_print(create_command(device.commands, concat=True)) pretty_print(create_command(device.commands, concat=True))
else: else:

View File

@ -15,7 +15,7 @@ _LOGGER = logging.getLogger()
class HonConnection: class HonConnection:
def __init__(self, email, password, session=None) -> None: def __init__(self, email="", password="", session=None) -> None:
super().__init__() super().__init__()
self._email = email self._email = email
self._password = password self._password = password
@ -26,7 +26,8 @@ class HonConnection:
async def __aenter__(self): async def __aenter__(self):
self._session = aiohttp.ClientSession() self._session = aiohttp.ClientSession()
await self.setup() if self._email and self._password:
await self.setup()
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
@ -94,6 +95,48 @@ class HonConnection:
return {} return {}
return result["payload"]["history"] return result["payload"]["history"]
async def last_activity(self, device: HonDevice):
url = f"{const.API_URL}/commands/v1/retrieve-last-activity"
params = {"macAddress": device.mac_address}
async with self._session.get(url, params=params, headers=await self._headers) as response:
result = await response.json()
if result and (activity := result.get("attributes")):
return activity
return {}
async def appliance_configuration(self):
url = f"{const.API_URL}/config/v1/appliance-configuration"
headers = {"x-api-key": const.API_KEY, "content-type": "application/json"}
async with self._session.get(url, headers=headers) as response:
result = await response.json()
if result and (data := result.get("payload")):
return data
return {}
async def app_config(self, language="en", beta=True):
headers = {"x-api-key": const.API_KEY, "content-type": "application/json"}
url = f"{const.API_URL}/app-config"
payload = {
"languageCode": language,
"beta": beta,
"appVersion": const.APP_VERSION,
"os": const.OS
}
payload = json.dumps(payload, separators=(',', ':'))
async with self._session.post(url, headers=headers, data=payload) as response:
if (result := await response.json()) and (data := result.get("payload")):
return data
return {}
async def translation_keys(self, language="en"):
headers = {"x-api-key": const.API_KEY, "content-type": "application/json"}
config = await self.app_config(language=language)
if url := config.get("language", {}).get("jsonPath"):
async with self._session.get(url, headers=headers) as response:
if result := await response.json():
return result
return {}
async def load_attributes(self, device: HonDevice, loop=False): async def load_attributes(self, device: HonDevice, loop=False):
params = { params = {
"macAddress": device.mac_address, "macAddress": device.mac_address,

16
pyhon/appliances/ov.py Normal file
View File

@ -0,0 +1,16 @@
from pyhon.parameter import HonParameterEnum
class Appliance:
def __init__(self):
filters = ["receipt", "standard, special"]
data = {'defaultValue': filters[0], 'enumValues': filters}
self._program_filter = HonParameterEnum("program_filter", data)
def data(self, data):
return data
def settings(self, settings):
settings["program_filter"] = self._program_filter
settings["startProgram.program"].filter = self._program_filter.value
return settings

10
pyhon/appliances/td.py Normal file
View File

@ -0,0 +1,10 @@
class Appliance:
def data(self, data):
if data["attributes"]["lastConnEvent"]["category"] == "DISCONNECTED":
data["attributes"]["parameters"]["machMode"] = "0"
data["active"] = bool(data.get("attributes", {}).get("activity"))
data["pause"] = data["attributes"]["parameters"]["machMode"] == "3"
return data
def settings(self, settings):
return settings

10
pyhon/appliances/wd.py Normal file
View File

@ -0,0 +1,10 @@
class Appliance:
def data(self, data):
if data["attributes"]["lastConnEvent"]["category"] == "DISCONNECTED":
data["attributes"]["parameters"]["machMode"] = "0"
data["active"] = bool(data.get("attributes", {}).get("activity"))
data["pause"] = data["attributes"]["parameters"]["machMode"] == "3"
return data
def settings(self, settings):
return settings

View File

@ -1,10 +1,10 @@
class Appliance: class Appliance:
def __init__(self, data): def data(self, data):
self._data = data if data["attributes"]["lastConnEvent"]["category"] == "DISCONNECTED":
data["attributes"]["parameters"]["machMode"] = "0"
data["active"] = bool(data.get("attributes", {}).get("activity"))
data["pause"] = data["attributes"]["parameters"]["machMode"] == "3"
return data
def get(self): def settings(self, settings):
if self._data["attributes"]["lastConnEvent"]["category"] == "DISCONNECTED": return settings
self._data["attributes"]["parameters"]["machMode"] = "0"
self._data["active"] = bool(self._data.get("activity"))
self._data["pause"] = self._data["attributes"]["parameters"]["machMode"] == "3"
return self._data

View File

@ -6,6 +6,7 @@ import urllib
from urllib import parse from urllib import parse
import aiohttp as aiohttp import aiohttp as aiohttp
from yarl import URL
from pyhon import const from pyhon import const
@ -14,7 +15,8 @@ _LOGGER = logging.getLogger()
class HonAuth: class HonAuth:
def __init__(self) -> None: def __init__(self) -> None:
self._framework = "" self._access_token = ""
self._refresh_token = ""
self._cognito_token = "" self._cognito_token = ""
self._id_token = "" self._id_token = ""
@ -26,71 +28,15 @@ class HonAuth:
def id_token(self): def id_token(self):
return self._id_token return self._id_token
async def _get_frontdoor_url(self, session, email, password): @property
data = { def access_token(self):
"message": { return self._access_token
"actions": [
{
"id": "79;a",
"descriptor": "apex://LightningLoginCustomController/ACTION$login",
"callingDescriptor": "markup://c:loginForm",
"params": {
"username": email,
"password": password,
"startUrl": ""
}
}
]
},
"aura.context": {
"mode": "PROD",
"fwuid": self._framework,
"app": "siteforce:loginApp2",
"loaded": {"APPLICATION@markup://siteforce:loginApp2": "YtNc5oyHTOvavSB9Q4rtag"},
"dn": [],
"globals": {},
"uad": False},
"aura.pageURI": f"SmartHome/s/login/?language={const.LANGUAGE}",
"aura.token": None}
params = {"r": 3, "other.LightningLoginCustom.login": 1} @property
async with session.post( def refresh_token(self):
const.AUTH_API + "/s/sfsites/aura", return self._refresh_token
headers={"Content-Type": "application/x-www-form-urlencoded"},
data="&".join(f"{k}={json.dumps(v)}" for k, v in data.items()),
params=params
) as response:
if response.status != 200:
_LOGGER.error("Unable to connect to the login service: %s\n%s", response.status, await response.text())
return ""
try:
text = await response.text()
return (await response.json())["events"][0]["attributes"]["values"]["url"]
except json.JSONDecodeError:
if framework := re.findall('clientOutOfSync.*?Expected: ([\\w-]+?) Actual: (.*?)"', text):
self._framework, actual = framework[0]
_LOGGER.debug('Framework update from "%s" to "%s"', self._framework, actual)
return await self._get_frontdoor_url(session, email, password)
_LOGGER.error("Unable to retrieve the frontdoor URL. Message: " + text)
return ""
async def _prepare_login(self, session, email, password): async def _load_login(self, session):
if not (frontdoor_url := await self._get_frontdoor_url(session, email, password)):
return False
async with session.get(frontdoor_url) as resp:
if resp.status != 200:
_LOGGER.error("Unable to connect to the login service: %s", resp.status)
return False
params = {"retURL": "/SmartHome/apex/CustomCommunitiesLanding"}
async with session.get(f"{const.AUTH_API}/apex/ProgressiveLogin", params=params) as resp:
if resp.status != 200:
_LOGGER.error("Unable to connect to the login service: %s", resp.status)
return False
return True
async def _login(self, session):
nonce = secrets.token_hex(16) nonce = secrets.token_hex(16)
nonce = f"{nonce[:8]}-{nonce[8:12]}-{nonce[12:16]}-{nonce[16:20]}-{nonce[20:]}" nonce = f"{nonce[:8]}-{nonce[8:12]}-{nonce[12:16]}-{nonce[16:20]}-{nonce[20:]}"
params = { params = {
@ -102,17 +48,100 @@ class HonAuth:
"nonce": nonce "nonce": nonce
} }
params = "&".join([f"{k}={v}" for k, v in params.items()]) params = "&".join([f"{k}={v}" for k, v in params.items()])
async with session.get(f"{const.AUTH_API}/services/oauth2/authorize?{params}") as resp: async with session.get(f"{const.AUTH_API}/services/oauth2/authorize/expid_Login?{params}") as resp:
if id_token := re.findall("id_token=(.*?)&", await resp.text()): if not (login_url := re.findall("url = '(.+?)'", await resp.text())):
self._id_token = id_token[0] return False
return True async with session.get(login_url[0], allow_redirects=False) as redirect1:
if not (url := redirect1.headers.get("Location")):
return False
async with session.get(url, allow_redirects=False) as redirect2:
if not (url := redirect2.headers.get("Location") + "&System=IoT_Mobile_App&RegistrationSubChannel=hOn"):
return False
async with session.get(URL(url, encoded=True)) as login_screen:
if context := re.findall('"fwuid":"(.*?)","loaded":(\\{.*?})', await login_screen.text()):
fw_uid, loaded_str = context[0]
loaded = json.loads(loaded_str)
login_url = login_url[0].replace("/".join(const.AUTH_API.split("/")[:-1]), "")
return fw_uid, loaded, login_url
return False return False
async def authorize(self, email, password, mobile_id): async def _login(self, session, email, password, fw_uid, loaded, login_url):
async with aiohttp.ClientSession() as session: data = {
if not await self._prepare_login(session, email, password): "message": {
"actions": [
{
"id": "79;a",
"descriptor": "apex://LightningLoginCustomController/ACTION$login",
"callingDescriptor": "markup://c:loginForm",
"params": {
"username": email,
"password": password,
"startUrl": parse.unquote(login_url.split("startURL=")[-1]).split("%3D")[0]
}
}
]
},
"aura.context": {
"mode": "PROD",
"fwuid": fw_uid,
"app": "siteforce:loginApp2",
"loaded": loaded,
"dn": [],
"globals": {},
"uad": False},
"aura.pageURI": login_url,
"aura.token": None}
params = {"r": 3, "other.LightningLoginCustom.login": 1}
async with session.post(
const.AUTH_API + "/s/sfsites/aura",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data="&".join(f"{k}={json.dumps(v)}" for k, v in data.items()),
params=params
) as response:
if response.status == 200:
try:
return (await response.json())["events"][0]["attributes"]["values"]["url"]
except json.JSONDecodeError:
pass
_LOGGER.error("Unable to login: %s\n%s", response.status, await response.text())
return ""
async def _get_token(self, session, url):
async with session.get(url) as resp:
if resp.status != 200:
_LOGGER.error("Unable to get token: %s", resp.status)
return False return False
if not await self._login(session): url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await resp.text())
async with session.get(url[0]) as resp:
if resp.status != 200:
_LOGGER.error("Unable to get token: %s", resp.status)
return False
url = re.findall("href\\s*=\\s*[\"'](.*?)[\"']", await resp.text())
url = "/".join(const.AUTH_API.split("/")[:-1]) + url[0]
async with session.get(url) as resp:
if resp.status != 200:
_LOGGER.error("Unable to connect to the login service: %s", resp.status)
return False
text = await resp.text()
if access_token := re.findall("access_token=(.*?)&", text):
self._access_token = access_token[0]
if refresh_token := re.findall("refresh_token=(.*?)&", text):
self._refresh_token = refresh_token[0]
if id_token := re.findall("id_token=(.*?)&", text):
self._id_token = id_token[0]
return True
async def authorize(self, email, password, mobile_id):
headers = {"user-agent": const.USER_AGENT}
async with aiohttp.ClientSession(headers=headers) as session:
if login_site := await self._load_login(session):
fw_uid, loaded, login_url = login_site
else:
return False
if not (url := await self._login(session, email, password, fw_uid, loaded, login_url)):
return False
if not await self._get_token(session, url):
return False return False
post_headers = {"Content-Type": "application/json", "id-token": self._id_token} post_headers = {"Content-Type": "application/json", "id-token": self._id_token}

View File

@ -1,5 +1,6 @@
AUTH_API = "https://he-accounts.force.com/SmartHome" AUTH_API = "https://he-accounts.force.com/SmartHome"
API_URL = "https://api-iot.he.services" API_URL = "https://api-iot.he.services"
API_KEY = "GRCqFhC6Gk@ikWXm1RmnSmX1cm,MxY-configuration"
APP = "hon" APP = "hon"
# All seen id's (different accounts, different devices) are the same, so I guess this hash is static # All seen id's (different accounts, different devices) are the same, so I guess this hash is static
CLIENT_ID = "3MVG9QDx8IX8nP5T2Ha8ofvlmjLZl5L_gvfbT9.HJvpHGKoAS_dcMN8LYpTSYeVFCraUnV.2Ag1Ki7m4znVO6" CLIENT_ID = "3MVG9QDx8IX8nP5T2Ha8ofvlmjLZl5L_gvfbT9.HJvpHGKoAS_dcMN8LYpTSYeVFCraUnV.2Ag1Ki7m4znVO6"
@ -7,4 +8,4 @@ APP_VERSION = "1.53.7"
OS_VERSION = 31 OS_VERSION = 31
OS = "android" OS = "android"
DEVICE_MODEL = "exynos9820" DEVICE_MODEL = "exynos9820"
LANGUAGE = "en" USER_AGENT = "Chrome/110.0.5481.153"

View File

@ -1,4 +1,5 @@
import importlib import importlib
from contextlib import suppress
from pyhon.commands import HonCommand from pyhon.commands import HonCommand
from pyhon.parameter import HonParameterFixed from pyhon.parameter import HonParameterFixed
@ -17,7 +18,7 @@ class HonDevice:
self._attributes = {} self._attributes = {}
try: try:
self._extra = importlib.import_module(f'pyhon.appliances.{self.appliance_type.lower()}') self._extra = importlib.import_module(f'pyhon.appliances.{self.appliance_type.lower()}').Appliance()
except ModuleNotFoundError: except ModuleNotFoundError:
self._extra = None self._extra = None
@ -95,7 +96,8 @@ class HonDevice:
command = self.commands[name] command = self.commands[name]
for key, data in command.settings.items(): for key, data in command.settings.items():
if not isinstance(data, HonParameterFixed) and parameters.get(key) is not None: if not isinstance(data, HonParameterFixed) and parameters.get(key) is not None:
data.value = parameters.get(key) with suppress(ValueError):
data.value = parameters.get(key)
async def load_commands(self): async def load_commands(self):
raw = await self._connector.load_commands(self) raw = await self._connector.load_commands(self)
@ -122,6 +124,8 @@ class HonDevice:
for name, command in self._commands.items(): for name, command in self._commands.items():
for key, setting in command.settings.items(): for key, setting in command.settings.items():
result[f"{name}.{key}"] = setting result[f"{name}.{key}"] = setting
if self._extra:
return self._extra.settings(result)
return result return result
@property @property
@ -148,5 +152,5 @@ class HonDevice:
result = {"attributes": self.attributes, "appliance": self.appliance, "statistics": self.statistics, result = {"attributes": self.attributes, "appliance": self.appliance, "statistics": self.statistics,
**self.parameters} **self.parameters}
if self._extra: if self._extra:
return result | self._extra.Appliance(result).get() return self._extra.data(result)
return result return result

View File

@ -94,7 +94,7 @@ class HonParameterEnum(HonParameter):
@property @property
def values(self): def values(self):
return sorted([str(value) for value in self._values]) return [str(value) for value in self._values]
@property @property
def value(self): def value(self):
@ -115,6 +115,7 @@ class HonParameterProgram(HonParameterEnum):
self._value = command._program self._value = command._program
self._values = command._multi self._values = command._multi
self._typology = "enum" self._typology = "enum"
self._filter = ""
@property @property
def value(self): def value(self):
@ -126,3 +127,15 @@ class HonParameterProgram(HonParameterEnum):
self._command.set_program(value) self._command.set_program(value)
else: else:
raise ValueError(f"Allowed values {self._values}") raise ValueError(f"Allowed values {self._values}")
@property
def filter(self):
return self._filter
@filter.setter
def filter(self, filter):
self._filter = filter
@property
def values(self):
return sorted([str(value) for value in self._values if not self._filter or self._filter in str(value)])

View File

@ -7,7 +7,7 @@ with open("README.md", "r") as f:
setup( setup(
name="pyhOn", name="pyhOn",
version="0.3.7", version="0.5.0",
author="Andre Basche", author="Andre Basche",
description="Control hOn devices with python", description="Control hOn devices with python",
long_description=long_description, long_description=long_description,