- docs/legacy/: рабочая раскладка изменённого форка (main.py + пакет aircon/); плоские дубликаты .py удалены, вложенный .git клона удалён (это изменённая копия, а не чистый клон), __pycache__ и .gitignore апстрима убраны. Локальный config_kata.json не версионируется (содержит lanip_key устройства). - docs/apk/: манифест APK; бинарники *.apk и icon.png не версионируются. - Обновлены пути в документации и tools/.
158 lines
6.2 KiB
Python
158 lines
6.2 KiB
Python
from aiohttp import web
|
|
import base64
|
|
from Crypto.Cipher import AES
|
|
from http import HTTPStatus
|
|
import json
|
|
import math
|
|
import logging
|
|
import queue
|
|
import random
|
|
import string
|
|
import time
|
|
from typing import Callable
|
|
|
|
from .config import Config, Encryption
|
|
from .aircon import Device
|
|
from .error import Error, KeyIdReplaced
|
|
|
|
|
|
class QueryHandlers:
|
|
|
|
def __init__(self, devices: [Device]):
|
|
self._devices_map = {}
|
|
for device in devices:
|
|
self._devices_map[device.ip_address] = device
|
|
|
|
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
|
|
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.
|
|
"""
|
|
updated_keys = {}
|
|
post_data = await request.text()
|
|
print(post_data)
|
|
data = json.loads(post_data)
|
|
try:
|
|
key = data['key_exchange']
|
|
if key['ver'] != 1 or key['proto'] != 1 or key.get('sec'):
|
|
logging.error(f'Invalid key exchange: {data}')
|
|
raise web.HTTPBadRequest(reason=f'Invalid key exchange: {data}')
|
|
updated_keys = self._devices_map[request.remote].update_key(key)
|
|
except KeyIdReplaced as e:
|
|
logging.error(f'{e.title}\n{e.message}')
|
|
return web.Response(status=HTTPStatus.NOT_FOUND.value, reason=f'{e.title}\n{e.message}')
|
|
print(updated_keys)
|
|
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 = {}
|
|
device = self._devices_map[request.remote]
|
|
command['seq_no'] = device.get_command_seq_no()
|
|
try:
|
|
command_entry = device.commands_queue.get_nowait()
|
|
command['data'], property_updater = command_entry.command, command_entry.updater
|
|
except queue.Empty:
|
|
command['data'], property_updater = {}, None
|
|
if property_updater:
|
|
property_updater() #TODO: should be async as well?
|
|
return web.json_response(self._encrypt_and_sign(device, command))
|
|
|
|
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(device, data)
|
|
except Error:
|
|
logging.exception('Failed to parse property.')
|
|
return web.Response(status=HTTPStatus.BAD_REQUEST.value, reason='Failed to parse property.')
|
|
response = web.Response()
|
|
if not device.is_update_valid(update['seq_no']):
|
|
return response
|
|
try:
|
|
if not update['data']:
|
|
logging.info('Unsupported update message = {}'.format(update['seq_no']))
|
|
return response
|
|
name = update['data']['name']
|
|
# Fix A/C typos.
|
|
if name == 'f_votage':
|
|
name = 'f_voltage'
|
|
value = device.parse_property(name, update['data']['value'])
|
|
logging.debug(f"Updating {device}.{name} to {value} ({update['data']['value']})")
|
|
device.update_property(name, value)
|
|
logging.debug(f"Updated{device}: {device.get_all_properties()})")
|
|
except Exception as ex:
|
|
logging.error('Failed to handle {}. Exception = {}'.format(update, ex))
|
|
#TODO: Should return internal error?
|
|
return response
|
|
|
|
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.
|
|
"""
|
|
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:
|
|
if len(self._devices_map) == 1:
|
|
device = list(self._devices_map.values())[0]
|
|
else:
|
|
raise web.HTTPBadRequest(reason=f'Device "{request.query.get("device_ip")}" not found.')
|
|
try:
|
|
device.queue_command(request.query['property'], request.query['value'])
|
|
except Exception as ex:
|
|
logging.exception('Failed to queue command.')
|
|
raise web.HTTPBadRequest(f'Failed to queue command:\n{ex!r}')
|
|
return web.json_response({'queued_commands': device.commands_queue.qsize()})
|
|
|
|
def _encrypt_and_sign(self, device: Device, 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(encryption.cipher.encrypt(self.pad(text))).decode('utf-8'),
|
|
"sign": base64.b64encode(Encryption.hmac_digest(encryption.sign_key, text)).decode('utf-8')
|
|
}
|
|
|
|
def _decrypt_and_validate(self, device: Device, 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(f'Invalid signature for:\n{text.decode("utf-8", errors="backslashreplace")}!')
|
|
logging.debug('Decrypted: %s', text.decode('utf-8'))
|
|
try:
|
|
return json.loads(text.decode('utf-8'))
|
|
except Exception as ex:
|
|
raise Error(f'Failed to decode message, {ex!r}:\n{text.decode("utf-8")}')
|
|
|
|
@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]))
|
|
|
|
@staticmethod
|
|
def unpad(data: bytes):
|
|
"""Remove Zero padding for AES data encryption (non standard)."""
|
|
return data.rstrip(bytes([0]))
|