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:
170
legacy/discovery.py
Normal file
170
legacy/discovery.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import aiohttp
|
||||
import base64
|
||||
from getmac import get_mac_address
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
from .app_mappings import *
|
||||
|
||||
_USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 9.0; SM-G850F Build/LRX22G)'
|
||||
|
||||
|
||||
async def _sign_in(user: str, passwd: str, user_server: str, app_id: str, app_secret: str,
|
||||
session: aiohttp.ClientSession, ssl_context: ssl.SSLContext):
|
||||
query = {
|
||||
'user': {
|
||||
'email': user,
|
||||
'password': 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)
|
||||
async with session.request('POST',
|
||||
f'https://{user_server}/users/sign_in.json',
|
||||
json=query,
|
||||
headers=headers,
|
||||
ssl=ssl_context) as resp:
|
||||
if resp.status != HTTPStatus.OK.value:
|
||||
logging.error('Failed to login to Hisense server:\nStatus %d: %r', resp.status, resp.reason)
|
||||
sys.exit(1)
|
||||
resp_data = await resp.text()
|
||||
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)
|
||||
return tokens['access_token']
|
||||
|
||||
|
||||
async def _get_devices(devices_server: str, access_token: str, headers: dict,
|
||||
session: aiohttp.ClientSession, ssl_context: ssl.SSLContext):
|
||||
logging.debug('GET /apiv1/devices.json, headers=%r', headers)
|
||||
async with session.get(f'https://{devices_server}/apiv1/devices.json',
|
||||
headers=headers,
|
||||
ssl=ssl_context) as resp:
|
||||
if resp.status != HTTPStatus.OK.value:
|
||||
logging.error('Failed to get devices data from Hisense server:\nStatus %d: %r', resp.status,
|
||||
resp.reason)
|
||||
sys.exit(1)
|
||||
resp_data = await resp.text()
|
||||
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)
|
||||
return devices
|
||||
|
||||
|
||||
async def _get_lanip(devices_server: str, dsn: str, headers: dict, session: aiohttp.ClientSession,
|
||||
ssl_context: ssl.SSLContext):
|
||||
logging.debug(f'GET /apiv1/dsns/{dsn}/lan.json, headers=%r', headers)
|
||||
async with session.get(f'https://{devices_server}/apiv1/dsns/{dsn}/lan.json',
|
||||
headers=headers,
|
||||
ssl=ssl_context) as resp:
|
||||
if resp.status != HTTPStatus.OK.value:
|
||||
logging.error('Failed to get device data from Hisense server: %r', resp)
|
||||
sys.exit(1)
|
||||
resp_data = await resp.text()
|
||||
return json.loads(resp_data)['lanip']
|
||||
|
||||
|
||||
async def _get_device_properties(devices_server: str, dsn: str, headers: dict,
|
||||
session: aiohttp.ClientSession, ssl_context: ssl.SSLContext):
|
||||
logging.debug(f'GET /apiv1/dsns/{dsn}/properties.json, headers=%r', headers)
|
||||
async with session.get(f'https://{devices_server}/apiv1/dsns/{dsn}/properties.json',
|
||||
headers=headers,
|
||||
ssl=ssl_context) as resp:
|
||||
if resp.status != HTTPStatus.OK.value:
|
||||
logging.error('Failed to get properties data from Hisense server: %r', resp)
|
||||
sys.exit(1)
|
||||
resp_data = await resp.text()
|
||||
return json.loads(resp_data)
|
||||
|
||||
|
||||
async def perform_discovery(session: aiohttp.ClientSession,
|
||||
app: str,
|
||||
user: str,
|
||||
passwd: str,
|
||||
device_filter: str = None,
|
||||
properties_filter: bool = False) -> 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 = await _sign_in(user, passwd, user_server, app_id, app_secret, session, ssl_context)
|
||||
|
||||
result = []
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'Connection': 'Keep-Alive',
|
||||
'Authorization': 'auth_token ' + access_token,
|
||||
'User-Agent': _USER_AGENT,
|
||||
'Host': devices_server,
|
||||
'Accept-Encoding': 'gzip'
|
||||
}
|
||||
devices = await _get_devices(devices_server, access_token, headers, session, ssl_context)
|
||||
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 = await _get_lanip(devices_server, dsn, headers, session, ssl_context)
|
||||
properties_text = ''
|
||||
if properties_filter:
|
||||
props = await _get_device_properties(devices_server, dsn, headers, session, ssl_context)
|
||||
device_data['properties'] = props
|
||||
|
||||
device_data['lanip_key'] = lanip['lanip_key']
|
||||
device_data['lanip_key_id'] = lanip['lanip_key_id']
|
||||
device_data['temp_type'] = 'C' if app in CELSIUS_BASED_APPS else 'F'
|
||||
# If the server doesn't know the MAC address, fetch it from the local network.
|
||||
if not device_data.get('mac'):
|
||||
mac = get_mac_address(ip=device_data['lan_ip'])
|
||||
if not mac or mac == '00:00:00:00:00:00':
|
||||
logging.error(f'Failed to fetch MAC address for AC on IP address {device_data["lan_ip"]}.' +
|
||||
'\nAre you sure it is connected? Skipping...')
|
||||
continue
|
||||
device_data['mac'] = mac.replace(':', '')
|
||||
result.append(device_data)
|
||||
return result
|
||||
Reference in New Issue
Block a user