docs: реконструкция LAN-протокола FGLair, анализ legacy, планы fglair-core/HA/ESPHome
- 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 исключены из версионирования.
This commit is contained in:
157
legacy/query_handlers.py
Normal file
157
legacy/query_handlers.py
Normal file
@@ -0,0 +1,157 @@
|
||||
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]))
|
||||
Reference in New Issue
Block a user