- PROTOCOL.md: полная спецификация (KDF, envelope/CBC-цепочка, local_reg, key exchange, commands.json 206/200, datapoint push, тайминги, таблицы свойств шаблонов A/B/F, облачный provisioning). Факты из APK помечены [APK], проверенные живыми экспериментами на AP-WC1E — [ПРОВЕРЕНО НА ПРИБОРЕ]: mDNS только :10276; лимит 2 LAN-сессии (3-я -> 503); принудительный re-key при возрасте сессии >= ~44с (единственный механизм самолечения десинхрона — 400/401 модуль игнорирует); записи не эхируются; delete_session освобождает слот. - LEGACY_ANALYSIS.md: причины рассинхрона (keep-alive 1200с вместо 10-15с + seq_no-фильтр) и перегрузки модуля; требования к новой реализации. - PLAN_CORE_LIBRARY.md: план C++20-библиотеки fglair-core (Linux + ESP-IDF), ключ lanip_key считается статичным, ротация — только ошибка + ручной перепровижининг. - PLAN_HOME_ASSISTANT.md: pyfglair (cffi wheel) + custom component, облачный provisioning только в config flow, ключ виден в диагностике для копирования в ESPHome. - PLAN_ESPHOME.md: external component, только ESP-IDF framework. - tools/probe_reference.py: эталонный клиент протокола (проверен на приборе end-to-end); tools/probe_mdns.py — mDNS-проба. - legacy/: снимок скрипта (апстрим gyro-labs/AirCon, вложенный клон не версионируется); apk/*.apk исключены из версионирования.
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]))
|