Compare commits

...

31 Commits

Author SHA1 Message Date
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
0ddbdd0b4e Bump version to v0.3.7 2023-03-14 18:51:48 +01:00
241ee95d5c Merge pull request #5 from alexandre-leites/main
Fixing Support for H-WASHER 500
2023-03-14 18:30:30 +01:00
c9d4461f8f Update commands.py to fix None values 2023-03-14 14:30:00 +01:00
318e60e91e Update device.py trying to set fixed value from history 2023-03-14 14:29:25 +01:00
fde41ac456 Update parameter.py to fix parameter being set from history 2023-03-14 14:28:22 +01:00
aeabbe64e2 Bump version to v0.3.6 2023-03-13 23:08:17 +01:00
7c99ffeaf7 Ignore virtual virtual wine cellar #3 2023-03-13 23:07:36 +01:00
2941b57d09 Merge pull request #2 from alexandre-leites/fix/support-hw500
Fixing Support for H-WASHER 500
2023-03-13 22:32:17 +01:00
f00ee03c0d Update parameter.py to support missing parameter in some cases with HW5600 2023-03-13 03:59:42 +01:00
ec7355e341 Update api.py to correctly set firmwareId 2023-03-13 03:58:45 +01:00
96233dd7fd Handle appliances without attributes 2023-03-11 22:55:11 +01:00
ef4f7f7398 Restore last command parameters 2023-03-11 02:31:56 +01:00
fc8c92d538 cut and lower program keys 2023-03-11 01:10:27 +01:00
fe4f6e766e Fix connection loose 2023-03-11 00:28:53 +01:00
6b346f766f Fix command start 2023-03-08 23:01:59 +01:00
f52f84711f Fix bugs 2023-03-08 22:18:44 +01:00
c4d21be388 Print all keys 2023-03-08 21:53:53 +01:00
5acc81acc3 Clean up attribute structure 2023-03-08 21:13:19 +01:00
43d61ab853 Show more command data 2023-03-06 19:45:46 +01:00
79a121263f Fix connection issues 2023-03-06 18:57:08 +01:00
00ac6912e2 Fixed setting wrong current value 2023-03-05 19:03:03 +01:00
0f630e854e Read out connection 2023-03-05 18:46:51 +01:00
1ef5f7a64d Add badges 2023-03-04 22:38:43 +01:00
326b5edcff More documentation 2023-03-04 22:19:48 +01:00
13 changed files with 449 additions and 206 deletions

2
.gitignore vendored
View File

@ -3,4 +3,4 @@ venv/
__pycache__/ __pycache__/
dist/ dist/
**/*.egg-info/ **/*.egg-info/
test.py test*

View File

@ -1,23 +1,46 @@
**This python package is unofficial and is not related in any way to Haier. It was developed by reversed engineered requests and can stop working at anytime!** **This python package is unofficial and is not related in any way to Haier. It was developed by reversed engineered requests and can stop working at anytime!**
# pyhOn # pyhOn
[![PyPI - Status](https://img.shields.io/pypi/status/pyhOn)](https://pypi.org/project/pyhOn)
[![PyPI](https://img.shields.io/pypi/v/pyhOn?color=blue)](https://pypi.org/project/pyhOn)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyhOn)](https://www.python.org/)
[![PyPI - License](https://img.shields.io/pypi/l/pyhOn)](https://github.com/Andre0512/pyhOn/blob/main/LICENSE)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/pyhOn)](https://pypistats.org/packages/pyhon)
Control your Haier appliances with python! Control your Haier appliances with python!
The idea behind this library is, to make the use of all available commands as simple as possible.
## Installation
```bash
pip install pyhOn
```
### Quick overview ### Quick overview
To see the available options of the appliances from your Haier Account, use the commandline-tool `pyhOn` To get an idea of what is possible, use the commandline-tool `pyhOn`. This command requests all available options of connected appliances from the hOn api of your Haier Account.
```commandline ```commandline
$ pyhOn --user example@mail.com --password pass123 $ pyhOn --user example@mail.com --password pass123
========== Waschmaschine ========== ========== WM - Waschmaschine ==========
commands:
pauseProgram: pauseProgram command
resumeProgram: resumeProgram command
startProgram: startProgram command
stopProgram: stopProgram command
data: data:
actualWeight: 0 attributes:
airWashTempLevel: 0 parameters:
airWashTime: 0 ...
antiAllergyStatus: 0 texture: 1
... totalElectricityUsed: 28.71
totalWashCycle: 35
totalWaterUsed: 2494
transMode: 0
...
settings:
startProgram:
rinseIterations:
max: 5
min: 3
step: 1
spinSpeed:
- 0
- 400
- 600
- 800
...
``` ```
## Python-API ## Python-API
@ -37,7 +60,52 @@ asyncio.run(devices_example())
### Execute a command ### Execute a command
```python ```python
async with HonConnection(USER, PASSWORD) as hon: async with HonConnection(USER, PASSWORD) as hon:
washing_machine = hon[0] washing_machine = hon.devices[0]
pause_command = washing_machine.commands["pauseProgram"] pause_command = washing_machine.commands["pauseProgram"]
await pause_command.send() await pause_command.send()
``` ```
### Set command parameter
```python
async with HonConnection(USER, PASSWORD) as hon:
washing_machine = hon.devices[0]
start_command = washing_machine.commands["startProgram"]
for name, setting in start_command.settings:
print("Setting", name)
print("Current value", setting.value)
if setting.typology == "enum":
print("Available values", setting.values)
setting.value = setting.values[0]
elif setting.typology == "range":
print("Min value", setting.min)
print("Max value", setting.max)
print("Step value", 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
- Haier Washing Machine HW90
## Usage example
This library is used for the custom [HomeAssistant Integration "Haier hOn"](https://github.com/Andre0512/hOn).
## Contribution
Any kind of contribution is welcome!

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
@ -21,10 +21,17 @@ def get_arguments():
parser = argparse.ArgumentParser(description="pyhOn: Command Line Utility") parser = argparse.ArgumentParser(description="pyhOn: Command Line Utility")
parser.add_argument("-u", "--user", help="user for haier hOn account") parser.add_argument("-u", "--user", help="user for haier hOn account")
parser.add_argument("-p", "--password", help="password for haier hOn account") parser.add_argument("-p", "--password", help="password for haier hOn account")
subparser = parser.add_subparsers(title="commands", metavar="COMMAND")
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("--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())
# yaml.dump() would be done the same, but needs an additional import... # yaml.dump() would be done the same, but needs an additional dependency...
def pretty_print(data, key="", intend=0, is_list=False): def pretty_print(data, key="", intend=0, is_list=False):
if type(data) is list: if type(data) is list:
if key: if key:
@ -47,17 +54,70 @@ def pretty_print(data, key="", intend=0, is_list=False):
print(f"{' ' * intend}{'- ' if is_list else ''}{key}{': ' if key else ''}{data}") print(f"{' ' * intend}{'- ' if is_list else ''}{key}{': ' if key else ''}{data}")
def key_print(data, key="", start=True):
if type(data) is list:
for i, value in enumerate(data):
key_print(value, key=f"{key}.{i}", start=False)
elif type(data) is dict:
for k, value in sorted(data.items()):
key_print(value, key=k if start else f"{key}.{k}", start=False)
else:
print(f"{key}: {data}")
def create_command(commands, concat=False):
result = {}
for name, command in commands.items():
if not concat:
result[name] = {}
for parameter, data in command.parameters.items():
if data.typology == "enum":
value = data.values
elif data.typology == "range":
value = {"min": data.min, "max": data.max, "step": data.step}
else:
continue
if not concat:
result[name][parameter] = value
else:
result[f"{name}.{parameter}"] = value
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"]):
password = getpass("Password for hOn account: ") password = getpass("Password for hOn account: ")
async with HonConnection(user, password) as hon: async with HonConnection(user, password) as hon:
for device in hon.devices: for device in hon.devices:
print("=" * 10, device.nick_name, "=" * 10) print("=" * 10, device.appliance_type, "-", device.nick_name, "=" * 10)
pretty_print({"commands": device.commands}) if args.get("keys"):
pretty_print({"data": device.data}) data = device.data.copy()
attr = "get" if args.get("all") else "pop"
key_print(data["attributes"].__getattribute__(attr)("parameters"))
key_print(data.__getattribute__(attr)("appliance"))
key_print(data.__getattribute__(attr)("commands"))
key_print(data)
pretty_print(create_command(device.commands, concat=True))
else:
pretty_print({"data": device.data})
pretty_print({"settings": create_command(device.commands)})
def start(): def start():

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):
@ -55,11 +56,13 @@ class HonConnection:
appliances = (await resp.json())["payload"]["appliances"] appliances = (await resp.json())["payload"]["appliances"]
for appliance in appliances: for appliance in appliances:
device = HonDevice(self, appliance) device = HonDevice(self, appliance)
if device.mac_address is None:
continue
await asyncio.gather(*[ await asyncio.gather(*[
device.load_attributes(), device.load_attributes(),
device.load_commands(), device.load_commands(),
device.load_statistics()]) device.load_statistics()])
self._devices.append(device) self._devices.append(device)
except json.JSONDecodeError: except json.JSONDecodeError:
_LOGGER.error("No JSON Data after GET: %s", await resp.text()) _LOGGER.error("No JSON Data after GET: %s", await resp.text())
return False return False
@ -67,15 +70,15 @@ class HonConnection:
async def load_commands(self, device: HonDevice): async def load_commands(self, device: HonDevice):
params = { params = {
"applianceType": device.appliance_type_name, "applianceType": device.appliance_type,
"code": device.code, "code": device.appliance["code"],
"applianceModelId": device.appliance_model_id, "applianceModelId": device.appliance_model_id,
"firmwareId": "41", "firmwareId": device.appliance["eepromId"],
"macAddress": device.mac_address, "macAddress": device.mac_address,
"fwVersion": device.fw_version, "fwVersion": device.appliance["fwVersion"],
"os": const.OS, "os": const.OS,
"appVersion": const.APP_VERSION, "appVersion": const.APP_VERSION,
"series": device.series, "series": device.appliance["series"],
} }
url = f"{const.API_URL}/commands/v1/retrieve" url = f"{const.API_URL}/commands/v1/retrieve"
async with self._session.get(url, params=params, headers=await self._headers) as response: async with self._session.get(url, params=params, headers=await self._headers) as response:
@ -84,20 +87,75 @@ class HonConnection:
return {} return {}
return result return result
async def load_attributes(self, device: HonDevice): async def command_history(self, device: HonDevice):
url = f"{const.API_URL}/commands/v1/appliance/{device.mac_address}/history"
async with self._session.get(url, headers=await self._headers) as response:
result = await response.json()
if not result or not result.get("payload"):
return {}
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):
params = { params = {
"macAddress": device.mac_address, "macAddress": device.mac_address,
"applianceType": device.appliance_type_name, "applianceType": device.appliance_type,
"category": "CYCLE" "category": "CYCLE"
} }
url = f"{const.API_URL}/commands/v1/context" url = f"{const.API_URL}/commands/v1/context"
async with self._session.get(url, params=params, headers=await self._headers) as response: async with self._session.get(url, params=params, headers=await self._headers) as response:
if response.status == 403 and not loop:
_LOGGER.error("%s - Error %s - %s", url, response.status, await response.text())
self._request_headers.pop("cognito-token", None)
self._request_headers.pop("id-token", None)
return await self.load_attributes(device, loop=True)
return (await response.json()).get("payload", {}) return (await response.json()).get("payload", {})
async def load_statistics(self, device: HonDevice): async def load_statistics(self, device: HonDevice):
params = { params = {
"macAddress": device.mac_address, "macAddress": device.mac_address,
"applianceType": device.appliance_type_name "applianceType": device.appliance_type
} }
url = f"{const.API_URL}/commands/v1/statistics" url = f"{const.API_URL}/commands/v1/statistics"
async with self._session.get(url, params=params, headers=await self._headers) as response: async with self._session.get(url, params=params, headers=await self._headers) as response:
@ -125,7 +183,7 @@ class HonConnection:
}, },
"ancillaryParameters": ancillary_parameters, "ancillaryParameters": ancillary_parameters,
"parameters": parameters, "parameters": parameters,
"applianceType": device.appliance_type_name "applianceType": device.appliance_type
} }
url = f"{const.API_URL}/commands/v1/send" url = f"{const.API_URL}/commands/v1/send"
async with self._session.post(url, headers=await self._headers, json=data) as resp: async with self._session.post(url, headers=await self._headers, json=data) as resp:

View File

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

@ -0,0 +1,10 @@
class Appliance:
def __init__(self, data):
self._data = data
def get(self):
if self._data["attributes"]["lastConnEvent"]["category"] == "DISCONNECTED":
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

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

@ -0,0 +1,10 @@
class Appliance:
def __init__(self, data):
self._data = data
def get(self):
if self._data["attributes"]["lastConnEvent"]["category"] == "DISCONNECTED":
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

@ -2,12 +2,12 @@ from pyhon.parameter import HonParameterFixed, HonParameterEnum, HonParameterRan
class HonCommand: class HonCommand:
def __init__(self, name, attributes, connector, device, multi=None, category=""): def __init__(self, name, attributes, connector, device, multi=None, program=""):
self._connector = connector self._connector = connector
self._device = device self._device = device
self._name = name self._name = name
self._multi = multi or {} self._multi = multi or {}
self._category = category self._program = program
self._description = attributes.get("description", "") self._description = attributes.get("description", "")
self._parameters = self._create_parameters(attributes.get("parameters", {})) self._parameters = self._create_parameters(attributes.get("parameters", {}))
self._ancillary_parameters = self._create_parameters(attributes.get("ancillaryParameters", {})) self._ancillary_parameters = self._create_parameters(attributes.get("ancillaryParameters", {}))
@ -31,18 +31,15 @@ class HonCommand:
@property @property
def parameters(self): def parameters(self):
result = {key: parameter.value for key, parameter in self._parameters.items()} return self._parameters
if self._multi:
result |= {"program": self._category}
return result
@property @property
def ancillary_parameters(self): def ancillary_parameters(self):
return {key: parameter.value for key, parameter in self._ancillary_parameters.items()} return {key: parameter.value for key, parameter in self._ancillary_parameters.items()}
async def send(self): async def send(self):
return await self._connector.send_command(self._device, self._name, self.parameters, parameters = {name: parameter.value for name, parameter in self._parameters.items()}
self.ancillary_parameters) return await self._connector.send_command(self._device, self._name, parameters, self.ancillary_parameters)
def get_programs(self): def get_programs(self):
return self._multi return self._multi
@ -69,5 +66,6 @@ class HonCommand:
@property @property
def settings(self): def settings(self):
return {s: self._parameters[s] for s in self.setting_keys} """Parameters with typology enum and range"""
return {s: self._parameters.get(s) for s in self.setting_keys if self._parameters.get(s) is not None}

View File

@ -1,10 +1,11 @@
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"
APP_VERSION = "1.51.9" 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,8 +1,14 @@
import importlib
from contextlib import suppress
from pyhon.commands import HonCommand from pyhon.commands import HonCommand
from pyhon.parameter import HonParameterFixed
class HonDevice: class HonDevice:
def __init__(self, connector, appliance): def __init__(self, connector, appliance):
if attributes := appliance.get("attributes"):
appliance["attributes"] = {v["parName"]: v["parValue"] for v in attributes}
self._appliance = appliance self._appliance = appliance
self._connector = connector self._connector = connector
self._appliance_model = {} self._appliance_model = {}
@ -11,74 +17,41 @@ class HonDevice:
self._statistics = {} self._statistics = {}
self._attributes = {} self._attributes = {}
@property try:
def appliance_id(self): self._extra = importlib.import_module(f'pyhon.appliances.{self.appliance_type.lower()}')
return self._appliance.get("applianceId") except ModuleNotFoundError:
self._extra = None
def __getitem__(self, item):
if "." in item:
result = self.data
for key in item.split("."):
if all([k in "0123456789" for k in key]) and type(result) is list:
result = result[int(key)]
else:
result = result[key]
return result
else:
if item in self.data:
return self.data[item]
if item in self.attributes["parameters"]:
return self.attributes["parameters"].get(item)
return self.appliance[item]
def get(self, item, default=None):
try:
return self[item]
except (KeyError, IndexError):
return default
@property @property
def appliance_model_id(self): def appliance_model_id(self):
return self._appliance.get("applianceModelId") return self._appliance.get("applianceModelId")
@property @property
def appliance_status(self): def appliance_type(self):
return self._appliance.get("applianceStatus")
@property
def appliance_type_id(self):
return self._appliance.get("applianceTypeId")
@property
def appliance_type_name(self):
return self._appliance.get("applianceTypeName") return self._appliance.get("applianceTypeName")
@property
def brand(self):
return self._appliance.get("brand")
@property
def code(self):
return self._appliance.get("code")
@property
def connectivity(self):
return self._appliance.get("connectivity")
@property
def coords(self):
return self._appliance.get("coords")
@property
def eeprom_id(self):
return self._appliance.get("eepromId")
@property
def eeprom_name(self):
return self._appliance.get("eepromName")
@property
def enrollment_date(self):
return self._appliance.get("enrollmentDate")
@property
def first_enrollment(self):
return self._appliance.get("firstEnrollment")
@property
def first_enrollment_tbc(self):
return self._appliance.get("firstEnrollmentTBC")
@property
def fw_version(self):
return self._appliance.get("fwVersion")
@property
def id(self):
return self._appliance.get("id")
@property
def last_update(self):
return self._appliance.get("lastUpdate")
@property @property
def mac_address(self): def mac_address(self):
return self._appliance.get("macAddress") return self._appliance.get("macAddress")
@ -91,22 +64,6 @@ class HonDevice:
def nick_name(self): def nick_name(self):
return self._appliance.get("nickName") return self._appliance.get("nickName")
@property
def purchase_date(self):
return self._appliance.get("purchaseDate")
@property
def serial_number(self):
return self._appliance.get("serialNumber")
@property
def series(self):
return self._appliance.get("series")
@property
def water_hard(self):
return self._appliance.get("waterHard")
@property @property
def commands_options(self): def commands_options(self):
return self._appliance_model.get("options") return self._appliance_model.get("options")
@ -123,6 +80,25 @@ class HonDevice:
def statistics(self): def statistics(self):
return self._statistics return self._statistics
@property
def appliance(self):
return self._appliance
async def _recover_last_command_states(self, commands):
command_history = await self._connector.command_history(self)
for name, command in commands.items():
last = next((index for (index, d) in enumerate(command_history) if d.get("command", {}).get("commandName") == name), None)
if last is None:
continue
parameters = command_history[last].get("command", {}).get("parameters", {})
if command._multi and parameters.get("program"):
command.set_program(parameters.pop("program").split(".")[-1].lower())
command = self.commands[name]
for key, data in command.settings.items():
if not isinstance(data, HonParameterFixed) and parameters.get(key) is not None:
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)
self._appliance_model = raw.pop("applianceModel") self._appliance_model = raw.pop("applianceModel")
@ -134,11 +110,13 @@ class HonDevice:
commands[command] = HonCommand(command, attr, self._connector, self) commands[command] = HonCommand(command, attr, self._connector, self)
elif "parameters" in attr[list(attr)[0]]: elif "parameters" in attr[list(attr)[0]]:
multi = {} multi = {}
for category, attr2 in attr.items(): for program, attr2 in attr.items():
cmd = HonCommand(command, attr2, self._connector, self, multi=multi, category=category) program = program.split(".")[-1].lower()
multi[category] = cmd cmd = HonCommand(command, attr2, self._connector, self, multi=multi, program=program)
multi[program] = cmd
commands[command] = cmd commands[command] = cmd
self._commands = commands self._commands = commands
await self._recover_last_command_states(commands)
@property @property
def settings(self): def settings(self):
@ -153,13 +131,13 @@ class HonDevice:
result = {} result = {}
for name, command in self._commands.items(): for name, command in self._commands.items():
for key, parameter in command.parameters.items(): for key, parameter in command.parameters.items():
result[f"{name}.{key}"] = parameter result.setdefault(name, {})[key] = parameter.value
return result return result
async def load_attributes(self): async def load_attributes(self):
data = await self._connector.load_attributes(self) self._attributes = await self._connector.load_attributes(self)
for name, values in data.get("shadow").get("parameters").items(): for name, values in self._attributes.pop("shadow").get("parameters").items():
self._attributes[name] = values["parNewVal"] self._attributes.setdefault("parameters", {})[name] = values["parNewVal"]
async def load_statistics(self): async def load_statistics(self):
self._statistics = await self._connector.load_statistics(self) self._statistics = await self._connector.load_statistics(self)
@ -169,4 +147,8 @@ class HonDevice:
@property @property
def data(self): def data(self):
return self.attributes | self.parameters | self._appliance | self._statistics result = {"attributes": self.attributes, "appliance": self.appliance, "statistics": self.statistics,
**self.parameters}
if self._extra:
return result | self._extra.Appliance(result).get()
return result

View File

@ -14,11 +14,23 @@ class HonParameter:
def value(self): def value(self):
return self._value if self._value is not None else "0" return self._value if self._value is not None else "0"
@property
def category(self):
return self._category
@property
def typology(self):
return self._typology
@property
def mandatory(self):
return self._mandatory
class HonParameterFixed(HonParameter): class HonParameterFixed(HonParameter):
def __init__(self, key, attributes): def __init__(self, key, attributes):
super().__init__(key, attributes) super().__init__(key, attributes)
self._value = attributes["fixedValue"] self._value = attributes.get("fixedValue", None)
def __repr__(self): def __repr__(self):
return f"{self.__class__} (<{self.key}> fixed)" return f"{self.__class__} (<{self.key}> fixed)"
@ -63,8 +75,9 @@ class HonParameterRange(HonParameter):
@value.setter @value.setter
def value(self, value): def value(self, value):
value = int(value)
if self._min <= value <= self._max and not value % self._step: if self._min <= value <= self._max and not value % self._step:
self._value = self._value self._value = value
else: else:
raise ValueError(f"Allowed: min {self._min} max {self._max} step {self._step}") raise ValueError(f"Allowed: min {self._min} max {self._max} step {self._step}")
@ -81,7 +94,7 @@ class HonParameterEnum(HonParameter):
@property @property
def values(self): def values(self):
return [str(value) for value in self._values] return sorted([str(value) for value in self._values])
@property @property
def value(self): def value(self):
@ -90,7 +103,7 @@ class HonParameterEnum(HonParameter):
@value.setter @value.setter
def value(self, value): def value(self, value):
if value in self.values: if value in self.values:
self._value = self._value self._value = value
else: else:
raise ValueError(f"Allowed values {self._value}") raise ValueError(f"Allowed values {self._value}")
@ -99,8 +112,9 @@ class HonParameterProgram(HonParameterEnum):
def __init__(self, key, command): def __init__(self, key, command):
super().__init__(key, {}) super().__init__(key, {})
self._command = command self._command = command
self._value = command._category self._value = command._program
self._values = command._multi self._values = command._multi
self._typology = "enum"
@property @property
def value(self): def value(self):
@ -111,4 +125,4 @@ class HonParameterProgram(HonParameterEnum):
if value in self.values: if value in self.values:
self._command.set_program(value) self._command.set_program(value)
else: else:
raise ValueError(f"Allowed values {self._value}") raise ValueError(f"Allowed values {self._values}")

View File

@ -7,18 +7,31 @@ with open("README.md", "r") as f:
setup( setup(
name="pyhOn", name="pyhOn",
version="0.2.0", version="0.4.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,
long_description_content_type='text/markdown', long_description_content_type='text/markdown',
url="https://github.com/Andre0512/pyh0n", project_urls={
"GitHub": "https://github.com/Andre0512/pyhOn",
"PyPI": "https://pypi.org/project/pyhOn",
},
license="MIT", license="MIT",
platforms="any", platforms="any",
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
python_requires=">=3.10", python_requires=">=3.10",
install_requires=["aiohttp"], install_requires=["aiohttp"],
classifiers=[
"Development Status :: 4 - Beta",
"Environment :: Console",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Software Development :: Libraries :: Python Modules",
],
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'pyhOn = pyhon.__main__:start', 'pyhOn = pyhon.__main__:start',