Files
fgl-aircon/legacy/notifier.py
Petr Polezhaev 024b290b89 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 исключены из версионирования.
2026-09-17 19:44:49 +03:00

127 lines
4.3 KiB
Python

import aiohttp
import asyncio
import concurrent
from dataclasses import dataclass
from http import HTTPStatus
import json
import logging
import socket
import sys
from tenacity import retry, retry_if_exception_type, wait_exponential, stop_after_attempt
import time
import threading
from .aircon import Device
if sys.version_info < (3, 8):
TimeoutError = concurrent.futures.TimeoutError
else:
TimeoutError = asyncio.exceptions.TimeoutError
@dataclass
class _NotifyConfiguration:
device: Device
headers: dict
last_timestamp: int
def _run_after_failure(retry_state):
config = retry_state.kwargs['config']
config.device.available = False
return 0
class Notifier:
_KEEP_ALIVE_INTERVAL = 1200.0
_TIME_TO_HANDLE_REQUESTS = 60.0
def __init__(self, port: int, local_ip: str):
self._configurations = []
self._condition = asyncio.Condition()
self._running = False
local_ip = local_ip or self._get_local_ip()
self._json = {'local_reg': {'ip': local_ip, 'notify': 0, 'port': port, 'uri': '/local_lan'}}
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()
def register_device(self, device: Device):
if device not in (conf.device for conf in self._configurations):
headers = {
'Accept': 'application/json',
'Connection': 'keep-alive',
'Content-Type': 'application/json',
'Host': device.ip_address,
'Accept-Encoding': 'gzip'
}
self._configurations.append(_NotifyConfiguration(device, headers, 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)
async def start(self, session: aiohttp.ClientSession):
self._running = True
async with self._condition:
while self._running:
queue_sizes = await asyncio.gather(*(self._perform_request(session=session, config=config)
for config in self._configurations))
if max(queue_sizes) <= 1:
logging.debug('[KeepAlive] Waiting for notification or timeout')
try:
await asyncio.wait_for(self._condition.wait(), timeout=self._KEEP_ALIVE_INTERVAL)
except TimeoutError:
pass
else:
# give some time to clean up the queues
await asyncio.sleep(self._TIME_TO_HANDLE_REQUESTS)
async def stop(self):
self._running = False
await self._notify()
@retry(retry=retry_if_exception_type(ConnectionError),
retry_error_callback=_run_after_failure,
wait=wait_exponential(exp_base=1.6, max=10),
stop=stop_after_attempt(6))
async def _perform_request(self, session: aiohttp.ClientSession,
config: _NotifyConfiguration) -> int:
now = time.time()
queue_size = config.device.commands_queue.qsize()
if (queue_size == 0 or
not config.device.available) and now - config.last_timestamp < self._KEEP_ALIVE_INTERVAL:
return 0
method = 'PUT' if config.device.available else 'POST'
self._json['local_reg']['notify'] = int(config.device.commands_queue.qsize() > 0)
url = f'http://{config.device.ip_address}/local_reg.json'
logging.debug(f'[KeepAlive] Sending {method} {url} {json.dumps(self._json)}')
try:
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(f'[KeepAlive] Sending local_reg failed: {resp.status}, {resp_data}')
raise ConnectionError(f'Sending local_reg failed: {resp.status}, {resp_data}')
except (aiohttp.client_exceptions.ClientConnectorError,
aiohttp.client_exceptions.ClientConnectionError) as e:
logging.error(f'Failed to connect to {config.device.ip_address}, maybe it is offline?')
raise ConnectionError(
f'Failed to connect to {config.device.ip_address}, maybe it is offline?')
config.last_timestamp = now
config.device.available = True
return queue_size