diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43f7617 --- /dev/null +++ b/.gitignore @@ -0,0 +1,103 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +.vscode/* + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/README.md b/README.md index c7437bd..45396f7 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ The module is installed in A/Cs and humidifiers that are either manufactured or 1. Air Conditioner with HiSense AEH-W4B1 or AEH-W4E1 installed. 1. Have Python 3.7 installed. If using Raspberry Pi, either upgrade to Raspbian Buster, or manually install it in Raspbian Stretch. -1. Install additional libraries: +1. Download and install aircon module: ```bash - pip3.7 install dataclasses_json paho-mqtt pycryptodome retry + python3.7 setup.py install ``` 1. Configure the A/C with the dedicated app. Links to each app are available in the table below. Log into the app, associate the A/C and connect it to the network, as described in the app documentation. 1. Once everything has been configured, the A/C can be blocked from connecting to the internet, as it will no longer be needed. Set it a static IP address in the router, and write it down. -1. Download and run [query_cli.py](query_cli.py), to fetch the LAN keys that will allow connecting to the A/C. Pass it your login credentials, as well as the code for your app from the list below: +1. Run discovery command to fetch the LAN keys that will allow connecting to the A/C. Pass it your login credentials, as well as the code for your app from the list below: | Code | App Name | App link |------------|---------------------|---------| @@ -43,17 +43,20 @@ The module is installed in A/Cs and humidifiers that are either manufactured or For example: ```bash - ./query_cli.py --user foo@example.com --passwd my_pass --app tornado-us --config config.json + python3.7 -m aircon discovery tornado-us foo@example.com my_pass ``` The CLI will generate a config file, that needs to be passed to the A/C control server below. If you have more than one A/C that you would like to control, create a separate config file for each A/C, and run a separate control process. You can select the A/C that the config is generated for by setting the `--device` flag to the device name you configured in the app. ## Run the A/C control server -1. Download [hisense.py](hisense.py). +1. Download and install aircon module: + ```bash + python3.7 setup.py install + ``` 1. Test out that you can run the server, e.g.: ```bash - ./hisense.py --port 8888 --ip 10.0.0.40 --config config.json --mqtt_host localhost + python3.7 -m aircon run --port 8888 --ip 10.0.0.40 --config config.json --mqtt_host localhost ``` Parameters: - `--port` or `-p` - Port for the web server. @@ -77,13 +80,14 @@ The module is installed in A/Cs and humidifiers that are either manufactured or curl -ik 'http://localhost:8888/hisense/command?property=t_power&value=ON' ``` ## Run as a service +Assuming your username is "pi" 1. Create a dedicated directory for the script files, and move the files to it. Pass the ownership to root. e.g.: ```bash sudo mkdir /usr/lib/hisense - sudo mv hisense.py config.json /usr/lib/hisense - sudo chown root:root /usr/lib/hisense/* + sudo mv config.json /usr/lib/hisense + sudo chown pi:pi /usr/lib/hisense/* ``` 1. Create a service configuration file (as root), e.g. `/lib/systemd/system/hisense.service`: ```INI @@ -92,11 +96,12 @@ The module is installed in A/Cs and humidifiers that are either manufactured or After=network.target [Service] - ExecStart=/usr/bin/python3.7 -u hisense.py --port 8888 --ip 10.0.0.40 --config config.json --mqtt_host localhost + ExecStart=/usr/bin/python3.7 -m aircon run --port 8888 --ip 10.0.0.40 --config config.json --mqtt_host localhost WorkingDirectory=/usr/lib/hisense StandardOutput=inherit StandardError=inherit Restart=always + User=pi [Install] WantedBy=multi-user.target @@ -166,13 +171,8 @@ Listed here are the properties available through the API: ## Multiple Air Conditioners -The server script supports a single Air Conditioner. In order to use with multiple Air Conditioners, a separate instance of the server needs to run for each Air Conditioner. - -This includes: -- Create a separate config file for each A/C. Do mind that you select the correct device when running the CLI. -- Select a different `--port` for the HTTP server of each A/C. -- If using MQTT, select a different `--mqtt_topic` for each A/C. -- Create a different service file, that refers to the settings above. +In order to use with multiple Air Conditioners, simply add multiple --config and --type params. +MQTT topic will contain your topic defined by flag --mqtt_topic (hisense_ac by default) and device name. * Note: _The smart home hub configuration should adjusted to refer to the right port or topics._ diff --git a/aircon/__init__.py b/aircon/__init__.py new file mode 100644 index 0000000..ad0e488 --- /dev/null +++ b/aircon/__init__.py @@ -0,0 +1,2 @@ +from . import * +__version__ = '0.0.1' diff --git a/aircon/__main__.py b/aircon/__main__.py index dc7575f..915a34a 100755 --- a/aircon/__main__.py +++ b/aircon/__main__.py @@ -1,62 +1,115 @@ -#!/usr/bin/env python3.7 -""" -Server for controlling HiSense Air Conditioner WiFi modules. -These modules are embedded for example in the Israel Tornado ACs. -This module is based on reverse engineering of the AC protocol, -and is not affiliated with HiSense, Tornado or any other relevant -company. - -In order to run this server, you need to provide it with the a -config file, that likes like this: -{"lanip_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "lanip_key_id":8888, - "random_1":"YYYYYYYYYYYYYYYY", - "time_1":201111111111111, - "random_2":"XXXXXXXXXXXXXXXX", - "time_2":111111111111} - -The random/time values are regenerated on key exchange when the -server first starts talking with the AC, so is the lanip_key_id. -The lanip_key, on the other hand, is generated only on the -HiSense server. In order to get that value, you'll need to either -sniff the TLS-encrypted network traffic, or fetch and unencrypt -the string locally stored by the app cache (using a rooted device). - -The code here relies on Python 3.7 -If running in Raspberry Pi, install Python 3.7 manually. -Also install additional libraries: -pip3.7 install dataclasses_json paho-mqtt pycryptodome retry -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - import argparse import base64 -from dataclasses import dataclass, field, fields -from dataclasses_json import dataclass_json -import enum -import hmac +from http import HTTPStatus from http.client import HTTPConnection, InvalidURL from http.server import HTTPServer, BaseHTTPRequestHandler -from http import HTTPStatus import json import logging import logging.handlers -import math import paho.mqtt.client as mqtt -import queue -import random from retry import retry +import signal import socket -import string import sys import threading import time -import typing +import _thread from urllib.parse import parse_qs, urlparse, ParseResult -from Crypto.Cipher import AES +from .app_mappings import SECRET_MAP +from .config import Config +from .error import Error +from .aircon import BaseDevice, AcDevice, FglDevice, FglBDevice, HumidifierDevice +from .discovery import perform_discovery +from .mqtt_client import MqttClient +from .query_handlers import QueryHandlers +class KeepAliveThread(threading.Thread): + """Thread to preiodically generate keep-alive requests.""" + + _KEEP_ALIVE_INTERVAL = 10.0 + + def __init__(self, port: int, devices: [BaseDevice]): + self.run_lock = threading.Condition() + self._alive = False + self._data = [] + + for device in devices: + header = { + 'Accept': 'application/json', + 'Connection': 'keep-alive', + 'Content-Type': 'application/json', + 'Host': device.ip_address, + 'Accept-Encoding': 'gzip' + } + self._data.append({ + 'device': device, + 'headers': header, + 'conn': None, + 'last_timestamp': 0 + }) + + local_ip = self._get_local_ip() + self._json = { + 'local_reg': { + 'ip': local_ip, + 'notify': 0, + 'port': port, + 'uri': "/local_lan" + } + } + super(KeepAliveThread, self).__init__(name='Keep Alive thread') + + def _get_local_ip(self): + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.connect(('10.255.255.255', 1)) + return sock.getsockname()[0] + finally: + if sock: + sock.close() + + @retry(exceptions=ConnectionError, delay=0.5, max_delay=20, backoff=1.5, logger=logging) + def _establish_connection(self, conn: HTTPConnection, headers: dict, device: BaseDevice) -> None: + method = 'PUT' if self._alive else 'POST' + self._json['local_reg']['notify'] = int(device.commands_queue.qsize() > 0) + logging.debug('[KeepAlive] %s %s/local_reg.json %s', method, conn.host, json.dumps(self._json)) + try: + conn.request(method, '/local_reg.json', json.dumps(self._json), headers) + resp = conn.getresponse() + if resp.status != HTTPStatus.ACCEPTED: + raise ConnectionError('Recieved invalid response for local_reg: %d, %s', resp.status, resp.read()) + resp.read() + except: + self._alive = False + raise + finally: + conn.close() + self._alive = True + + def run(self) -> None: + with self.run_lock: + for entry in self._data: + try: + conn = HTTPConnection(entry['device'].ip_address, timeout=5) + entry['conn'] = conn + except InvalidURL: + logging.exception('[KeepAlive] Invalid IP provided.') + _thread.interrupt_main() + return + while True: + try: + for entry in self._data: + now = time.time() + if now - entry['timestamp'] >= self._KEEP_ALIVE_INTERVAL or entry['device'].commands_queue.qsize() > 0: + self._establish_connection(entry['conn'], entry['headers'], entry['device']) + entry['timestamp'] = now + except: + logging.exception('[KeepAlive] Failed to send local_reg keep alive to the AC.') + logging.debug('[KeepAlive] Waiting for notification or timeout') + self.run_lock.wait(self._KEEP_ALIVE_INTERVAL) class QueryStatusThread(threading.Thread): """Thread to preiodically query the status of all properties. @@ -68,255 +121,154 @@ class QueryStatusThread(threading.Thread): _STATUS_UPDATE_INTERVAL = 600.0 _WAIT_FOR_EMPTY_QUEUE = 10.0 - def __init__(self): - self._next_command_id = 0 + def __init__(self, devices: [BaseDevice]): super(QueryStatusThread, self).__init__(name='Query Status thread') + self._devices = devices def run(self) -> None: while True: # In case the AC is stuck, and not fetching commands, avoid flooding # the queue with status updates. - while _data.commands_queue.qsize() > 10: - time.sleep(self._WAIT_FOR_EMPTY_QUEUE) - for data_field in fields(_data.properties): - command = { - 'cmds': [{ - 'cmd': { - 'method': 'GET', - 'resource': 'property.json?name=' + data_field.name, - 'uri': '/local_lan/property/datapoint.json', - 'data': '', - 'cmd_id': self._next_command_id, - } - }] - } - self._next_command_id += 1 - _data.commands_queue.put_nowait((command, None)) + for device in self._devices: + while device.commands_queue.qsize() > 10: + time.sleep(self._WAIT_FOR_EMPTY_QUEUE) + device.queue_status() if _keep_alive: with _keep_alive.run_lock: + logging.debug('QueryStatusThread triggered KeepAlive notify') _keep_alive.run_lock.notify() time.sleep(self._STATUS_UPDATE_INTERVAL) +def MakeHttpRequestHandlerClass(devices: [BaseDevice]): + class HTTPRequestHandler(BaseHTTPRequestHandler): + """Handler for AC related HTTP requests.""" + def __init__(self, request, client_address, server): + self._query_handlers = QueryHandlers(devices, self._write_response) + self._HANDLERS_MAP = { + '/hisense/status': self._query_handlers.get_status_handler, + '/hisense/command': self._queue_command, + '/local_lan/key_exchange.json': self._query_handlers.key_exchange_handler, + '/local_lan/commands.json': self._query_handlers.command_handler, + '/local_lan/property/datapoint.json': self._query_handlers.property_update_handler, + '/local_lan/property/datapoint/ack.json': self._query_handlers.property_update_handler, + '/local_lan/node/property/datapoint.json': self._query_handlers.property_update_handler, + '/local_lan/node/property/datapoint/ack.json': self._query_handlers.property_update_handler, + # TODO: Handle these if needed. + # '/local_lan/node/conn_status.json': _query_handlers.connection_status_handler, + # '/local_lan/connect_status': _query_handlers.module_request_handler, + # '/local_lan/status.json': _query_handlers.setup_device_details_handler, + # '/local_lan/wifi_scan.json': _query_handlers.module_request_handler, + # '/local_lan/wifi_scan_results.json': _query_handlers.module_request_handler, + # '/local_lan/wifi_status.json': _query_handlers.module_request_handler, + # '/local_lan/regtoken.json': _query_handlers.module_request_handler, + # '/local_lan/wifi_stop_ap.json': _query_handlers.module_request_handler, + } + super(HTTPRequestHandler, self).__init__(request, client_address, server) + + def _queue_command(self, path: str, query: dict, data: dict): + self._query_handlers.queue_command_handler(path, query, data) + with _keep_alive.run_lock: + logging.debug("_queue_command triggered KeepAlive notify") + _keep_alive.run_lock.notify() -class HTTPRequestHandler(BaseHTTPRequestHandler): - """Handler for AC related HTTP requests.""" - - def do_HEAD(self, code: HTTPStatus = HTTPStatus.OK) -> None: - """Return a JSON header.""" - self.send_response(code) - if code == HTTPStatus.OK: - self.send_header('Content-type', 'application/json') - self.end_headers() - - def do_GET(self) -> None: - """Accepts get requests.""" - logging.debug('GET Request,\nPath: %s\n', self.path) - parsed_url = urlparse(self.path) - query = parse_qs(parsed_url.query) - handler = self._HANDLERS_MAP.get(parsed_url.path) - if handler: - try: - handler(self, parsed_url.path, query, {}) - return - except: - logging.exception('Failed to parse property.') - self.do_HEAD(HTTPStatus.NOT_FOUND) - - def do_POST(self): - """Accepts post requests.""" - content_length = int(self.headers['Content-Length']) - post_data = self.rfile.read(content_length) - logging.debug('POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n', - str(self.path), str(self.headers), post_data.decode('utf-8')) - parsed_url = urlparse(self.path) - query = parse_qs(parsed_url.query) - data = json.loads(post_data) - handler = self._HANDLERS_MAP.get(parsed_url.path) - if handler: - try: - handler(self, parsed_url.path, query, data) - return - except: - logging.exception('Failed to parse property.') - self.do_HEAD(HTTPStatus.NOT_FOUND) - - def key_exchange_handler(self, path: str, query: dict, data: dict) -> None: - """Handles a key exchange. - Accepts the AC's random and time and pass its own. - Note that a key encryption component is the lanip_key, mapped to the - lanip_key_id provided by the AC. This secret part is provided by HiSense - server. Fortunately the lanip_key_id (and lanip_key) are static for a given - AC. - """ - try: - key = data['key_exchange'] - if key['ver'] != 1 or key['proto'] != 1 or key.get('sec'): - raise KeyError() - _config.lan_config.random_1 = key['random_1'] - _config.lan_config.time_1 = key['time_1'] - except KeyError: - logging.error('Invalid key exchange: %r', data) - self.do_HEAD(HTTPStatus.BAD_REQUEST) - return - if key['key_id'] != _config.lan_config.lanip_key_id: - logging.error('The key_id has been replaced!!\nOld ID was %d; new ID is %d.', - _config.lan_config.lanip_key_id, key['key_id']) + def _write_response(self, status: HTTPStatus, response: str): + self.do_HEAD(status) + if (response != None): + self.wfile.write(response) + + def do_HEAD(self, code: HTTPStatus = HTTPStatus.OK) -> None: + """Return a JSON header.""" + self.send_response(code) + if code == HTTPStatus.OK: + self.send_header('Content-type', 'application/json') + self.end_headers() + + def do_GET(self) -> None: + """Accepts get requests.""" + sender = self.headers['Host'] + logging.debug('GET Request from %s,\nPath: %s\n', sender, self.path) + parsed_url = urlparse(self.path) + query = parse_qs(parsed_url.query) + handler = self._HANDLERS_MAP.get(parsed_url.path) + if handler: + try: + handler(sender, parsed_url.path, query, {}) + return + except: + logging.exception('Failed to parse property.') self.do_HEAD(HTTPStatus.NOT_FOUND) - return - _config.lan_config.random_2 = ''.join( - random.choices(string.ascii_letters + string.digits, k=16)) - _config.lan_config.time_2 = time.monotonic_ns() % 2**40 - _config.update() - self.do_HEAD(HTTPStatus.OK) - self._write_json({"random_2": _config.lan_config.random_2, - "time_2": _config.lan_config.time_2}) - - def command_handler(self, path: str, query: dict, data: dict) -> None: - """Handles a command request. - Request arrives from the AC. takes a command from the queue, - builds the JSON, encrypts and signs it, and sends it to the AC. - """ - command = {} - with _data.commands_seq_no_lock: - command['seq_no'] = _data.commands_seq_no - _data.commands_seq_no += 1 - try: - command['data'], property_updater = _data.commands_queue.get_nowait() - except queue.Empty: - command['data'], property_updater = {}, None - self.do_HEAD(HTTPStatus.OK) - self._write_json(self._encrypt_and_sign(command)) - if property_updater: - property_updater() - - def property_update_handler(self, path: str, query: dict, data: dict) -> None: - """Handles a property update request. - Decrypts, validates, and pushes the value into the local properties store. - """ - try: - update = self._decrypt_and_validate(data) - except Error: - logging.exception('Failed to parse property.') - self.do_HEAD(HTTPStatus.BAD_REQUEST) - return - self.do_HEAD(HTTPStatus.OK) - with _data.updates_seq_no_lock: - # Every once in a while the sequence number is zeroed out, so accept it. - if _data.updates_seq_no > update['seq_no'] and update['seq_no'] > 0: - logging.error('Stale update found %d. Last update used is %d.', - (update['seq_no'], _data.updates_seq_no)) - return # Old update - _data.updates_seq_no = update['seq_no'] - try: - if not update['data']: - logging.debug('No value returned for seq_no %d, likely an unsupported property key.', - update['seq_no']) - return - name = update['data']['name'] - data_type = _data.properties.get_type(name) - value = data_type(update['data']['value']) - _data.update_property(name, value) - except: - logging.exception('Failed to handle %s', update) - - def get_status_handler(self, path: str, query: dict, data: dict) -> None: - """Handles get status request (by a smart home hub). - Returns the current internally stored state of the AC. - """ - with _data.properties_lock: - data = _data.properties.to_dict() - self.do_HEAD(HTTPStatus.OK) - self._write_json(data) - - def queue_command_handler(self, path: str, query: dict, data: dict) -> None: - """Handles queue command request (by a smart home hub). - """ - try: - queue_command(query['property'][0], query['value'][0]) - except: - logging.exception('Failed to queue command.') - self.do_HEAD(HTTPStatus.BAD_REQUEST) - return - self.do_HEAD(HTTPStatus.OK) - self._write_json({'queued commands': _data.commands_queue.qsize()}) - - @staticmethod - def _encrypt_and_sign(data: dict) -> dict: - text = json.dumps(data).encode('utf-8') - logging.debug('Encrypting: %s', text.decode('utf-8')) - return { - "enc": base64.b64encode(_config.app.cipher.encrypt(pad(text))).decode('utf-8'), - "sign": base64.b64encode(Encryption.hmac_digest(_config.app.sign_key, text)).decode('utf-8') - } - - @staticmethod - def _decrypt_and_validate(data: dict) -> dict: - text = unpad(_config.dev.cipher.decrypt(base64.b64decode(data['enc']))) - sign = base64.b64encode(Encryption.hmac_digest(_config.dev.sign_key, text)).decode('utf-8') - if sign != data['sign']: - raise Error('Invalid signature for %s!' % text.decode('utf-8')) - logging.info('Decrypted: %s', text.decode('utf-8')) - return json.loads(text.decode('utf-8')) - - def _write_json(self, data: dict) -> None: - """Send out the provided data dict as JSON.""" - logging.debug('Response:\n%s', json.dumps(data)) - self.wfile.write(json.dumps(data).encode('utf-8')) - - _HANDLERS_MAP = { - '/hisense/status': get_status_handler, - '/hisense/command': queue_command_handler, - '/local_lan/key_exchange.json': key_exchange_handler, - '/local_lan/commands.json': command_handler, - '/local_lan/property/datapoint.json': property_update_handler, - '/local_lan/property/datapoint/ack.json': property_update_handler, - '/local_lan/node/property/datapoint.json': property_update_handler, - '/local_lan/node/property/datapoint/ack.json': property_update_handler, - # TODO: Handle these if needed. - # '/local_lan/node/conn_status.json': connection_status_handler, - # '/local_lan/connect_status': module_request_handler, - # '/local_lan/status.json': setup_device_details_handler, - # '/local_lan/wifi_scan.json': module_request_handler, - # '/local_lan/wifi_scan_results.json': module_request_handler, - # '/local_lan/wifi_status.json': module_request_handler, - # '/local_lan/regtoken.json': module_request_handler, - # '/local_lan/wifi_stop_ap.json': module_request_handler, - } + def do_POST(self): + """Accepts post requests.""" + sender = self.headers['Host'] + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length) + logging.debug('POST request from %s,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n', + sender, str(self.path), str(self.headers), post_data.decode('utf-8')) + parsed_url = urlparse(self.path) + query = parse_qs(parsed_url.query) + data = json.loads(post_data) + handler = self._HANDLERS_MAP.get(parsed_url.path) + if handler: + try: + handler(sender, parsed_url.path, query, data) + return + except: + logging.exception('Failed to parse property.') + self.do_HEAD(HTTPStatus.NOT_FOUND) + return HTTPRequestHandler def ParseArguments() -> argparse.Namespace: """Parse command line arguments.""" arg_parser = argparse.ArgumentParser( description='JSON server for HiSense air conditioners.', allow_abbrev=False) - arg_parser.add_argument('-p', '--port', required=True, type=int, + arg_parser.add_argument('--log_level', default='WARNING', + choices={'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'}, + help='Minimal log level.') + subparsers = arg_parser.add_subparsers(dest='cmd', + help='Determines what server should do') + subparsers.required = True + + parser_run = subparsers.add_parser('run', help='Runs the server to control the device') + parser_run.add_argument('-p', '--port', required=True, type=int, help='Port for the server.') - arg_parser.add_argument('--ip', required=True, - help='IP address for the AC.') - arg_parser.add_argument('--config', required=True, - help='LAN Config file.') - arg_parser.add_argument('--device_type', default='ac', - choices={'ac', 'fgl', 'fgl_b', 'humidifier'}, - help='Device type (for systems other than Hisense A/C).') - arg_parser.add_argument('--mqtt_host', default=None, + group_device = parser_run.add_argument_group('Device', 'Arguments that are related to the device') + group_device.add_argument('--ip', required=True, action='append', + help='IP address for the AC.') + group_device.add_argument('--config', required=True, action='append', + help='LAN Config file.') + group_device.add_argument('--type', required=True, action='append', + choices={'ac', 'fgl', 'fgl_b', 'humidifier'}, + help='Device type (for systems other than Hisense A/C).') + + group_mqtt = parser_run.add_argument_group('MQTT', 'Settings related to the MQTT') + group_mqtt.add_argument('--mqtt_host', default=None, help='MQTT broker hostname or IP address.') - arg_parser.add_argument('--mqtt_port', type=int, default=1883, + group_mqtt.add_argument('--mqtt_port', type=int, default=1883, help='MQTT broker port.') - arg_parser.add_argument('--mqtt_client_id', default=None, + group_mqtt.add_argument('--mqtt_client_id', default=None, help='MQTT client ID.') - arg_parser.add_argument('--mqtt_user', default=None, + group_mqtt.add_argument('--mqtt_user', default=None, help=' for the MQTT channel.') - arg_parser.add_argument('--mqtt_topic', default='hisense_ac', + group_mqtt.add_argument('--mqtt_topic', default='hisense_ac', help='MQTT topic.') - arg_parser.add_argument('--log_level', default='WARNING', - choices={'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'}, - help='Minimal log level.') - return arg_parser.parse_args() - -if __name__ == '__main__': - _parsed_args = ParseArguments() # type: argparse.Namespace + parser_discovery = subparsers.add_parser('discovery', help='Runs the device discovery') + parser_discovery.add_argument('app', + choices=set(SECRET_MAP), + help='The app used for the login.') + parser_discovery.add_argument('user', help='Username for the app login.') + parser_discovery.add_argument('passwd', help='Password for the app login.') + parser_discovery.add_argument('-d', '--device', default=None, + help='Device name to fetch data for. If not set, takes all.') + parser_discovery.add_argument('--prefix', required=False, default='config_', + help='Config file prefix.') + parser_discovery.add_argument('--properties', action='store_true', + help='Fetch the properties for the device.') + return arg_parser.parse_args() +def setup_logger(log_level): if sys.platform == 'linux': logging_handler = logging.handlers.SysLogHandler(address='/dev/log') elif sys.platform == 'darwin': @@ -330,46 +282,79 @@ def ParseArguments() -> argparse.Namespace: '{filename}:{lineno}] {message}', datefmt='%m%d %H:%M:%S', style='{')) logger = logging.getLogger() - logger.setLevel(_parsed_args.log_level) + logger.setLevel(log_level) logger.addHandler(logging_handler) - _config = Config() - if _parsed_args.device_type == 'ac': - _data = Data(properties=AcProperties()) - elif _parsed_args.device_type == 'fgl': - _data = Data(properties=FglProperties()) - elif _parsed_args.device_type == 'fgl_b': - _data = Data(properties=FglBProperties()) - elif _parsed_args.device_type == 'humidifier': - _data = Data(properties=HumidifierProperties()) - else: - sys.exit(1) # Should never get here. - - _mqtt_client = None # type: typing.Optional[mqtt.Client] - _mqtt_topics = {} # type: typing.Dict[str, str] - if _parsed_args.mqtt_host: - _mqtt_topics['pub'] = '/'.join((_parsed_args.mqtt_topic, '{}', 'status')) - _mqtt_topics['sub'] = '/'.join((_parsed_args.mqtt_topic, '{}', 'command')) - _mqtt_client = mqtt.Client(client_id=_parsed_args.mqtt_client_id, - clean_session=True) - _mqtt_client.on_connect = mqtt_on_connect - _mqtt_client.on_message = mqtt_on_message - if _parsed_args.mqtt_user: - _mqtt_client.username_pw_set(*_parsed_args.mqtt_user.split(':',1)) - _mqtt_client.connect(_parsed_args.mqtt_host, _parsed_args.mqtt_port) - _mqtt_client.loop_start() - +def run(parsed_args): + devices = [] + for i in range(len(parsed_args.ip)): + with open(parsed_args.config[i], 'rb') as f: + data = json.load(f) + lanip_key = data['lanip_key'] + lanip_key_id = data['lanip_key_id'] + if parsed_args.type[i] == 'ac': + device = AcDevice(parsed_args.ip[i], lanip_key, lanip_key_id) + elif parsed_args.type[i] == 'fgl': + device = FglDevice(parsed_args.ip[i], lanip_key, lanip_key_id) + elif parsed_args.type[i] == 'fgl_b': + device = FglBDevice(parsed_args.ip[i], lanip_key, lanip_key_id) + elif parsed_args.type[i] == 'humidifier': + device = HumidifierDevice(parsed_args.ip[i], lanip_key, lanip_key_id) + else: + logging.error('Unknown type of device: %s', parsed_args.type[i]) + sys.exit(1) # Should never get here. + devices.append(device) + + if parsed_args.mqtt_host: + mqtt_topics = {'pub' : '/'.join((parsed_args.mqtt_topic, '{}', 'status')), + 'sub' : '/'.join((parsed_args.mqtt_topic, '{}', 'command'))} + mqtt_client = MqttClient(parsed_args.mqtt_client_id, mqtt_topics, device) + if parsed_args.mqtt_user: + mqtt_client.username_pw_set(*parsed_args.mqtt_user.split(':',1)) + mqtt_client.connect(parsed_args.mqtt_host, parsed_args.mqtt_port) + mqtt_client.loop_start() + for device in devices: + device.change_listener = mqtt_client.mqtt_publish_update + + global _keep_alive _keep_alive = None # type: typing.Optional[KeepAliveThread] - query_status = QueryStatusThread() + query_status = QueryStatusThread(devices) query_status.start() - _keep_alive = KeepAliveThread() + _keep_alive = KeepAliveThread(parsed_args.port, devices) _keep_alive.start() - _httpd = HTTPServer(('', _parsed_args.port), HTTPRequestHandler) + httpd = HTTPServer(('', parsed_args.port), MakeHttpRequestHandlerClass(devices)) #TODO It should be a map of ip -> device try: - _httpd.serve_forever() + httpd.serve_forever() except KeyboardInterrupt: pass - _httpd.server_close() + finally: + httpd.server_close() + +def _escape_name(name: str): + safe_name = name.replace(' ', '_').lower() + return "".join(x for x in safe_name if x.isalnum()) + +def discovery(parsed_args): + all_configs = perform_discovery(parsed_args.app, parsed_args.user, parsed_args.passwd, + parsed_args.prefix, parsed_args.device, parsed_args.properties) + for config in all_configs: + file_content = { + 'lanip_key': config['lanip_key'], + 'lanip_key_id': config['lanip_key_id'] + } + with open(parsed_args.prefix + _escape_name(config['product_name']) + '.json', 'w') as f: + f.write(json.dumps(file_content)) + +if __name__ == '__main__': + parsed_args = ParseArguments() # type: argparse.Namespace + setup_logger(parsed_args.log_level) + if (len(parsed_args.ip) != len(parsed_args.type) and len(parsed_args.ip) != len(parsed_args.config)): + raise ValueError("Each device has to have specified ip, type and config file") + + if (parsed_args.cmd == 'run'): + run(parsed_args) + elif (parsed_args.cmd == 'discovery'): + discovery(parsed_args) diff --git a/aircon/aircon.py b/aircon/aircon.py index 84c1be3..483eb6a 100755 --- a/aircon/aircon.py +++ b/aircon/aircon.py @@ -1,395 +1,396 @@ -#!/usr/bin/env python3.7 -""" -Air conditioner devices for the air conditioner module server. -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse -import base64 -from dataclasses import dataclass, field, fields -from dataclasses_json import dataclass_json +from copy import deepcopy +from dataclasses import fields import enum -import hmac -from http.client import HTTPConnection, InvalidURL -from http.server import HTTPServer, BaseHTTPRequestHandler -from http import HTTPStatus -import json import logging -import logging.handlers -import math -import paho.mqtt.client as mqtt -import queue import random -from retry import retry -import socket import string -import sys import threading -import time -import typing -from urllib.parse import parse_qs, urlparse, ParseResult - +from typing import Callable +import queue from Crypto.Cipher import AES +from .config import Config, Encryption +from .control_value import (get_power_value, set_power_value, get_temp_value, + set_temp_value, get_work_mode_value, set_work_mode_value, get_fan_speed_value, + set_fan_speed_value, get_heat_cold_value, set_heat_cold_value, get_eco_value, + set_eco_value, get_fan_power_value, set_fan_power_value, get_fan_lr_value, + set_fan_lr_value, get_fan_mute_value, set_fan_mute_value, get_temptype_value, + set_temptype_value, clear_up_change_flags_value) +from .error import Error +from .properties import (AcProperties, AirFlow, Economy, FanSpeed, FastColdHeat, FglProperties, FglBProperties, + HumidifierProperties, Properties, Power, AcWorkMode, Quiet, TemperatureUnit) + +class BaseDevice: + def __init__(self, name: str, ip_address: str, lanip_key: str, lanip_key_id: str, + properties: Properties, notifier: Callable[[None], None]): + self.name = name + self.ip_address = ip_address + self._config = Config(lanip_key, lanip_key_id) + self._properties = properties + self._properties_lock = threading.RLock() + self._queue_listener = notifier + + self._next_command_id = 0 + + self.commands_queue = queue.Queue() + self._commands_seq_no = 0 + self._commands_seq_no_lock = threading.Lock() + + self._updates_seq_no = 0 + self._updates_seq_no_lock = threading.Lock() -@dataclass -class Data: - """The current data store: commands, updates and properties.""" - commands_queue = queue.Queue() - commands_seq_no = 0 - commands_seq_no_lock = threading.Lock() - updates_seq_no = 0 - updates_seq_no_lock = threading.Lock() - properties: Properties - properties_lock = threading.Lock() + self.property_change_listener: Callable[[str, str], None] = None + + def get_all_properties(self) -> Properties: + with self._properties_lock: + return deepcopy(self._properties) def get_property(self, name: str): """Get a stored property.""" - with self.properties_lock: - return getattr(self.properties, name) + with self._properties_lock: + return getattr(self._properties, name) + + def get_property_type(self, name: str): + return self._properties.get_type(name) def update_property(self, name: str, value) -> None: """Update the stored properties, if changed.""" - with self.properties_lock: - old_value = getattr(self.properties, name) + with self._properties_lock: + old_value = getattr(self._properties, name) if value != old_value: - setattr(self.properties, name, value) - logging.debug('Updated properties: %s' % self.properties) - mqtt_publish_update(name, value) - - -def queue_command(name: str, value, recursive: bool = False) -> None: - if _data.properties.get_read_only(name): - raise Error('Cannot update read-only property "{}".'.format(name)) - data_type = _data.properties.get_type(name) - base_type = _data.properties.get_base_type(name) - if issubclass(data_type, enum.Enum): - data_value = data_type[value].value - elif data_type is int and type(value) is str and '.' in value: - # Round rather than fail if the input is a float. - # This is commonly the case for temperatures converted by HA from Celsius. - data_value = round(float(value)) - else: - data_value = data_type(value) - command = { - 'properties': [{ - 'property': { - 'base_type': base_type, - 'name': name, - 'value': data_value, - 'id': ''.join(random.choices(string.ascii_letters + string.digits, k=8)), - } - }] - } - # There are (usually) no acks on commands, so also queue an update to the - # property, to be run once the command is sent. - typed_value = data_type[value] if issubclass(data_type, enum.Enum) else data_value - property_updater = lambda: _data.update_property(name, typed_value) - _data.commands_queue.put_nowait((command, property_updater)) - - # Handle turning on FastColdHeat - if name == 't_temp_heatcold' and typed_value is FastColdHeat.ON: - queue_command('t_fan_speed', 'AUTO', True) - queue_command('t_fan_mute', 'OFF', True) - queue_command('t_sleep', 'STOP', True) - queue_command('t_temp_eight', 'OFF', True) - if not recursive: - with _keep_alive.run_lock: - _keep_alive.run_lock.notify() - - -class KeepAliveThread(threading.Thread): - """Thread to preiodically generate keep-alive requests.""" - - _KEEP_ALIVE_INTERVAL = 10.0 - - def __init__(self): - self.run_lock = threading.Condition() - self._alive = False - sock = None - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.connect(('10.255.255.255', 1)) - local_ip = sock.getsockname()[0] - finally: - if sock: - sock.close() - self._headers = { - 'Accept': 'application/json', - 'Connection': 'Keep-Alive', - 'Content-Type': 'application/json', - 'Host': _parsed_args.ip, - 'Accept-Encoding': 'gzip' - } - self._json = { - 'local_reg': { - 'ip': local_ip, - 'notify': 0, - 'port': _parsed_args.port, - 'uri': "/local_lan" - } - } - super(KeepAliveThread, self).__init__(name='Keep Alive thread') - - @retry(exceptions=ConnectionError, delay=0.5, max_delay=20, backoff=1.5, logger=logging) - def _establish_connection(self, conn: HTTPConnection) -> None: - method = 'PUT' if self._alive else 'POST' - logging.debug('%s /local_reg.json %s', method, json.dumps(self._json)) - try: - conn.request(method, '/local_reg.json', json.dumps(self._json), self._headers) - resp = conn.getresponse() - if resp.status != HTTPStatus.ACCEPTED: - raise ConnectionError('Recieved invalid response for local_reg: ' + repr(resp)) - resp.read() - except: - self._alive = False - raise - finally: - conn.close() - self._alive = True - - def run(self) -> None: - with self.run_lock: - try: - conn = HTTPConnection(_parsed_args.ip, timeout=5) - except InvalidURL: - logging.exception('Invalid IP provided.') - _httpd.shutdown() - return - while True: - try: - self._establish_connection(conn) - except: - logging.exception('Failed to send local_reg keep alive to the AC.') - _httpd.shutdown() - return - self._json['local_reg']['notify'] = int( - _data.commands_queue.qsize() > 0 or self.run_lock.wait(self._KEEP_ALIVE_INTERVAL)) - - -class QueryStatusThread(threading.Thread): - """Thread to preiodically query the status of all properties. - - After start-up, essentailly all updates should be pushed to the server due - to the keep alive, so this is just a belt and suspenders. - """ - - _STATUS_UPDATE_INTERVAL = 600.0 - _WAIT_FOR_EMPTY_QUEUE = 10.0 - - def __init__(self): - self._next_command_id = 0 - super(QueryStatusThread, self).__init__(name='Query Status thread') - - def run(self) -> None: - while True: - # In case the AC is stuck, and not fetching commands, avoid flooding - # the queue with status updates. - while _data.commands_queue.qsize() > 10: - time.sleep(self._WAIT_FOR_EMPTY_QUEUE) - for data_field in fields(_data.properties): - command = { - 'cmds': [{ - 'cmd': { - 'method': 'GET', - 'resource': 'property.json?name=' + data_field.name, - 'uri': '/local_lan/property/datapoint.json', - 'data': '', - 'cmd_id': self._next_command_id, - } - }] - } - self._next_command_id += 1 - _data.commands_queue.put_nowait((command, None)) - if _keep_alive: - with _keep_alive.run_lock: - _keep_alive.run_lock.notify() - time.sleep(self._STATUS_UPDATE_INTERVAL) - - -class HTTPRequestHandler(BaseHTTPRequestHandler): - """Handler for AC related HTTP requests.""" - - def do_HEAD(self, code: HTTPStatus = HTTPStatus.OK) -> None: - """Return a JSON header.""" - self.send_response(code) - if code == HTTPStatus.OK: - self.send_header('Content-type', 'application/json') - self.end_headers() - - def do_GET(self) -> None: - """Accepts get requests.""" - logging.debug('GET Request,\nPath: %s\n', self.path) - parsed_url = urlparse(self.path) - query = parse_qs(parsed_url.query) - handler = self._HANDLERS_MAP.get(parsed_url.path) - if handler: - try: - handler(self, parsed_url.path, query, {}) - return - except: - logging.exception('Failed to parse property.') - self.do_HEAD(HTTPStatus.NOT_FOUND) - - def do_POST(self): - """Accepts post requests.""" - content_length = int(self.headers['Content-Length']) - post_data = self.rfile.read(content_length) - logging.debug('POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n', - str(self.path), str(self.headers), post_data.decode('utf-8')) - parsed_url = urlparse(self.path) - query = parse_qs(parsed_url.query) - data = json.loads(post_data) - handler = self._HANDLERS_MAP.get(parsed_url.path) - if handler: - try: - handler(self, parsed_url.path, query, data) - return - except: - logging.exception('Failed to parse property.') - self.do_HEAD(HTTPStatus.NOT_FOUND) - - def key_exchange_handler(self, path: str, query: dict, data: dict) -> None: - """Handles a key exchange. - Accepts the AC's random and time and pass its own. - Note that a key encryption component is the lanip_key, mapped to the - lanip_key_id provided by the AC. This secret part is provided by HiSense - server. Fortunately the lanip_key_id (and lanip_key) are static for a given - AC. - """ - try: - key = data['key_exchange'] - if key['ver'] != 1 or key['proto'] != 1 or key.get('sec'): - raise KeyError() - _config.lan_config.random_1 = key['random_1'] - _config.lan_config.time_1 = key['time_1'] - except KeyError: - logging.error('Invalid key exchange: %r', data) - self.do_HEAD(HTTPStatus.BAD_REQUEST) - return - if key['key_id'] != _config.lan_config.lanip_key_id: - logging.error('The key_id has been replaced!!\nOld ID was %d; new ID is %d.', - _config.lan_config.lanip_key_id, key['key_id']) - self.do_HEAD(HTTPStatus.NOT_FOUND) - return - _config.lan_config.random_2 = ''.join( - random.choices(string.ascii_letters + string.digits, k=16)) - _config.lan_config.time_2 = time.monotonic_ns() % 2**40 - _config.update() - self.do_HEAD(HTTPStatus.OK) - self._write_json({"random_2": _config.lan_config.random_2, - "time_2": _config.lan_config.time_2}) - - def command_handler(self, path: str, query: dict, data: dict) -> None: - """Handles a command request. - Request arrives from the AC. takes a command from the queue, - builds the JSON, encrypts and signs it, and sends it to the AC. - """ - command = {} - with _data.commands_seq_no_lock: - command['seq_no'] = _data.commands_seq_no - _data.commands_seq_no += 1 - try: - command['data'], property_updater = _data.commands_queue.get_nowait() - except queue.Empty: - command['data'], property_updater = {}, None - self.do_HEAD(HTTPStatus.OK) - self._write_json(self._encrypt_and_sign(command)) - if property_updater: - property_updater() - - def property_update_handler(self, path: str, query: dict, data: dict) -> None: - """Handles a property update request. - Decrypts, validates, and pushes the value into the local properties store. - """ - try: - update = self._decrypt_and_validate(data) - except Error: - logging.exception('Failed to parse property.') - self.do_HEAD(HTTPStatus.BAD_REQUEST) - return - self.do_HEAD(HTTPStatus.OK) - with _data.updates_seq_no_lock: + setattr(self._properties, name, value) + #logging.debug('Updated properties: %s' % self._properties) + if name == 't_control_value': + self._update_controlled_properties(value) + if self.property_change_listener: + self.property_change_listener(self.name, name, value) + + def _update_controlled_properties(self, control_value: int): + raise NotImplementedError() + + def get_command_seq_no(self) -> int: + with self._commands_seq_no_lock: + seq_no = self._commands_seq_no + self._commands_seq_no += 1 + return seq_no + + def is_update_valid(self, cur_update_no: int) -> bool: + with self._updates_seq_no_lock: # Every once in a while the sequence number is zeroed out, so accept it. - if _data.updates_seq_no > update['seq_no'] and update['seq_no'] > 0: + if self._updates_seq_no > cur_update_no and cur_update_no > 0: logging.error('Stale update found %d. Last update used is %d.', - (update['seq_no'], _data.updates_seq_no)) - return # Old update - _data.updates_seq_no = update['seq_no'] - try: - if not update['data']: - logging.debug('No value returned for seq_no %d, likely an unsupported property key.', - update['seq_no']) - return - name = update['data']['name'] - data_type = _data.properties.get_type(name) - value = data_type(update['data']['value']) - _data.update_property(name, value) - except: - logging.exception('Failed to handle %s', update) - - def get_status_handler(self, path: str, query: dict, data: dict) -> None: - """Handles get status request (by a smart home hub). - Returns the current internally stored state of the AC. - """ - with _data.properties_lock: - data = _data.properties.to_dict() - self.do_HEAD(HTTPStatus.OK) - self._write_json(data) - - def queue_command_handler(self, path: str, query: dict, data: dict) -> None: - """Handles queue command request (by a smart home hub). - """ - try: - queue_command(query['property'][0], query['value'][0]) - except: - logging.exception('Failed to queue command.') - self.do_HEAD(HTTPStatus.BAD_REQUEST) + cur_update_no, self._updates_seq_no) + return False # Old update + self._updates_seq_no = cur_update_no + return True + + def queue_command(self, name: str, value) -> None: + if self._properties.get_read_only(name): + raise Error('Cannot update read-only property "{}".'.format(name)) + data_type = self._properties.get_type(name) + + # Device mode is set using control_value + if issubclass(data_type, enum.Enum): + data_value = data_type[value] + elif data_type is int and type(value) is str and '.' in value: + # Round rather than fail if the input is a float. + # This is commonly the case for temperatures converted by HA from Celsius. + data_value = round(float(value)) + else: + data_value = data_type(value) + + # If device has set t_control_value it is being controlled by this field. + if name != 't_control_value' and self.get_property('t_control_value'): + self._convert_to_control_value(name, data_value) return - self.do_HEAD(HTTPStatus.OK) - self._write_json({'queued commands': _data.commands_queue.qsize()}) - - @staticmethod - def _encrypt_and_sign(data: dict) -> dict: - text = json.dumps(data).encode('utf-8') - logging.debug('Encrypting: %s', text.decode('utf-8')) + + if issubclass(data_type, enum.Enum): + data_value = data_value.value + + command = self._build_command(name, data_value) + # There are (usually) no acks on commands, so also queue an update to the + # property, to be run once the command is sent. + typed_value = data_type[value] if issubclass(data_type, enum.Enum) else data_value + property_updater = lambda: self.update_property(name, typed_value) + self.commands_queue.put_nowait((command, property_updater)) + + # Handle turning on FastColdHeat + if name == 't_temp_heatcold' and typed_value is FastColdHeat.ON: + self.queue_command('t_fan_speed', 'AUTO') + self.queue_command('t_fan_mute', 'OFF') + self.queue_command('t_sleep', 'STOP') + self.queue_command('t_temp_eight', 'OFF') + + self._queue_listener() + + def _build_command(self, name: str, data_value: int): + base_type = self._properties.get_base_type(name) return { - "enc": base64.b64encode(_config.app.cipher.encrypt(pad(text))).decode('utf-8'), - "sign": base64.b64encode(Encryption.hmac_digest(_config.app.sign_key, text)).decode('utf-8') + 'properties': [{ + 'property': { + 'base_type': base_type, + 'name': name, + 'value': data_value, + 'id': ''.join(random.choices(string.ascii_letters + string.digits, k=8)), + } + }] } - @staticmethod - def _decrypt_and_validate(data: dict) -> dict: - text = unpad(_config.dev.cipher.decrypt(base64.b64decode(data['enc']))) - sign = base64.b64encode(Encryption.hmac_digest(_config.dev.sign_key, text)).decode('utf-8') - if sign != data['sign']: - raise Error('Invalid signature for %s!' % text.decode('utf-8')) - logging.info('Decrypted: %s', text.decode('utf-8')) - return json.loads(text.decode('utf-8')) - - def _write_json(self, data: dict) -> None: - """Send out the provided data dict as JSON.""" - logging.debug('Response:\n%s', json.dumps(data)) - self.wfile.write(json.dumps(data).encode('utf-8')) - - _HANDLERS_MAP = { - '/hisense/status': get_status_handler, - '/hisense/command': queue_command_handler, - '/local_lan/key_exchange.json': key_exchange_handler, - '/local_lan/commands.json': command_handler, - '/local_lan/property/datapoint.json': property_update_handler, - '/local_lan/property/datapoint/ack.json': property_update_handler, - '/local_lan/node/property/datapoint.json': property_update_handler, - '/local_lan/node/property/datapoint/ack.json': property_update_handler, - # TODO: Handle these if needed. - # '/local_lan/node/conn_status.json': connection_status_handler, - # '/local_lan/connect_status': module_request_handler, - # '/local_lan/status.json': setup_device_details_handler, - # '/local_lan/wifi_scan.json': module_request_handler, - # '/local_lan/wifi_scan_results.json': module_request_handler, - # '/local_lan/wifi_status.json': module_request_handler, - # '/local_lan/regtoken.json': module_request_handler, - # '/local_lan/wifi_stop_ap.json': module_request_handler, - } + def _convert_to_control_value(self, name: str, value) -> int: + raise NotImplementedError() + + def queue_status(self) -> None: + for data_field in fields(self._properties): + command = { + 'cmds': [{ + 'cmd': { + 'method': 'GET', + 'resource': 'property.json?name=' + data_field.name, + 'uri': '/local_lan/property/datapoint.json', + 'data': '', + 'cmd_id': self._next_command_id, + } + }] + } + self._next_command_id += 1 + self.commands_queue.put_nowait((command, None)) + self._queue_listener() + + def update_key(self, key: dict) -> dict: + return self._config.update(key) + + def get_app_encryption(self) -> Encryption: + return self._config.app + + def get_dev_encryption(self) -> Encryption: + return self._config.dev + +class AcDevice(BaseDevice): + def __init__(self, name: str, ip_address: str, lanip_key: str, lanip_key_id: str, + notifier: Callable[[None], None]): + super().__init__(name, ip_address, lanip_key, lanip_key_id, AcProperties(), notifier) + + def get_env_temp(self) -> int: + return self.get_property('f_temp_in') + + def set_power(self, setting: Power) -> None: + control_value = self.get_property('t_control_value') + if (control_value): + control_value = set_power_value(control_value, setting) + self.queue_command('t_control_value', control_value) + else: + self.queue_command('t_power', setting) + + def get_power(self) -> Power: + control_value = self.get_property('t_control_value') + if (control_value): + return get_power_value(control_value) + else: + return self.get_property('t_power') + + def set_temperature(self, setting: int) -> None: + control_value = self.get_property('t_control_value') + control_value = clear_up_change_flags_value(control_value) + if (control_value): + control_value = set_temp_value(control_value, setting) + self.queue_command('t_control_value', control_value) + else: + self.queue_command('t_temp', setting) + + def get_temperature(self) -> int: + control_value = self.get_property('t_control_value') + if (control_value): + return get_temp_value(control_value) + else: + return self.get_property('t_temp') + + def set_work_mode(self, setting: AcWorkMode) -> None: + control_value = self.get_property('t_control_value') + if (control_value): + control_value = set_work_mode_value(control_value, setting) + self.queue_command('t_control_value', control_value) + else: + self.queue_command('t_work_mode', setting) + + def get_work_mode(self) -> AcWorkMode: + control_value = self.get_property('t_control_value') + if (control_value): + return get_work_mode_value(control_value) + else: + return self.get_property('t_work_mode') + + def set_fan_speed(self, setting: FanSpeed) -> None: + control_value = self.get_property('t_control_value') + if (control_value): + control_value = set_fan_speed_value(control_value, setting) + self.queue_command('t_control_value', control_value) + else: + self.queue_command('t_fan_speed', setting) + + def get_fan_speed(self) -> FanSpeed: + control_value = self.get_property('t_control_value') + if (control_value): + return get_fan_speed_value(control_value) + else: + return self.get_property('t_fan_speed') + + def set_fan_vertical(self, setting: AirFlow) -> None: + control_value = self.get_property('t_control_value') + if (control_value): + control_value = set_fan_power_value(control_value, setting) + self.queue_command('t_control_value', control_value) + else: + self.queue_command('t_fan_power', setting) + + def get_fan_vertical(self) -> AirFlow: + control_value = self.get_property('t_control_value') + if (control_value): + return get_fan_power_value(control_value) + else: + return self.get_property('t_fan_power') + + def set_fan_horizontal(self, setting: AirFlow) -> None: + control_value = self.get_property('t_control_value') + if (control_value): + control_value = set_fan_lr_value(control_value, setting) + self.queue_command('t_control_value', control_value) + else: + self.queue_command('t_fan_leftright', setting) + + def get_fan_horizontal(self) -> AirFlow: + control_value = self.get_property('t_control_value') + if (control_value): + return get_fan_lr_value(control_value) + else: + return self.get_property('t_fan_leftright') + + def set_fan_mute(self, setting: Quiet) -> None: + control_value = self.get_property('t_control_value') + if (control_value): + control_value = set_fan_mute_value(control_value, setting) + self.queue_command('t_control_value', control_value) + else: + self.queue_command('t_fan_mute', setting) + + def get_fan_mute(self) -> Quiet: + control_value = self.get_property('t_control_value') + if (control_value): + return get_fan_mute_value(control_value) + else: + return self.get_property('t_fan_mute') + + def set_fast_heat_cold(self, setting: FastColdHeat): + control_value = self.get_property('t_control_value') + if (control_value): + control_value = set_heat_cold_value(control_value, setting) + self.queue_command('t_control_value', control_value) + else: + self.queue_command('t_temp_heatcold', setting) + + def get_fast_heat_cold(self) -> FastColdHeat: + control_value = self.get_property('t_control_value') + if (control_value): + return get_heat_cold_value(control_value) + else: + return self.get_property('t_temp_heatcold') + + def set_eco(self, setting: Economy) -> None: + control_value = self.get_property('t_control_value') + if (control_value): + control_value = set_eco_value(control_value, setting) + self.queue_command('t_control_value', control_value) + else: + self.queue_command('t_eco', setting) + + def get_eco(self) -> Economy: + control_value = self.get_property('t_control_value') + if (control_value): + return get_eco_value(control_value) + else: + return self.get_property('t_eco') + + def set_temptype(self, setting: TemperatureUnit) -> None: + control_value = self.get_property('t_control_value') + if (control_value): + control_value = set_temptype_value(control_value, setting) + self.queue_command('t_control_value', control_value) + else: + self.queue_command('t_temptype', setting) + + def get_temptype(self) -> TemperatureUnit: + control_value = self.get_property('t_control_value') + if (control_value): + return get_temptype_value(control_value) + else: + return self.get_property('t_temptype') + + def _convert_to_control_value(self, name: str, value) -> int: + if name == 't_power': + return self.set_power(value) + elif name == 't_fan_speed': + return self.set_fan_speed(value) + elif name == 't_work_mode': + return self.set_work_mode(value) + elif name == 't_temp_heatcold': + return self.set_fast_heat_cold(value) + elif name == 't_eco': + return self.set_eco(value) + elif name == 't_temp': + return self.set_temperature(value) + elif name == 't_fan_power': + return self.set_fan_vertical(value) + elif name == 't_fan_leftright': + return self.set_fan_horizontal(value) + elif name == 't_fan_mute': + return self.set_fan_mute(value) + elif name == 't_temptype': + return self.set_temptype(value) + else: + logging.error('Cannot convert to control value property {}'.format(name)) + raise ValueError() + + def _update_controlled_properties(self, control_value: int): + power = get_power_value(control_value) + self.update_property('t_power', power) + + fan_speed = get_fan_speed_value(control_value) + self.update_property('t_fan_speed', fan_speed) + + work_mode = get_work_mode_value(control_value) + self.update_property('t_work_mode', work_mode) + + temp_heatcold = get_heat_cold_value(control_value) + self.update_property('t_temp_heatcold', temp_heatcold) + + eco = get_eco_value(control_value) + self.update_property('t_eco', eco) + + temp = get_temp_value(control_value) + self.update_property('t_temp', temp) + + fan_power = get_fan_power_value(control_value) + self.update_property('t_fan_power', fan_power) + + fan_horizontal = get_fan_lr_value(control_value) + self.update_property('t_fan_leftright', fan_horizontal) + + fan_mute = get_fan_mute_value(control_value) + self.update_property('t_fan_mute', fan_mute) + + temptype = get_temptype_value(control_value) + self.update_property('t_temptype', temptype) + +class FglDevice(BaseDevice): + def __init__(self, name: str, ip_address: str, lanip_key: str, + lanip_key_id: str, notifier: Callable[[None], None]): + super().__init__(name, ip_address, lanip_key, lanip_key_id, FglProperties(), notifier) + +class FglBDevice(BaseDevice): + def __init__(self, name: str, ip_address: str, lanip_key: str, + lanip_key_id: str, notifier: Callable[[None], None]): + super().__init__(name, ip_address, lanip_key, lanip_key_id, FglBProperties(), notifier) + +class HumidifierDevice(BaseDevice): + def __init__(self, name: str, ip_address: str, lanip_key: str, + lanip_key_id: str, notifier: Callable[[None], None]): + super().__init__(name, ip_address, lanip_key, lanip_key_id, HumidifierProperties(), notifier) diff --git a/aircon/app_mappings.py b/aircon/app_mappings.py index 9e28fd0..e46d656 100755 --- a/aircon/app_mappings.py +++ b/aircon/app_mappings.py @@ -1,30 +1,15 @@ -#!/usr/bin/env python3.7 -""" -App mappings for the HiSense servers query CLI. -""" -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse -import base64 -import gzip -import json -import logging -import ssl -import sys -from http.client import HTTPSConnection - -_AYLA_USER_SERVERS = { +AYLA_USER_SERVERS = { 'us': 'user-field.aylanetworks.com', 'eu': 'user-field-eu.aylanetworks.com', 'cn': 'user-field.ayla.com.cn', } -_AYLA_DEVICES_SERVERS = { +AYLA_DEVICES_SERVERS = { 'us': 'ads-field.aylanetworks.com', 'eu': 'ads-eu.aylanetworks.com', 'cn': 'ads-field.ayla.com.cn', } -_SECRET_MAP = { +SECRET_MAP = { 'oem-us': b'\x1dgAPT\xd1\xa9\xec\xe2\xa2\x01\x19\xc0\x03X\x13j\xfc\xb5\x91', 'mid-us': b'\xdeCx\xbe\x0cq8\x0b\x99\xb4Z\x93>\xfc\xcc\x9ag\x98\xf8\x14', 'tornado-us': b'\x87O\xf2.&;X\xfb\xf6L\xfdRq\'\x0f\t6\x0c\xfd)', @@ -46,7 +31,7 @@ 'hismart-eu': b'0\x07\xe9\x04a\xa6e\xc4\x1c\x08+"\r\x84w\x91\x8f\xa8)\x98', 'hismart-us': b'\xd6+\x1f\xb0b\t\x19G\x87\x8c\xaak\xd0\xf8y\xf5\x933\xafp', } -_SECRET_ID_MAP = { +SECRET_ID_MAP = { 'haxxair': 'HAXXAIR', 'field-us': 'pactera-field-f624d97f-us', 'fglair-cn': 'FGLairField-cn', @@ -59,173 +44,10 @@ 'hismart-eu': 'Hismart', 'hismart-us': 'App1', } -_SECRET_ID_EXTRA_MAP = { +SECRET_ID_EXTRA_MAP = { 'denali-us': 'iA', 'hisense-eu': 'mw', 'hisense-us': 'pg', 'hismart-eu': 'fA', 'hismart-us': 'Lg', } -_USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 9.0; SM-G850F Build/LRX22G)' - -if __name__ == '__main__': - arg_parser = argparse.ArgumentParser( - description='Command Line to query HiSense server.', - allow_abbrev=False) - arg_parser.add_argument('-a', '--app', required=True, - choices=set(_SECRET_MAP), - help='The app used for the login.') - arg_parser.add_argument('-u', '--user', required=True, - help='Username for the app login.') - arg_parser.add_argument('-p', '--passwd', required=True, - help='Password for the app login.') - arg_parser.add_argument('-d', '--device', default=None, - help='Device name to fetch data for. If not set, takes the first.') - arg_parser.add_argument('--config', required=True, - help='Config file to write to.') - arg_parser.add_argument('--properties', type=bool, default=False, - help='Fetch the properties for the device.') - args = arg_parser.parse_args() - logging_handler = logging.StreamHandler(stream=sys.stderr) - logging_handler.setFormatter( - logging.Formatter(fmt='{levelname[0]}{asctime}.{msecs:03.0f} ' - '{filename}:{lineno}] {message}', - datefmt='%m%d %H:%M:%S', style='{')) - logger = logging.getLogger() - logger.setLevel('INFO') - logger.addHandler(logging_handler) - if args.app in _SECRET_ID_MAP: - app_prefix = _SECRET_ID_MAP[args.app] - else: - app_prefix = 'a-Hisense-{}-field'.format(args.app) - if args.app in _SECRET_ID_EXTRA_MAP: - app_id = '-'.join((app_prefix, _SECRET_ID_EXTRA_MAP[args.app], 'id')) - else: - app_id = '-'.join((app_prefix, 'id')) - secret = base64.b64encode(_SECRET_MAP[args.app]).decode('utf-8').rstrip('=').replace('+', '-').replace('/', '_') - app_secret = '-'.join((app_prefix, secret)) - # Extract the region from the app ID (and fallback to US) - region = args.app[-2:] - if region not in _AYLA_USER_SERVERS: - region = 'us' - user_server = _AYLA_USER_SERVERS[region] - devices_server = _AYLA_DEVICES_SERVERS[region] - ssl_context = ssl.SSLContext() - ssl_context.verify_mode = ssl.CERT_NONE - ssl_context.check_hostname = False - ssl_context.load_default_certs() - conn = HTTPSConnection(user_server, context=ssl_context) - query = { - 'user': { - 'email': args.user, - 'password': args.passwd, - 'application': { - 'app_id': app_id, - 'app_secret': app_secret - } - } - } - headers = { - 'Accept': 'application/json', - 'Connection': 'Keep-Alive', - 'Authorization': 'none', - 'Content-Type': 'application/json', - 'User-Agent': _USER_AGENT, - 'Host': user_server, - 'Accept-Encoding': 'gzip' - } - logging.debug('POST /users/sign_in.json, body=%r, headers=%r' % (json.dumps(query), headers)) - conn.request('POST', '/users/sign_in.json', body=json.dumps(query), headers=headers) - resp = conn.getresponse() - if resp.status != 200: - logging.error('Failed to login to Hisense server:\nStatus %d: %r', - resp.status, resp.reason) - sys.exit(1) - resp_data = resp.read() - try: - resp_data = gzip.decompress(resp_data) - except OSError: - pass # Not gzipped. - try: - tokens = json.loads(resp_data) - except UnicodeDecodeError: - logging.exception('Failed to parse login tokens to Hisense server:\nData: %r', - resp_data) - sys.exit(1) - conn.close() - conn = HTTPSConnection(devices_server, context=ssl_context) - headers = { - 'Accept': 'application/json', - 'Connection': 'Keep-Alive', - 'Authorization': 'auth_token ' + tokens['access_token'], - 'User-Agent': _USER_AGENT, - 'Host': devices_server, - 'Accept-Encoding': 'gzip' - } - logging.debug('GET /apiv1/devices.json, headers=%r' % headers) - conn.request('GET', '/apiv1/devices.json', headers=headers) - resp = conn.getresponse() - if resp.status != 200: - logging.error('Failed to get devices data from Hisense server:\nStatus %d: %r', - resp.status, resp.reason) - sys.exit(1) - resp_data = resp.read() - try: - resp_data = gzip.decompress(resp_data) - except OSError: - pass # Not gzipped. - try: - devices = json.loads(resp_data) - except UnicodeDecodeError: - logging.exception('Failed to parse devices data from Hisense server:\nData: %r', - resp_data) - sys.exit(1) - if not devices: - logging.error('No device is configured! Please configure a device first.') - sys.exit(1) - logging.info('Found devices: %r', devices) - if args.device: - for device in devices: - device = device - if device['device']['product_name'] == args.device: - break - else: - logging.error('No device named "%s" was found!', args.device) - sys.exit(1) - else: - device = devices[0] - dsn = device['device']['dsn'] - conn.request('GET', '/apiv1/dsns/{}/lan.json'.format(dsn), headers=headers) - resp = conn.getresponse() - if resp.status != 200: - logging.error('Failed to get device data from Hisense server: %r', resp) - sys.exit(1) - resp_data = resp.read() - try: - resp_data = gzip.decompress(resp_data) - except OSError: - pass # Not gzipped. - lanip = json.loads(resp_data)['lanip'] - if args.properties: - conn.request('GET', '/apiv1/dsns/{}/properties.json'.format(dsn), headers=headers) - resp = conn.getresponse() - if resp.status != 200: - logging.error('Failed to get properties data from Hisense server: %r', resp) - sys.exit(1) - resp_data = resp.read() - try: - resp_data = gzip.decompress(resp_data) - except OSError: - pass # Not gzipped. - logging.info('Properties:\n%s', json.dumps(json.loads(resp_data), indent=2)) - conn.close() - config = { - 'lanip_key': lanip['lanip_key'], - 'lanip_key_id': lanip['lanip_key_id'], - 'random_1': '', - 'time_1': 0, - 'random_2': '', - 'time_2': 0 - } - with open(args.config, 'w') as f: - f.write(json.dumps(config)) diff --git a/aircon/config.py b/aircon/config.py index e40c5ff..c33ff83 100755 --- a/aircon/config.py +++ b/aircon/config.py @@ -1,65 +1,12 @@ -#!/usr/bin/env python3.7 -""" -Configuration for the air conditioner module server. -Server for controlling HiSense Air Conditioner WiFi modules. -These modules are embedded for example in the Israel Tornado ACs. -This module is based on reverse engineering of the AC protocol, -and is not affiliated with HiSense, Tornado or any other relevant -company. - -In order to run this server, you need to provide it with the a -config file, that likes like this: -{"lanip_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "lanip_key_id":8888, - "random_1":"YYYYYYYYYYYYYYYY", - "time_1":201111111111111, - "random_2":"XXXXXXXXXXXXXXXX", - "time_2":111111111111} - -The random/time values are regenerated on key exchange when the -server first starts talking with the AC, so is the lanip_key_id. -The lanip_key, on the other hand, is generated only on the -HiSense server. In order to get that value, you'll need to either -sniff the TLS-encrypted network traffic, or fetch and unencrypt -the string locally stored by the app cache (using a rooted device). - -The code here relies on Python 3.7 -If running in Raspberry Pi, install Python 3.7 manually. -Also install additional libraries: -pip3.7 install dataclasses_json paho-mqtt pycryptodome retry -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse -import base64 -from dataclasses import dataclass, field, fields -from dataclasses_json import dataclass_json -import enum +from Crypto.Cipher import AES +from dataclasses import dataclass import hmac -from http.client import HTTPConnection, InvalidURL -from http.server import HTTPServer, BaseHTTPRequestHandler -from http import HTTPStatus -import json -import logging -import logging.handlers -import math -import paho.mqtt.client as mqtt -import queue import random -from retry import retry -import socket import string -import sys -import threading import time -import typing -from urllib.parse import parse_qs, urlparse, ParseResult - -from Crypto.Cipher import AES +from .error import KeyIdReplaced -@dataclass_json @dataclass class LanConfig: lanip_key: str @@ -69,7 +16,6 @@ class LanConfig: random_2: str time_2: int - @dataclass class Encryption: sign_key: bytes @@ -91,29 +37,36 @@ def _build_key(cls, lanip_key: bytes, msg: bytes) -> bytes: def hmac_digest(key: bytes, msg: bytes) -> bytes: return hmac.digest(key, msg, 'sha256') - @dataclass class Config: - lan_config: LanConfig + _lan_config: LanConfig app: Encryption dev: Encryption - - def __init__(self): - with open(_parsed_args.config, 'rb') as f: - self.lan_config = LanConfig.from_json(f.read().decode('utf-8')) + + def __init__(self, lanip_key: str, lanip_key_id: int): + self._lan_config = LanConfig(lanip_key, lanip_key_id, '', 0, '', 0) self._update_encryption() - def update(self): + def update(self, key: dict): """Updates the stored lan config, and encryption data.""" - with open(_parsed_args.config, 'wb') as f: - f.write(self.lan_config.to_json().encode('utf-8')) + self._lan_config.random_1 = key['random_1'] + self._lan_config.time_1 = key['time_1'] + if key['key_id'] != self._lan_config.lanip_key_id: + raise KeyIdReplaced('The key_id has been replaced!!', + 'Old ID was {}; new ID is {}.'.format( + self._lan_config.lanip_key_id, key['key_id'])) + self._lan_config.random_2 = ''.join( + random.choices(string.ascii_letters + string.digits, k=16)) + self._lan_config.time_2 = time.monotonic_ns() % 2**40 self._update_encryption() + return {'random_2': self._lan_config.random_2, + 'time_2': self._lan_config.time_2} def _update_encryption(self): - lanip_key = self.lan_config.lanip_key.encode('utf-8') - random_1 = self.lan_config.random_1.encode('utf-8') - random_2 = self.lan_config.random_2.encode('utf-8') - time_1 = str(self.lan_config.time_1).encode('utf-8') - time_2 = str(self.lan_config.time_2).encode('utf-8') + lanip_key = self._lan_config.lanip_key.encode('utf-8') + random_1 = self._lan_config.random_1.encode('utf-8') + random_2 = self._lan_config.random_2.encode('utf-8') + time_1 = str(self._lan_config.time_1).encode('utf-8') + time_2 = str(self._lan_config.time_2).encode('utf-8') self.app = Encryption(lanip_key, random_1 + random_2 + time_1 + time_2) self.dev = Encryption(lanip_key, random_2 + random_1 + time_2 + time_1) diff --git a/aircon/control_value.py b/aircon/control_value.py new file mode 100644 index 0000000..4bc8fbb --- /dev/null +++ b/aircon/control_value.py @@ -0,0 +1,83 @@ +from .properties import (AcWorkMode, AirFlow, Economy, FanSpeed, + FastColdHeat, Quiet, Power, TemperatureUnit) + +def clear_up_change_flags_value(control: int) -> int: + return control & 2868817502 + +def get_fan_speed_value(control: int) -> FanSpeed: + int_val = (control >> 1) & 15 + return FanSpeed(int_val) + +def set_fan_speed_value(control: int, value: FanSpeed) -> None: + int_val = value.value + return (control & ~31) | ((int_val << 1) | 1) + +def get_power_value(control: int) -> Power: + int_val = (control >> 6) & 1 + return Power(int_val) + +def set_power_value(control: int, value: Power) -> None: + int_val = value.value + return (control & ~(3 << 5)) | (((int_val << 1) | 1) << 5) + +def get_work_mode_value(control: int) -> AcWorkMode: + int_val = (control >> 9) & 7 + return AcWorkMode(int_val) + +def set_work_mode_value(control: int, value: AcWorkMode) -> None: + int_val = value.value + return (control & ~(15 << 8)) | (((int_val << 1) | 1) << 8) + +def get_heat_cold_value(control: int) -> FastColdHeat: + int_val = (control >> 13) & 1 + return FastColdHeat(int_val) + +def set_heat_cold_value(control: int, value: FastColdHeat) -> None: + int_val = value.value + return (control & ~(3 << 12)) | (((int_val << 1) | 1) << 12) + +def get_eco_value(control: int) -> Economy: + int_val = (control >> 15) & 1 + return Economy(int_val) + +def set_eco_value(control: int, value: Economy) -> None: + int_val = value.value + return (control & ~(3 << 14)) | (((int_val << 1) | 1) << 14) + +def get_temp_value(control: int) -> int: + return (control >> 17) & 63 + +def set_temp_value(control: int, value: int) -> None: + return (control & ~(127 << 16)) | (((value << 1) | 1) << 16) + +def get_fan_power_value(control: int) -> AirFlow: + int_val = (control >> 25) & 1 + return AirFlow(int_val) + +def set_fan_power_value(control: int, value: AirFlow) -> None: + int_val = value.value + return (control & ~(3 << 24)) | (((int_val << 1) | 1) << 24) + +def get_fan_lr_value(control: int) -> AirFlow: + int_val = (control >> 27) & 1 + return AirFlow(int_val) + +def set_fan_lr_value(control: int, value: AirFlow) -> None: + int_val = value.value + return (control & ~(3 << 26)) | (((int_val << 1) | 1) << 26) + +def get_fan_mute_value(control: int) -> Quiet: + int_val = (control >> 29) & 1 + return Quiet(int_val) + +def set_fan_mute_value(control: int, value: Quiet) -> None: + int_val = value.value + return (control & ~(3 << 28)) | (((int_val << 1) | 1) << 28) + +def get_temptype_value(control: int) -> TemperatureUnit: + int_val = (control >> 31) & 1 + return TemperatureUnit(int_val) + +def set_temptype_value(control: int, value: TemperatureUnit) -> None: + int_val = value.value + return (control & ~(3 << 30)) | (((int_val << 1) | 1) << 30) diff --git a/aircon/discovery.py b/aircon/discovery.py index fa49a99..92da9bf 100755 --- a/aircon/discovery.py +++ b/aircon/discovery.py @@ -1,135 +1,22 @@ -#!/usr/bin/env python3.7 -""" -Small command line program to query HiSense servers. -Generates a small config file, to control the AC locally. - -After configuring the AC from your phone, pass the username, password -and application type to this script, in order to be able to control -the device locally. - -Note that this script needs to be run only once. The generated config -file needs to be passed to the hisense server script, to continuously -control the AC. - -The --app flag depends on your AC. -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse import base64 import gzip +from http.client import HTTPSConnection import json import logging import ssl import sys -from http.client import HTTPSConnection -_AYLA_USER_SERVERS = { - 'us': 'user-field.aylanetworks.com', - 'eu': 'user-field-eu.aylanetworks.com', - 'cn': 'user-field.ayla.com.cn', -} -_AYLA_DEVICES_SERVERS = { - 'us': 'ads-field.aylanetworks.com', - 'eu': 'ads-eu.aylanetworks.com', - 'cn': 'ads-field.ayla.com.cn', -} -_SECRET_MAP = { - 'oem-us': b'\x1dgAPT\xd1\xa9\xec\xe2\xa2\x01\x19\xc0\x03X\x13j\xfc\xb5\x91', - 'mid-us': b'\xdeCx\xbe\x0cq8\x0b\x99\xb4Z\x93>\xfc\xcc\x9ag\x98\xf8\x14', - 'tornado-us': b'\x87O\xf2.&;X\xfb\xf6L\xfdRq\'\x0f\t6\x0c\xfd)', - 'wwh-us': b'(\xcb9w\xc5\xc9\xb7\xab{*k8T!Yb\xaa\xcf\xd0\x85', - 'winia-us': b'\xeb_\xce\xb2\xc6\xff`\xa9\xfa\xa8r\x1c\x0bH\xf8\xe27\xa7U\xec', - 'york-us': b'\xc6A\x7fHyV<\xb2\xa2\xde<\x1f{c\xa9\rt\x9fy\xef', - 'beko-eu': b'\xa9C\n\xdb\xf7+\x01\xe2X\ne\x85\x06\x89\xaa\x88ZP+\x07>~s{\xd3\x1f\x05\x91&\x8c\x81\x84&\xe11\xef=s"*\xa4', - 'oem-eu': b'a\x1ez\xf5\xc4\x0f\x18~\xe5\xeb\xb1\x9f\xe4\xf5&B\xfe#\x88\xcb>\x06O,y\xc1\x06c\x9d\x99J\xc2x\xac\xeb\x82\x93\xe5\r\x89d', - 'mid-eu': b'\x05$\xe6\xecW\xa3\xd1B\xa0\x84\xab*\xf0\x04\x80\xce\xae\xe5`\xc4>w\xf8\xc4\xf3X\xf6<\xd2\xd2I\x14!\xd0\x98\xed\xf2\xab\xae\xc6\x03', - 'haxxair': b'\xd8\xaf\x89--\x00\xabI\x93\x83j\xab\x9acX\xac^\x90f;', - 'fglair-cn': b'\xcd\xec\xe0\xed\x8e\xb4b\x90/\xcbq\xcf\xc3\x1b\xd6.wx:\x1e', - 'fglair-eu': b'\x82\x91[T\x14h\x88\x9f\x04\xdd\x05\x89\xf9\x04T,\xb2\xf7\x8fu', - 'fglair-us': b'U\xbf\x0c@\xbf\xe5\x16&\x10\xec2\xa37G\x82\x15|\xe7)\x91', - 'field-us': b'\xc8b\x08\xfa\xce8\xf8\xf1\x81\xa5\x81\x8fX\xb4\x80\xc0\xdc\xf5\ny', - 'huihe-us': b'\xa2\xbcZ3\xbch\xfa7.`\xbc\xef0\xa3p\xa1\xf0\xaf\xf4\xd4', - 'denali-us': b'\xf1\'\xb0K \xdbZ\xd84;\xeb\x02\xa2\xee\x008\xda\x95\xfd\x93', - 'hisense-eu': b'\xc0\xedK,\xff+X\xfa\xf6p\x87\xaa\xbcV\x88\xfbI\xb4\xcf\xad', - 'hisense-us': b'x\x04\xdf\xef6\x08\x8e\x06\n\x97\xfc\xed4m\xd8\xc7\xa3=\xce\x9f', - 'hismart-eu': b'0\x07\xe9\x04a\xa6e\xc4\x1c\x08+"\r\x84w\x91\x8f\xa8)\x98', - 'hismart-us': b'\xd6+\x1f\xb0b\t\x19G\x87\x8c\xaak\xd0\xf8y\xf5\x933\xafp', -} -_SECRET_ID_MAP = { - 'haxxair': 'HAXXAIR', - 'field-us': 'pactera-field-f624d97f-us', - 'fglair-cn': 'FGLairField-cn', - 'fglair-eu': 'FGLair-eu', - 'fglair-us': 'CJIOSP', - 'huihe-us': 'huihe-d70b5148-field-us', - 'denali-us': 'DenaliAire', - 'hisense-eu': 'Hisense', - 'hisense-us': 'APP1', - 'hismart-eu': 'Hismart', - 'hismart-us': 'App1', -} -_SECRET_ID_EXTRA_MAP = { - 'denali-us': 'iA', - 'hisense-eu': 'mw', - 'hisense-us': 'pg', - 'hismart-eu': 'fA', - 'hismart-us': 'Lg', -} +from .app_mappings import * + _USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 9.0; SM-G850F Build/LRX22G)' -if __name__ == '__main__': - arg_parser = argparse.ArgumentParser( - description='Command Line to query HiSense server.', - allow_abbrev=False) - arg_parser.add_argument('-a', '--app', required=True, - choices=set(_SECRET_MAP), - help='The app used for the login.') - arg_parser.add_argument('-u', '--user', required=True, - help='Username for the app login.') - arg_parser.add_argument('-p', '--passwd', required=True, - help='Password for the app login.') - arg_parser.add_argument('-d', '--device', default=None, - help='Device name to fetch data for. If not set, takes the first.') - arg_parser.add_argument('--config', required=True, - help='Config file to write to.') - arg_parser.add_argument('--properties', type=bool, default=False, - help='Fetch the properties for the device.') - args = arg_parser.parse_args() - logging_handler = logging.StreamHandler(stream=sys.stderr) - logging_handler.setFormatter( - logging.Formatter(fmt='{levelname[0]}{asctime}.{msecs:03.0f} ' - '{filename}:{lineno}] {message}', - datefmt='%m%d %H:%M:%S', style='{')) - logger = logging.getLogger() - logger.setLevel('INFO') - logger.addHandler(logging_handler) - if args.app in _SECRET_ID_MAP: - app_prefix = _SECRET_ID_MAP[args.app] - else: - app_prefix = 'a-Hisense-{}-field'.format(args.app) - if args.app in _SECRET_ID_EXTRA_MAP: - app_id = '-'.join((app_prefix, _SECRET_ID_EXTRA_MAP[args.app], 'id')) - else: - app_id = '-'.join((app_prefix, 'id')) - secret = base64.b64encode(_SECRET_MAP[args.app]).decode('utf-8').rstrip('=').replace('+', '-').replace('/', '_') - app_secret = '-'.join((app_prefix, secret)) - # Extract the region from the app ID (and fallback to US) - region = args.app[-2:] - if region not in _AYLA_USER_SERVERS: - region = 'us' - user_server = _AYLA_USER_SERVERS[region] - devices_server = _AYLA_DEVICES_SERVERS[region] - ssl_context = ssl.SSLContext() - ssl_context.verify_mode = ssl.CERT_NONE - ssl_context.check_hostname = False - ssl_context.load_default_certs() +def _sign_in(user: str, passwd: str, user_server: str, app_id: str, app_secret: str, + ssl_context: ssl.SSLContext): conn = HTTPSConnection(user_server, context=ssl_context) query = { 'user': { - 'email': args.user, - 'password': args.passwd, + 'email': user, + 'password': passwd, 'application': { 'app_id': app_id, 'app_secret': app_secret @@ -164,15 +51,9 @@ resp_data) sys.exit(1) conn.close() - conn = HTTPSConnection(devices_server, context=ssl_context) - headers = { - 'Accept': 'application/json', - 'Connection': 'Keep-Alive', - 'Authorization': 'auth_token ' + tokens['access_token'], - 'User-Agent': _USER_AGENT, - 'Host': devices_server, - 'Accept-Encoding': 'gzip' - } + return tokens['access_token'] + +def _get_devices(devices_server: str, access_token: str, headers: dict, conn: HTTPSConnection): logging.debug('GET /apiv1/devices.json, headers=%r' % headers) conn.request('GET', '/apiv1/devices.json', headers=headers) resp = conn.getresponse() @@ -194,18 +75,9 @@ if not devices: logging.error('No device is configured! Please configure a device first.') sys.exit(1) - logging.info('Found devices: %r', devices) - if args.device: - for device in devices: - device = device - if device['device']['product_name'] == args.device: - break - else: - logging.error('No device named "%s" was found!', args.device) - sys.exit(1) - else: - device = devices[0] - dsn = device['device']['dsn'] + return devices + +def _get_lanip(dsn: str, headers: dict, conn: HTTPSConnection): conn.request('GET', '/apiv1/dsns/{}/lan.json'.format(dsn), headers=headers) resp = conn.getresponse() if resp.status != 200: @@ -217,26 +89,81 @@ except OSError: pass # Not gzipped. lanip = json.loads(resp_data)['lanip'] - if args.properties: - conn.request('GET', '/apiv1/dsns/{}/properties.json'.format(dsn), headers=headers) - resp = conn.getresponse() - if resp.status != 200: - logging.error('Failed to get properties data from Hisense server: %r', resp) - sys.exit(1) - resp_data = resp.read() - try: - resp_data = gzip.decompress(resp_data) - except OSError: - pass # Not gzipped. - logging.info('Properties:\n%s', json.dumps(json.loads(resp_data), indent=2)) - conn.close() - config = { - 'lanip_key': lanip['lanip_key'], - 'lanip_key_id': lanip['lanip_key_id'], - 'random_1': '', - 'time_1': 0, - 'random_2': '', - 'time_2': 0 + return lanip + +def _get_device_properties(dsn: str, headers: dict, conn: HTTPSConnection): + conn.request('GET', '/apiv1/dsns/{}/properties.json'.format(dsn), headers=headers) + resp = conn.getresponse() + if resp.status != 200: + logging.error('Failed to get properties data from Hisense server: %r', resp) + sys.exit(1) + resp_data = resp.read() + try: + resp_data = gzip.decompress(resp_data) + except OSError: + pass # Not gzipped. + return json.loads(resp_data) + +def perform_discovery(app: str, user: str, passwd: str, + prefix: str, device_filter: str, + properties_filter: bool) -> dict: + if app in SECRET_ID_MAP: + app_prefix = SECRET_ID_MAP[app] + else: + app_prefix = 'a-Hisense-{}-field'.format(app) + + if app in SECRET_ID_EXTRA_MAP: + app_id = '-'.join((app_prefix, SECRET_ID_EXTRA_MAP[app], 'id')) + else: + app_id = '-'.join((app_prefix, 'id')) + + secret = base64.b64encode(SECRET_MAP[app]).decode('utf-8').rstrip('=').replace('+', '-').replace('/', '_') + app_secret = '-'.join((app_prefix, secret)) + + # Extract the region from the app ID (and fallback to US) + region = app[-2:] + if region not in AYLA_USER_SERVERS: + region = 'us' + user_server = AYLA_USER_SERVERS[region] + devices_server = AYLA_DEVICES_SERVERS[region] + + ssl_context = ssl.SSLContext() + ssl_context.verify_mode = ssl.CERT_NONE + ssl_context.check_hostname = False + ssl_context.load_default_certs() + + access_token = _sign_in(user, passwd, user_server, app_id, app_secret, ssl_context) + + result = [] + conn = HTTPSConnection(devices_server, context=ssl_context) + headers = { + 'Accept': 'application/json', + 'Connection': 'Keep-Alive', + 'Authorization': 'auth_token ' + access_token, + 'User-Agent': _USER_AGENT, + 'Host': devices_server, + 'Accept-Encoding': 'gzip' } - with open(args.config, 'w') as f: - f.write(json.dumps(config)) + devices = _get_devices(devices_server, access_token, headers, conn) + logging.debug('Found devices: %r', devices) + for device in devices: + device_data = device['device'] + if device_filter and device_filter != device_data['product_name']: + continue + dsn = device_data['dsn'] + lanip = _get_lanip(dsn, headers, conn) + properties_text = '' + if properties_filter: + props = _get_device_properties(dsn, headers, conn) + device_data['properties'] = props + properties_text = 'Properties:\n%s', json.dumps(props, indent=2) + + print('Device {} has:\nIP address: {}\nlanip_key: {}\nlanip_key_id: {}\n{}\n'.format( + device_data['product_name'], device_data['lan_ip'], + lanip['lanip_key'], lanip['lanip_key_id'], properties_text)) + + device_data['lanip_key'] = lanip['lanip_key'] + device_data['lanip_key_id'] = lanip['lanip_key_id'] + result.append(device_data) + conn.close() + return result diff --git a/aircon/error.py b/aircon/error.py index 90b79c0..dc3ea3a 100755 --- a/aircon/error.py +++ b/aircon/error.py @@ -1,38 +1,9 @@ -#!/usr/bin/env python3.7 -""" -Error classes for the air conditioner module server. -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse -import base64 -from dataclasses import dataclass, field, fields -from dataclasses_json import dataclass_json -import enum -import hmac -from http.client import HTTPConnection, InvalidURL -from http.server import HTTPServer, BaseHTTPRequestHandler -from http import HTTPStatus -import json -import logging -import logging.handlers -import math -import paho.mqtt.client as mqtt -import queue -import random -from retry import retry -import socket -import string -import sys -import threading -import time -import typing -from urllib.parse import parse_qs, urlparse, ParseResult - -from Crypto.Cipher import AES - - class Error(Exception): """Error class for AC handling.""" pass + +class KeyIdReplaced(Exception): + """Error class for key id replacement""" + def __init__(self, title, message): + self.title = title + self.message = message diff --git a/aircon/mqtt_client.py b/aircon/mqtt_client.py index a8fbe9a..8c052d8 100755 --- a/aircon/mqtt_client.py +++ b/aircon/mqtt_client.py @@ -1,136 +1,69 @@ -#!/usr/bin/env python3.7 -""" -MQTT client for the air conditioner module server. -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse -import base64 -from dataclasses import dataclass, field, fields -from dataclasses_json import dataclass_json +from dataclasses import fields import enum -import hmac -from http.client import HTTPConnection, InvalidURL -from http.server import HTTPServer, BaseHTTPRequestHandler -from http import HTTPStatus -import json import logging -import logging.handlers -import math import paho.mqtt.client as mqtt -import queue -import random -from retry import retry -import socket -import string -import sys -import threading -import time -import typing -from urllib.parse import parse_qs, urlparse, ParseResult - -from Crypto.Cipher import AES - - -def mqtt_on_connect(client: mqtt.Client, userdata, flags, rc): - client.subscribe([(_mqtt_topics['sub'].format(data_field.name), 0) - for data_field in fields(_data.properties)]) - # Subscribe to subscription updates. - client.subscribe('$SYS/broker/log/M/subscribe/#') - - -def mqtt_on_subscribe(payload: bytes): - # The last segment in the space delimited string is the topic. - topic = payload.decode('utf-8').rsplit(' ', 1)[-1] - if topic not in _mqtt_topics['pub']: - return - name = topic.rsplit('/', 2)[1] - mqtt_publish_update(name, _data.get_property(name)) - - -def mqtt_on_message(client: mqtt.Client, userdata, message: mqtt.MQTTMessage): - logging.info('MQTT message Topic: %r, Payload %r', - message.topic, message.payload) - if message.topic.startswith('$SYS/broker/log/M/subscribe'): - return mqtt_on_subscribe(message.payload) - name = message.topic.rsplit('/', 2)[1] - payload = message.payload.decode('utf-8') - if name == 't_work_mode' and payload == 'fan_only': - payload = 'FAN' - try: - queue_command(name, payload.upper()) - except Exception: - logging.exception('Failed to parse value %r for property %r', - payload.upper(), name) - -def mqtt_publish_update(name: str, value) -> None: - if _mqtt_client: +from .aircon import BaseDevice +from .properties import AcWorkMode + +class MqttClient(mqtt.Client): + def __init__(self, client_id: str, mqtt_topics: dict, devices: [BaseDevice]): + super().__init__(client_id=client_id, clean_session=True) + self._mqtt_topics = mqtt_topics + self._devices = devices + + self.on_connect = self.mqtt_on_connect + self.on_message = self.mqtt_on_message + + def mqtt_on_connect(self, client: mqtt.Client, userdata, flags, rc): + for device in self._devices: + client.subscribe([(self._mqtt_topics['sub'].format(device.name, data_field.name), 0) + for data_field in fields(device.get_all_properties())]) + # Subscribe to subscription updates. + client.subscribe('$SYS/broker/log/M/subscribe/#') + + def mqtt_on_message(self, client: mqtt.Client, userdata, message: mqtt.MQTTMessage): + logging.info('MQTT message Topic: {}, Payload {}'.format(message.topic, message.payload)) + if message.topic.startswith('$SYS/broker/log/M/subscribe'): + return self.mqtt_on_subscribe(message.payload) + dev_name = message.topic.rsplit('/', 3)[1] + prop_name = message.topic.rsplit('/', 3)[2] + payload = message.payload.decode('utf-8') + if prop_name == 't_work_mode': + if payload == 'fan_only': + payload = 'FAN' + elif payload == 'off': + prop_name = 't_power' + payload = 'OFF' + + for device in self._devices: + if device.name != dev_name: + continue + chosen_device = device + + try: + chosen_device.queue_command(prop_name, payload.upper()) + except Exception: + logging.exception('Failed to parse value {} for property {}'.format(payload.upper(), prop_name)) + + def mqtt_on_subscribe(self, payload: bytes): + # The last segment in the space delimited string is the topic. + topic = payload.decode('utf-8').rsplit(' ', 1)[-1] + if topic not in self._mqtt_topics['pub']: + return + dev_name = topic.rsplit('/', 3)[1] + prop_name = topic.rsplit('/', 3)[2] + + for device in self._devices: + if device.name != dev_name: + continue + chosen_device = device + + self.mqtt_publish_update(chosen_device.name, prop_name, chosen_device.get_property(prop_name)) + + def mqtt_publish_update(self, device_name: str, property_name: str, value) -> None: if isinstance(value, enum.Enum): payload = 'fan_only' if value is AcWorkMode.FAN else value.name.lower() else: payload = str(value) - _mqtt_client.publish(_mqtt_topics['pub'].format(name), - payload=payload.encode('utf-8')) - - -if __name__ == '__main__': - _parsed_args = ParseArguments() # type: argparse.Namespace - - if sys.platform == 'linux': - logging_handler = logging.handlers.SysLogHandler(address='/dev/log') - elif sys.platform == 'darwin': - logging_handler = logging.handlers.SysLogHandler(address='/var/run/syslog') - elif sys.platform.lower() in ['windows', 'win32']: - logging_handler = logging.handlers.SysLogHandler() - else: # Unknown platform, revert to stderr - logging_handler = logging.StreamHandler(sys.stderr) - logging_handler.setFormatter( - logging.Formatter(fmt='{levelname[0]}{asctime}.{msecs:03.0f} ' - '{filename}:{lineno}] {message}', - datefmt='%m%d %H:%M:%S', style='{')) - logger = logging.getLogger() - logger.setLevel(_parsed_args.log_level) - logger.addHandler(logging_handler) - - _config = Config() - if _parsed_args.device_type == 'ac': - _data = Data(properties=AcProperties()) - elif _parsed_args.device_type == 'fgl': - _data = Data(properties=FglProperties()) - elif _parsed_args.device_type == 'fgl_b': - _data = Data(properties=FglBProperties()) - elif _parsed_args.device_type == 'humidifier': - _data = Data(properties=HumidifierProperties()) - else: - sys.exit(1) # Should never get here. - - _mqtt_client = None # type: typing.Optional[mqtt.Client] - _mqtt_topics = {} # type: typing.Dict[str, str] - if _parsed_args.mqtt_host: - _mqtt_topics['pub'] = '/'.join((_parsed_args.mqtt_topic, '{}', 'status')) - _mqtt_topics['sub'] = '/'.join((_parsed_args.mqtt_topic, '{}', 'command')) - _mqtt_client = mqtt.Client(client_id=_parsed_args.mqtt_client_id, - clean_session=True) - _mqtt_client.on_connect = mqtt_on_connect - _mqtt_client.on_message = mqtt_on_message - if _parsed_args.mqtt_user: - _mqtt_client.username_pw_set(*_parsed_args.mqtt_user.split(':',1)) - _mqtt_client.connect(_parsed_args.mqtt_host, _parsed_args.mqtt_port) - _mqtt_client.loop_start() - - _keep_alive = None # type: typing.Optional[KeepAliveThread] - - query_status = QueryStatusThread() - query_status.start() - - _keep_alive = KeepAliveThread() - _keep_alive.start() - - _httpd = HTTPServer(('', _parsed_args.port), HTTPRequestHandler) - try: - _httpd.serve_forever() - except KeyboardInterrupt: - pass - _httpd.server_close() + self.publish(self._mqtt_topics['pub'].format(device_name, property_name), payload=payload.encode('utf-8')) diff --git a/aircon/notifier.py b/aircon/notifier.py index 9ccf663..8d3b903 100755 --- a/aircon/notifier.py +++ b/aircon/notifier.py @@ -1,103 +1,112 @@ -#!/usr/bin/env python3.7 -""" -Notifier for the air conditioner module server. -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse -import base64 -from dataclasses import dataclass, field, fields -from dataclasses_json import dataclass_json -import enum -import hmac -from http.client import HTTPConnection, InvalidURL -from http.server import HTTPServer, BaseHTTPRequestHandler +import aiohttp +import asyncio +import concurrent +from dataclasses import dataclass from http import HTTPStatus import json import logging -import logging.handlers -import math -import paho.mqtt.client as mqtt -import queue -import random -from retry import retry import socket -import string -import sys -import threading +from tenacity import retry, retry_if_exception_type, wait_incrementing import time -import typing -from urllib.parse import parse_qs, urlparse, ParseResult +import threading -from Crypto.Cipher import AES +from .aircon import BaseDevice +@dataclass +class _NotifyConfiguration: + device: BaseDevice + headers: dict + alive: bool + last_timestamp: int -class KeepAliveThread(threading.Thread): - """Thread to preiodically generate keep-alive requests.""" - +class Notifier: _KEEP_ALIVE_INTERVAL = 10.0 + _TIME_TO_HANDLE_REQUESTS = 100e-3 + + def __init__(self, port: int): + self._configurations = [] + self._condition = asyncio.Condition() + + local_ip = self._get_local_ip() + self._json = { + 'local_reg': { + 'ip': local_ip, + 'notify': 0, + 'port': port, + 'uri': "/local_lan" + } + } - def __init__(self): - self.run_lock = threading.Condition() - self._alive = False + def _get_local_ip(self): sock = None try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.connect(('10.255.255.255', 1)) - local_ip = sock.getsockname()[0] + return sock.getsockname()[0] finally: if sock: sock.close() - self._headers = { - 'Accept': 'application/json', - 'Connection': 'Keep-Alive', - 'Content-Type': 'application/json', - 'Host': _parsed_args.ip, - 'Accept-Encoding': 'gzip' - } - self._json = { - 'local_reg': { - 'ip': local_ip, - 'notify': 0, - 'port': _parsed_args.port, - 'uri': "/local_lan" + + def register_device(self, device: BaseDevice): + if (not device in self._configurations): + headers = { + 'Accept': 'application/json', + 'Connection': 'keep-alive', + 'Content-Type': 'application/json', + 'Host': device.ip_address, + 'Accept-Encoding': 'gzip' } - } - super(KeepAliveThread, self).__init__(name='Keep Alive thread') + self._configurations.append(_NotifyConfiguration(device, headers, False, 0)) + + async def _notify(self): + async with self._condition: + self._condition.notify_all() + + def notify(self): + loop = asyncio.get_event_loop() + asyncio.run_coroutine_threadsafe(self._notify(), loop) - @retry(exceptions=ConnectionError, delay=0.5, max_delay=20, backoff=1.5, logger=logging) - def _establish_connection(self, conn: HTTPConnection) -> None: - method = 'PUT' if self._alive else 'POST' - logging.debug('%s /local_reg.json %s', method, json.dumps(self._json)) + async def start(self): + async with aiohttp.ClientSession(conn_timeout=5.0) as session: + async with self._condition: + while True: + queues_empty = True + try: + for entry in self._configurations: + now = time.time() + queue_size = entry.device.commands_queue.qsize() + if queue_size > 1: + queues_empty = False + if now - entry.last_timestamp >= self._KEEP_ALIVE_INTERVAL or queue_size > 0: + await self._perform_request(session, entry) + entry.last_timestamp = now + except: + logging.exception('[KeepAlive] Failed to send local_reg keep alive to the AC.') + if queues_empty: + logging.debug('[KeepAlive] Waiting for notification or timeout') + try: + await asyncio.wait_for(self._condition.wait(), timeout=self._KEEP_ALIVE_INTERVAL) + #await self._wait_on_condition_with_timeout(self._condition, self._KEEP_ALIVE_INTERVAL) + except concurrent.futures.TimeoutError: + pass + else: + # give some time to clean up the queues + await asyncio.sleep(self._TIME_TO_HANDLE_REQUESTS) + + @retry(retry=retry_if_exception_type(ConnectionError), wait=wait_incrementing(start=0.5, increment=1.5, max=10)) + async def _perform_request(self, session: aiohttp.ClientSession, config: _NotifyConfiguration) -> None: + method = 'PUT' if config.alive else 'POST' + self._json['local_reg']['notify'] = int(config.device.commands_queue.qsize() > 0) + url = 'http://{}/local_reg.json'.format(config.device.ip_address) try: - conn.request(method, '/local_reg.json', json.dumps(self._json), self._headers) - resp = conn.getresponse() - if resp.status != HTTPStatus.ACCEPTED: - raise ConnectionError('Recieved invalid response for local_reg: ' + repr(resp)) - resp.read() + logging.debug('[KeepAlive] Sending {} {} {}'.format(method, url, json.dumps(self._json))) + async with session.request(method, url, json=self._json, headers=config.headers) as resp: + if resp.status != HTTPStatus.ACCEPTED.value: + resp_data = await resp.text() + logging.error('[KeepAlive] Sending local_reg failed: {}, {}'.format(resp.status, resp_data)) + raise ConnectionError('Sending local_reg failed: {}, {}'.format(resp.status, resp_data)) except: - self._alive = False + config.alive = False raise - finally: - conn.close() - self._alive = True - - def run(self) -> None: - with self.run_lock: - try: - conn = HTTPConnection(_parsed_args.ip, timeout=5) - except InvalidURL: - logging.exception('Invalid IP provided.') - _httpd.shutdown() - return - while True: - try: - self._establish_connection(conn) - except: - logging.exception('Failed to send local_reg keep alive to the AC.') - _httpd.shutdown() - return - self._json['local_reg']['notify'] = int( - _data.commands_queue.qsize() > 0 or self.run_lock.wait(self._KEEP_ALIVE_INTERVAL)) + config.alive = True diff --git a/aircon/properties.py b/aircon/properties.py index c2580dc..ba85eee 100755 --- a/aircon/properties.py +++ b/aircon/properties.py @@ -1,37 +1,6 @@ -#!/usr/bin/env python3.7 -""" -Properties for the air conditioner module server. -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse -import base64 -from dataclasses import dataclass, field, fields +from dataclasses import dataclass, field from dataclasses_json import dataclass_json import enum -import hmac -from http.client import HTTPConnection, InvalidURL -from http.server import HTTPServer, BaseHTTPRequestHandler -from http import HTTPStatus -import json -import logging -import logging.handlers -import math -import paho.mqtt.client as mqtt -import queue -import random -from retry import retry -import socket -import string -import sys -import threading -import time -import typing -from urllib.parse import parse_qs, urlparse, ParseResult - -from Crypto.Cipher import AES - class AirFlowState(enum.IntEnum): OFF = 0 @@ -146,8 +115,6 @@ class FglFanSpeed(enum.IntEnum): HIGH = 3 AUTO = 4 - - class Properties(object): @classmethod def _get_metadata(cls, attr: str): @@ -165,7 +132,6 @@ def get_base_type(cls, attr: str): def get_read_only(cls, attr: str): return cls._get_metadata(attr)['read_only'] - @dataclass_json @dataclass class AcProperties(Properties): @@ -206,7 +172,7 @@ class AcProperties(Properties): t_fan_leftright: AirFlow = field(default=AirFlow.OFF, metadata={'base_type': 'boolean', 'read_only': False, 'dataclasses_json': {'encoder': lambda x: x.name, 'decoder': lambda x: AirFlow[x]}}) # HorizontalAirFlow t_fan_mute: Quiet = field(default=Quiet.OFF, metadata={'base_type': 'boolean', 'read_only': False, - 'dataclasses_json': {'encoder': lambda x: x.name, 'decoder': lambda x: Quite[x]}}) # QuiteModeStatus + 'dataclasses_json': {'encoder': lambda x: x.name, 'decoder': lambda x: Quiet[x]}}) # QuietModeStatus t_fan_power: AirFlow = field(default=AirFlow.OFF, metadata={'base_type': 'boolean', 'read_only': False, 'dataclasses_json': {'encoder': lambda x: x.name, 'decoder': lambda x: AirFlow[x]}}) # VerticalAirFlow t_fan_speed: FanSpeed = field(default=FanSpeed.AUTO, metadata={'base_type': 'integer', 'read_only': False, diff --git a/aircon/query_handlers.py b/aircon/query_handlers.py index 6ed425c..7965fd0 100755 --- a/aircon/query_handlers.py +++ b/aircon/query_handlers.py @@ -1,92 +1,27 @@ -#!/usr/bin/env python3.7 -""" -Query handlers for the air conditioner module server. -""" - -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse +from aiohttp import web import base64 -from dataclasses import dataclass, field, fields -from dataclasses_json import dataclass_json -import enum -import hmac -from http.client import HTTPConnection, InvalidURL -from http.server import HTTPServer, BaseHTTPRequestHandler +from Crypto.Cipher import AES from http import HTTPStatus import json -import logging -import logging.handlers import math -import paho.mqtt.client as mqtt +import logging import queue import random -from retry import retry -import socket import string -import sys -import threading import time -import typing -from urllib.parse import parse_qs, urlparse, ParseResult - -from Crypto.Cipher import AES - - -def pad(data: bytes): - """Zero padding for AES data encryption (non standard).""" - new_size = math.ceil(len(data) / AES.block_size) * AES.block_size - return data.ljust(new_size, bytes([0])) +from typing import Callable +from .config import Config, Encryption +from .aircon import BaseDevice +from .error import Error, KeyIdReplaced -def unpad(data: bytes): - """Remove Zero padding for AES data encryption (non standard).""" - return data.rstrip(bytes([0])) +class QueryHandlers: + def __init__(self, devices: [BaseDevice]): + self._devices_map = {} + for device in devices: + self._devices_map[device.ip_address] = device - -class HTTPRequestHandler(BaseHTTPRequestHandler): - """Handler for AC related HTTP requests.""" - - def do_HEAD(self, code: HTTPStatus = HTTPStatus.OK) -> None: - """Return a JSON header.""" - self.send_response(code) - if code == HTTPStatus.OK: - self.send_header('Content-type', 'application/json') - self.end_headers() - - def do_GET(self) -> None: - """Accepts get requests.""" - logging.debug('GET Request,\nPath: %s\n', self.path) - parsed_url = urlparse(self.path) - query = parse_qs(parsed_url.query) - handler = self._HANDLERS_MAP.get(parsed_url.path) - if handler: - try: - handler(self, parsed_url.path, query, {}) - return - except: - logging.exception('Failed to parse property.') - self.do_HEAD(HTTPStatus.NOT_FOUND) - - def do_POST(self): - """Accepts post requests.""" - content_length = int(self.headers['Content-Length']) - post_data = self.rfile.read(content_length) - logging.debug('POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n', - str(self.path), str(self.headers), post_data.decode('utf-8')) - parsed_url = urlparse(self.path) - query = parse_qs(parsed_url.query) - data = json.loads(post_data) - handler = self._HANDLERS_MAP.get(parsed_url.path) - if handler: - try: - handler(self, parsed_url.path, query, data) - return - except: - logging.exception('Failed to parse property.') - self.do_HEAD(HTTPStatus.NOT_FOUND) - - def key_exchange_handler(self, path: str, query: dict, data: dict) -> None: + async def key_exchange_handler(self, request: web.Request) -> web.Response: """Handles a key exchange. Accepts the AC's random and time and pass its own. Note that a key encryption component is the lanip_key, mapped to the @@ -94,198 +29,115 @@ def key_exchange_handler(self, path: str, query: dict, data: dict) -> None: server. Fortunately the lanip_key_id (and lanip_key) are static for a given AC. """ + updated_keys = {} + post_data = await request.text() + data = json.loads(post_data) try: key = data['key_exchange'] if key['ver'] != 1 or key['proto'] != 1 or key.get('sec'): - raise KeyError() - _config.lan_config.random_1 = key['random_1'] - _config.lan_config.time_1 = key['time_1'] - except KeyError: - logging.error('Invalid key exchange: %r', data) - self.do_HEAD(HTTPStatus.BAD_REQUEST) - return - if key['key_id'] != _config.lan_config.lanip_key_id: - logging.error('The key_id has been replaced!!\nOld ID was %d; new ID is %d.', - _config.lan_config.lanip_key_id, key['key_id']) - self.do_HEAD(HTTPStatus.NOT_FOUND) - return - _config.lan_config.random_2 = ''.join( - random.choices(string.ascii_letters + string.digits, k=16)) - _config.lan_config.time_2 = time.monotonic_ns() % 2**40 - _config.update() - self.do_HEAD(HTTPStatus.OK) - self._write_json({"random_2": _config.lan_config.random_2, - "time_2": _config.lan_config.time_2}) - - def command_handler(self, path: str, query: dict, data: dict) -> None: + logging.error('Invalid key exchange: {}'.format(data)) + raise web.HTTPBadRequest() + updated_keys = self._devices_map[request.remote].update_key(key) + except KeyIdReplaced as e: + logging.error('{}\n{}'.format(e.title, e.message)) + return web.Response(status=HTTPStatus.NOT_FOUND.value) + return web.json_response(updated_keys) + + async def command_handler(self, request: web.Request) -> web.Response: """Handles a command request. Request arrives from the AC. takes a command from the queue, builds the JSON, encrypts and signs it, and sends it to the AC. """ command = {} - with _data.commands_seq_no_lock: - command['seq_no'] = _data.commands_seq_no - _data.commands_seq_no += 1 + device = self._devices_map[request.remote] + command['seq_no'] = device.get_command_seq_no() try: - command['data'], property_updater = _data.commands_queue.get_nowait() + command['data'], property_updater = device.commands_queue.get_nowait() except queue.Empty: command['data'], property_updater = {}, None - self.do_HEAD(HTTPStatus.OK) - self._write_json(self._encrypt_and_sign(command)) if property_updater: - property_updater() + property_updater() #TODO: should be async as well? + return web.json_response(self._encrypt_and_sign(device, command)) - def property_update_handler(self, path: str, query: dict, data: dict) -> None: + async def property_update_handler(self, request: web.Request) -> web.Response: """Handles a property update request. Decrypts, validates, and pushes the value into the local properties store. """ + device = self._devices_map[request.remote] + post_data = await request.text() + data = json.loads(post_data) try: - update = self._decrypt_and_validate(data) + update = self._decrypt_and_validate(device, data) except Error: logging.exception('Failed to parse property.') - self.do_HEAD(HTTPStatus.BAD_REQUEST) - return - self.do_HEAD(HTTPStatus.OK) - with _data.updates_seq_no_lock: - # Every once in a while the sequence number is zeroed out, so accept it. - if _data.updates_seq_no > update['seq_no'] and update['seq_no'] > 0: - logging.error('Stale update found %d. Last update used is %d.', - (update['seq_no'], _data.updates_seq_no)) - return # Old update - _data.updates_seq_no = update['seq_no'] + return web.Response(status=HTTPStatus.BAD_REQUEST.value) + response = web.Response() + if not device.is_update_valid(update['seq_no']): + return response try: if not update['data']: - logging.debug('No value returned for seq_no %d, likely an unsupported property key.', - update['seq_no']) - return + logging.error('Unsupported update message = {}'.format(update['seq_no'])) + return response #TODO: Should return error? name = update['data']['name'] - data_type = _data.properties.get_type(name) + data_type = device.get_property_type(name) value = data_type(update['data']['value']) - _data.update_property(name, value) - except: - logging.exception('Failed to handle %s', update) + device.update_property(name, value) + except Exception as ex: + logging.error('Failed to handle {}. Exception = {}'.format(update, ex)) + #TODO: Should return internal error? + return response - def get_status_handler(self, path: str, query: dict, data: dict) -> None: + async def get_status_handler(self, request: web.Request) -> web.Response: """Handles get status request (by a smart home hub). Returns the current internally stored state of the AC. """ - with _data.properties_lock: - data = _data.properties.to_dict() - self.do_HEAD(HTTPStatus.OK) - self._write_json(data) - - def queue_command_handler(self, path: str, query: dict, data: dict) -> None: + devices = [] + for device in self._devices_map.values(): + if 'device_ip' in request.query.keys() and device.ip_address != request.query['device_ip']: + continue + devices.append({'ip': device.ip_address, + 'props': device.get_all_properties().to_dict()}) + return web.json_response({'devices': devices}) + + async def queue_command_handler(self, request: web.Request) -> web.Response: """Handles queue command request (by a smart home hub). """ + device = self._devices_map.get(request.query.get('device_ip')) + if not device: + raise web.HTTPBadRequest() try: - queue_command(query['property'][0], query['value'][0]) + device.queue_command(request.query['property'], request.query['value']) except: logging.exception('Failed to queue command.') - self.do_HEAD(HTTPStatus.BAD_REQUEST) - return - self.do_HEAD(HTTPStatus.OK) - self._write_json({'queued commands': _data.commands_queue.qsize()}) - - @staticmethod - def _encrypt_and_sign(data: dict) -> dict: - text = json.dumps(data).encode('utf-8') - logging.debug('Encrypting: %s', text.decode('utf-8')) + raise web.HTTPBadRequest() + return web.json_response({'queued_commands': device.commands_queue.qsize()}) + + def _encrypt_and_sign(self, device: BaseDevice, data: dict) -> dict: + text = json.dumps(data) + logging.debug('Encrypting: {}'.format(text)) + text = text.encode('utf-8') + encryption = device.get_app_encryption() return { - "enc": base64.b64encode(_config.app.cipher.encrypt(pad(text))).decode('utf-8'), - "sign": base64.b64encode(Encryption.hmac_digest(_config.app.sign_key, text)).decode('utf-8') + "enc": base64.b64encode(encryption.cipher.encrypt(self.pad(text))).decode('utf-8'), + "sign": base64.b64encode(Encryption.hmac_digest(encryption.sign_key, text)).decode('utf-8') } - @staticmethod - def _decrypt_and_validate(data: dict) -> dict: - text = unpad(_config.dev.cipher.decrypt(base64.b64decode(data['enc']))) - sign = base64.b64encode(Encryption.hmac_digest(_config.dev.sign_key, text)).decode('utf-8') + def _decrypt_and_validate(self, device: BaseDevice, data: dict) -> dict: + encryption = device.get_dev_encryption() + text = self.unpad(encryption.cipher.decrypt(base64.b64decode(data['enc']))) + sign = base64.b64encode(Encryption.hmac_digest(encryption.sign_key, text)).decode('utf-8') if sign != data['sign']: raise Error('Invalid signature for %s!' % text.decode('utf-8')) logging.info('Decrypted: %s', text.decode('utf-8')) return json.loads(text.decode('utf-8')) - def _write_json(self, data: dict) -> None: - """Send out the provided data dict as JSON.""" - logging.debug('Response:\n%s', json.dumps(data)) - self.wfile.write(json.dumps(data).encode('utf-8')) - - _HANDLERS_MAP = { - '/hisense/status': get_status_handler, - '/hisense/command': queue_command_handler, - '/local_lan/key_exchange.json': key_exchange_handler, - '/local_lan/commands.json': command_handler, - '/local_lan/property/datapoint.json': property_update_handler, - '/local_lan/property/datapoint/ack.json': property_update_handler, - '/local_lan/node/property/datapoint.json': property_update_handler, - '/local_lan/node/property/datapoint/ack.json': property_update_handler, - # TODO: Handle these if needed. - # '/local_lan/node/conn_status.json': connection_status_handler, - # '/local_lan/connect_status': module_request_handler, - # '/local_lan/status.json': setup_device_details_handler, - # '/local_lan/wifi_scan.json': module_request_handler, - # '/local_lan/wifi_scan_results.json': module_request_handler, - # '/local_lan/wifi_status.json': module_request_handler, - # '/local_lan/regtoken.json': module_request_handler, - # '/local_lan/wifi_stop_ap.json': module_request_handler, - } - - -if __name__ == '__main__': - _parsed_args = ParseArguments() # type: argparse.Namespace - - if sys.platform == 'linux': - logging_handler = logging.handlers.SysLogHandler(address='/dev/log') - elif sys.platform == 'darwin': - logging_handler = logging.handlers.SysLogHandler(address='/var/run/syslog') - elif sys.platform.lower() in ['windows', 'win32']: - logging_handler = logging.handlers.SysLogHandler() - else: # Unknown platform, revert to stderr - logging_handler = logging.StreamHandler(sys.stderr) - logging_handler.setFormatter( - logging.Formatter(fmt='{levelname[0]}{asctime}.{msecs:03.0f} ' - '{filename}:{lineno}] {message}', - datefmt='%m%d %H:%M:%S', style='{')) - logger = logging.getLogger() - logger.setLevel(_parsed_args.log_level) - logger.addHandler(logging_handler) - - _config = Config() - if _parsed_args.device_type == 'ac': - _data = Data(properties=AcProperties()) - elif _parsed_args.device_type == 'fgl': - _data = Data(properties=FglProperties()) - elif _parsed_args.device_type == 'fgl_b': - _data = Data(properties=FglBProperties()) - elif _parsed_args.device_type == 'humidifier': - _data = Data(properties=HumidifierProperties()) - else: - sys.exit(1) # Should never get here. - - _mqtt_client = None # type: typing.Optional[mqtt.Client] - _mqtt_topics = {} # type: typing.Dict[str, str] - if _parsed_args.mqtt_host: - _mqtt_topics['pub'] = '/'.join((_parsed_args.mqtt_topic, '{}', 'status')) - _mqtt_topics['sub'] = '/'.join((_parsed_args.mqtt_topic, '{}', 'command')) - _mqtt_client = mqtt.Client(client_id=_parsed_args.mqtt_client_id, - clean_session=True) - _mqtt_client.on_connect = mqtt_on_connect - _mqtt_client.on_message = mqtt_on_message - if _parsed_args.mqtt_user: - _mqtt_client.username_pw_set(*_parsed_args.mqtt_user.split(':',1)) - _mqtt_client.connect(_parsed_args.mqtt_host, _parsed_args.mqtt_port) - _mqtt_client.loop_start() - - _keep_alive = None # type: typing.Optional[KeepAliveThread] - - query_status = QueryStatusThread() - query_status.start() - - _keep_alive = KeepAliveThread() - _keep_alive.start() + @staticmethod + def pad(data: bytes): + """Zero padding for AES data encryption (non standard).""" + new_size = math.ceil(len(data) / AES.block_size) * AES.block_size + return data.ljust(new_size, bytes([0])) - _httpd = HTTPServer(('', _parsed_args.port), HTTPRequestHandler) - try: - _httpd.serve_forever() - except KeyboardInterrupt: - pass - _httpd.server_close() + @staticmethod + def unpad(data: bytes): + """Remove Zero padding for AES data encryption (non standard).""" + return data.rstrip(bytes([0])) diff --git a/hass/configuration.yaml b/hass/configuration.yaml index 4eec562..cb4f4dc 100644 --- a/hass/configuration.yaml +++ b/hass/configuration.yaml @@ -2,9 +2,9 @@ climate: - platform: mqtt name: "HiSense AC" - current_temperature_topic: "hisense_ac/f_temp_in/status" - fan_mode_command_topic: "hisense_ac/t_fan_speed/command" - fan_mode_state_topic: "hisense_ac/t_fan_speed/status" + current_temperature_topic: "hisense_ac//f_temp_in/status" + fan_mode_command_topic: "hisense_ac//t_fan_speed/command" + fan_mode_state_topic: "hisense_ac//t_fan_speed/status" fan_modes: - "auto" - "lower" @@ -14,19 +14,23 @@ climate: - "higher" max_temp: "86" min_temp: "61" - mode_command_topic: "hisense_ac/t_work_mode/command" - mode_state_topic: "hisense_ac/t_work_mode/status" + mode_command_topic: "hisense_ac//t_work_mode/command" + mode_state_topic: "hisense_ac//t_work_mode/status" modes: + - "off" - "fan_only" - "heat" - "cool" - "dry" - "auto" - power_command_topic: "hisense_ac/t_power/command" - power_state_topic: "hisense_ac/t_power/status" + swing_modes: + - "on" + - "off" + power_command_topic: "hisense_ac//t_power/command" + power_state_topic: "hisense_ac//t_power/status" precision: 1.0 - swing_mode_command_topic: "hisense_ac/t_fan_power/command" - swing_mode_state_topic: "hisense_ac/t_fan_power/status" - temperature_command_topic: "hisense_ac/t_temp/command" - temperature_state_topic: "hisense_ac/t_temp/status" + swing_mode_command_topic: "hisense_ac//t_fan_power/command" + swing_mode_state_topic: "hisense_ac//t_fan_power/status" + temperature_command_topic: "hisense_ac//t_temp/command" + temperature_state_topic: "hisense_ac//t_temp/status" temperature_unit: "F" diff --git a/query_cli.py b/query_cli.py deleted file mode 100644 index 047eb7f..0000000 --- a/query_cli.py +++ /dev/null @@ -1,241 +0,0 @@ -#!/usr/bin/env python3.7 -""" -Small command line program to query HiSense servers. -Generates a small config file, to control the AC locally. - -After configuring the AC from your phone, pass the username, password -and application type to this script, in order to be able to control -the device locally. - -Note that this script needs to be run only once. The generated config -file needs to be passed to the hisense server script, to continuously -control the AC. - -The --app flag depends on your AC. -""" -__author__ = 'droreiger@gmail.com (Dror Eiger)' - -import argparse -import base64 -import gzip -import json -import logging -import ssl -import sys -from http.client import HTTPSConnection - -_AYLA_USER_SERVERS = { - 'us': 'user-field.aylanetworks.com', - 'eu': 'user-field-eu.aylanetworks.com', - 'cn': 'user-field.ayla.com.cn', -} -_AYLA_DEVICES_SERVERS = { - 'us': 'ads-field.aylanetworks.com', - 'eu': 'ads-eu.aylanetworks.com', - 'cn': 'ads-field.ayla.com.cn', -} -_SECRET_MAP = { - 'oem-us': b'\x1dgAPT\xd1\xa9\xec\xe2\xa2\x01\x19\xc0\x03X\x13j\xfc\xb5\x91', - 'mid-us': b'\xdeCx\xbe\x0cq8\x0b\x99\xb4Z\x93>\xfc\xcc\x9ag\x98\xf8\x14', - 'tornado-us': b'\x87O\xf2.&;X\xfb\xf6L\xfdRq\'\x0f\t6\x0c\xfd)', - 'wwh-us': b'(\xcb9w\xc5\xc9\xb7\xab{*k8T!Yb\xaa\xcf\xd0\x85', - 'winia-us': b'\xeb_\xce\xb2\xc6\xff`\xa9\xfa\xa8r\x1c\x0bH\xf8\xe27\xa7U\xec', - 'york-us': b'\xc6A\x7fHyV<\xb2\xa2\xde<\x1f{c\xa9\rt\x9fy\xef', - 'beko-eu': b'\xa9C\n\xdb\xf7+\x01\xe2X\ne\x85\x06\x89\xaa\x88ZP+\x07>~s{\xd3\x1f\x05\x91&\x8c\x81\x84&\xe11\xef=s"*\xa4', - 'oem-eu': b'a\x1ez\xf5\xc4\x0f\x18~\xe5\xeb\xb1\x9f\xe4\xf5&B\xfe#\x88\xcb>\x06O,y\xc1\x06c\x9d\x99J\xc2x\xac\xeb\x82\x93\xe5\r\x89d', - 'mid-eu': b'\x05$\xe6\xecW\xa3\xd1B\xa0\x84\xab*\xf0\x04\x80\xce\xae\xe5`\xc4>w\xf8\xc4\xf3X\xf6<\xd2\xd2I\x14!\xd0\x98\xed\xf2\xab\xae\xc6\x03', - 'haxxair': b'\xd8\xaf\x89--\x00\xabI\x93\x83j\xab\x9acX\xac^\x90f;', - 'fglair-cn': b'\xcd\xec\xe0\xed\x8e\xb4b\x90/\xcbq\xcf\xc3\x1b\xd6.wx:\x1e', - 'fglair-eu': b'\x82\x91[T\x14h\x88\x9f\x04\xdd\x05\x89\xf9\x04T,\xb2\xf7\x8fu', - 'fglair-us': b'U\xbf\x0c@\xbf\xe5\x16&\x10\xec2\xa37G\x82\x15|\xe7)\x91', - 'field-us': b'\xc8b\x08\xfa\xce8\xf8\xf1\x81\xa5\x81\x8fX\xb4\x80\xc0\xdc\xf5\ny', - 'huihe-us': b'\xa2\xbcZ3\xbch\xfa7.`\xbc\xef0\xa3p\xa1\xf0\xaf\xf4\xd4', - 'denali-us': b'\xf1\'\xb0K \xdbZ\xd84;\xeb\x02\xa2\xee\x008\xda\x95\xfd\x93', - 'hisense-eu': b'\xc0\xedK,\xff+X\xfa\xf6p\x87\xaa\xbcV\x88\xfbI\xb4\xcf\xad', - 'hisense-us': b'x\x04\xdf\xef6\x08\x8e\x06\n\x97\xfc\xed4m\xd8\xc7\xa3=\xce\x9f', - 'hismart-eu': b'0\x07\xe9\x04a\xa6e\xc4\x1c\x08+"\r\x84w\x91\x8f\xa8)\x98', - 'hismart-us': b'\xd6+\x1f\xb0b\t\x19G\x87\x8c\xaak\xd0\xf8y\xf5\x933\xafp', -} -_SECRET_ID_MAP = { - 'haxxair': 'HAXXAIR', - 'field-us': 'pactera-field-f624d97f-us', - 'fglair-cn': 'FGLairField-cn', - 'fglair-eu': 'FGLair-eu', - 'fglair-us': 'CJIOSP', - 'huihe-us': 'huihe-d70b5148-field-us', - 'denali-us': 'DenaliAire', - 'hisense-eu': 'Hisense', - 'hisense-us': 'APP1', - 'hismart-eu': 'Hismart', - 'hismart-us': 'App1', -} -_SECRET_ID_EXTRA_MAP = { - 'denali-us': 'iA', - 'hisense-eu': 'mw', - 'hisense-us': 'pg', - 'hismart-eu': 'fA', - 'hismart-us': 'Lg', -} -_USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 9.0; SM-G850F Build/LRX22G)' - -if __name__ == '__main__': - arg_parser = argparse.ArgumentParser( - description='Command Line to query HiSense server.', - allow_abbrev=False) - arg_parser.add_argument('-a', '--app', required=True, - choices=set(_SECRET_MAP), - help='The app used for the login.') - arg_parser.add_argument('-u', '--user', required=True, - help='Username for the app login.') - arg_parser.add_argument('-p', '--passwd', required=True, - help='Password for the app login.') - arg_parser.add_argument('-d', '--device', default=None, - help='Device name to fetch data for. If not set, takes the first.') - arg_parser.add_argument('--config', required=True, - help='Config file to write to.') - arg_parser.add_argument('--properties', type=bool, default=False, - help='Fetch the properties for the device.') - args = arg_parser.parse_args() - logging_handler = logging.StreamHandler(stream=sys.stderr) - logging_handler.setFormatter( - logging.Formatter(fmt='{levelname[0]}{asctime}.{msecs:03.0f} ' - '{filename}:{lineno}] {message}', - datefmt='%m%d %H:%M:%S', style='{')) - logger = logging.getLogger() - logger.setLevel('INFO') - logger.addHandler(logging_handler) - if args.app in _SECRET_ID_MAP: - app_prefix = _SECRET_ID_MAP[args.app] - else: - app_prefix = 'a-Hisense-{}-field'.format(args.app) - if args.app in _SECRET_ID_EXTRA_MAP: - app_id = '-'.join((app_prefix, _SECRET_ID_EXTRA_MAP[args.app], 'id')) - else: - app_id = '-'.join((app_prefix, 'id')) - secret = base64.b64encode(_SECRET_MAP[args.app]).decode('utf-8').rstrip('=').replace('+', '-').replace('/', '_') - app_secret = '-'.join((app_prefix, secret)) - # Extract the region from the app ID (and fallback to US) - region = args.app[-2:] - if region not in _AYLA_USER_SERVERS: - region = 'us' - user_server = _AYLA_USER_SERVERS[region] - devices_server = _AYLA_DEVICES_SERVERS[region] - ssl_context = ssl.SSLContext() - ssl_context.verify_mode = ssl.CERT_NONE - ssl_context.check_hostname = False - ssl_context.load_default_certs() - conn = HTTPSConnection(user_server, context=ssl_context) - query = { - 'user': { - 'email': args.user, - 'password': args.passwd, - 'application': { - 'app_id': app_id, - 'app_secret': app_secret - } - } - } - headers = { - 'Accept': 'application/json', - 'Connection': 'Keep-Alive', - 'Authorization': 'none', - 'Content-Type': 'application/json', - 'User-Agent': _USER_AGENT, - 'Host': user_server, - 'Accept-Encoding': 'gzip' - } - logging.debug('POST /users/sign_in.json, body=%r, headers=%r' % (json.dumps(query), headers)) - conn.request('POST', '/users/sign_in.json', body=json.dumps(query), headers=headers) - resp = conn.getresponse() - if resp.status != 200: - logging.error('Failed to login to Hisense server:\nStatus %d: %r', - resp.status, resp.reason) - sys.exit(1) - resp_data = resp.read() - try: - resp_data = gzip.decompress(resp_data) - except OSError: - pass # Not gzipped. - try: - tokens = json.loads(resp_data) - except UnicodeDecodeError: - logging.exception('Failed to parse login tokens to Hisense server:\nData: %r', - resp_data) - sys.exit(1) - conn.close() - conn = HTTPSConnection(devices_server, context=ssl_context) - headers = { - 'Accept': 'application/json', - 'Connection': 'Keep-Alive', - 'Authorization': 'auth_token ' + tokens['access_token'], - 'User-Agent': _USER_AGENT, - 'Host': devices_server, - 'Accept-Encoding': 'gzip' - } - logging.debug('GET /apiv1/devices.json, headers=%r' % headers) - conn.request('GET', '/apiv1/devices.json', headers=headers) - resp = conn.getresponse() - if resp.status != 200: - logging.error('Failed to get devices data from Hisense server:\nStatus %d: %r', - resp.status, resp.reason) - sys.exit(1) - resp_data = resp.read() - try: - resp_data = gzip.decompress(resp_data) - except OSError: - pass # Not gzipped. - try: - devices = json.loads(resp_data) - except UnicodeDecodeError: - logging.exception('Failed to parse devices data from Hisense server:\nData: %r', - resp_data) - sys.exit(1) - if not devices: - logging.error('No device is configured! Please configure a device first.') - sys.exit(1) - logging.info('Found devices: %r', devices) - if args.device: - for device in devices: - device = device - if device['device']['product_name'] == args.device: - break - else: - logging.error('No device named "%s" was found!', args.device) - sys.exit(1) - else: - device = devices[0] - dsn = device['device']['dsn'] - conn.request('GET', '/apiv1/dsns/{}/lan.json'.format(dsn), headers=headers) - resp = conn.getresponse() - if resp.status != 200: - logging.error('Failed to get device data from Hisense server: %r', resp) - sys.exit(1) - resp_data = resp.read() - try: - resp_data = gzip.decompress(resp_data) - except OSError: - pass # Not gzipped. - lanip = json.loads(resp_data)['lanip'] - if args.properties: - conn.request('GET', '/apiv1/dsns/{}/properties.json'.format(dsn), headers=headers) - resp = conn.getresponse() - if resp.status != 200: - logging.error('Failed to get properties data from Hisense server: %r', resp) - sys.exit(1) - resp_data = resp.read() - try: - resp_data = gzip.decompress(resp_data) - except OSError: - pass # Not gzipped. - logging.info('Properties:\n%s', json.dumps(json.loads(resp_data), indent=2)) - conn.close() - config = { - 'lanip_key': lanip['lanip_key'], - 'lanip_key_id': lanip['lanip_key_id'], - 'random_1': '', - 'time_1': 0, - 'random_2': '', - 'time_2': 0 - } - with open(args.config, 'w') as f: - f.write(json.dumps(config)) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5d6f41d --- /dev/null +++ b/setup.py @@ -0,0 +1,35 @@ +import aircon +import setuptools +from os import path + +this_directory = path.abspath(path.dirname(__file__)) + +with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +setuptools.setup( + name='aircon', + version=aircon.__version__, + description='Interface for controlling Air Conditioners, e.g. with HiSense modules.', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/deiger/AirCon', + author='Dror Eiger', + author_email='droreiger@gmail.com', + license='GPL 3.0', + packages=setuptools.find_packages(), + install_requires=[ + 'aiohttp==3.6.2', + 'dataclasses_json', + 'pycryptodome', + 'paho-mqtt==1.5.0', + 'tenacity' + ], + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Topic :: Home Automation", + ], +) +