Files
fgl-aircon/legacy/main.py
Petr Polezhaev 157df4e795 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

315 lines
14 KiB
Python

import aiohttp
from aiohttp import web
import argparse
import asyncio
import base64
from http import HTTPStatus
from http.client import HTTPConnection, InvalidURL
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import logging
import logging.handlers
import os
import paho.mqtt.client as mqtt
from retry import retry
import signal
import socket
import sys
try:
from systemd.journal import JournalHandler
except:
JournalHandler = None
import textwrap
import threading
import time
import _thread
from urllib.parse import parse_qs, urlparse, ParseResult
from aircon.app_mappings import SECRET_MAP
from aircon.config import Config
from aircon.error import Error
from aircon.aircon import Device
from aircon.discovery import perform_discovery
from aircon.mqtt_client import MqttClient
from aircon.notifier import Notifier
from aircon.query_handlers import QueryHandlers
async def query_status_device(device: Device):
_STATUS_UPDATE_INTERVAL = 600.0
_WAIT_FOR_EMPTY_QUEUE = 10.0
while True:
# In case the AC is stuck, and not fetching commands, avoid flooding
# the queue with status updates.
while device.commands_queue.qsize() > 10:
await asyncio.sleep(_WAIT_FOR_EMPTY_QUEUE)
device.queue_status()
await asyncio.sleep(_STATUS_UPDATE_INTERVAL)
async def query_status_worker(devices: [Device]):
await asyncio.wait([asyncio.create_task(query_status_device(device)) for device in devices])
def ParseArguments() -> argparse.Namespace:
"""Parse command line arguments."""
arg_parser = argparse.ArgumentParser(description='JSON server for HiSense air conditioners.',
allow_abbrev=False)
arg_parser.add_argument('--log_level',
default='WARNING',
choices={'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'},
help='Minimal log level.')
arg_parser.add_argument('--stderr',
default=False,
action='store_true',
help='Output log to stderr')
subparsers = arg_parser.add_subparsers(dest='cmd', help='Determines what server should do')
subparsers.required = True
parser_run = subparsers.add_parser('run', help='Runs the server to control the device')
parser_run.add_argument('-p', '--port', required=True, type=int, help='Port for the server.')
parser_run.add_argument('--local_ip',
required=False,
default=None,
help='The local IP address to report to the AC unit(s) as target server. Useful in case the server running this application has multiple IP addresses (e.g. in multiple VLANs), since some/most(?) AC units will refuse to report to an IP address outside of their subnet.')
group_device = parser_run.add_argument_group('Device', 'Arguments that are related to the device')
group_device.add_argument('--config', required=True, action='append', help='LAN Config file.')
group_device.add_argument('--type',
required=False,
action='append',
choices={'ac', 'fgl', 'fgl_b', 'humidifier'},
help='Device type. Deprecated, now decided based on OEM model.')
group_mqtt = parser_run.add_argument_group('MQTT', 'Settings related to the MQTT')
group_mqtt.add_argument('--mqtt_host', default=None, help='MQTT broker hostname or IP address.')
group_mqtt.add_argument('--mqtt_port', type=int, default=1883, help='MQTT broker port.')
group_mqtt.add_argument('--mqtt_client_id', default=None, help='MQTT client ID.')
group_mqtt.add_argument('--mqtt_user', default=None, help='<user:password> for the MQTT channel.')
group_mqtt.add_argument('--mqtt_topic', default='hisense_ac', help='MQTT topic.')
group_mqtt.add_argument('--mqtt_discovery_prefix',
default='homeassistant',
help='MQTT discovery prefix for HomeAssistant.')
parser_discovery = subparsers.add_parser('discovery', help='Runs the device discovery')
parser_discovery.add_argument('app', choices=set(SECRET_MAP), help='The app used for the login.')
parser_discovery.add_argument('user', help='Username for the app login.')
parser_discovery.add_argument('passwd', help='Password for the app login.')
parser_discovery.add_argument('-d',
'--device',
default=None,
help='Device name to fetch data for. If not set, takes all.')
parser_discovery.add_argument('--prefix',
required=False,
default='config_',
help='Config file prefix.')
parser_discovery.add_argument('--properties',
action='store_true',
help='Fetch the properties for the device.')
return arg_parser.parse_args()
def setup_logger(log_level, use_stderr=False):
if use_stderr or os.environ.get('PLATFORM') == 'docker':
logging_handler = logging.StreamHandler(sys.stderr)
elif JournalHandler:
logging_handler = JournalHandler()
# Fallbacks when JournalHandler isn't available.
elif sys.platform == 'linux':
logging_handler = logging.handlers.SysLogHandler(address='/dev/log')
elif sys.platform == 'darwin':
logging_handler = logging.handlers.SysLogHandler(address='/var/run/syslog')
elif sys.platform.lower() in ['windows', 'win32']:
logging_handler = logging.handlers.SysLogHandler()
else: # Unknown platform, revert to stderr
logging_handler = logging.StreamHandler(sys.stderr)
logging_handler.setFormatter(
logging.Formatter(fmt='{levelname[0]}{asctime}.{msecs:03.0f} '
'{filename}:{lineno}] {message}',
datefmt='%m%d %H:%M:%S',
style='{'))
logger = logging.getLogger()
logger.setLevel(log_level)
logger.addHandler(logging_handler)
async def setup_and_run_http_server(parsed_args, devices: [Device]):
query_handlers = QueryHandlers(devices)
app = web.Application()
app.add_routes([
web.get('/hisense/status', query_handlers.get_status_handler),
web.get('/hisense/command', query_handlers.queue_command_handler),
web.post('/local_lan/key_exchange.json', query_handlers.key_exchange_handler),
web.get('/local_lan/commands.json', query_handlers.command_handler),
web.post('/local_lan/property/datapoint.json', query_handlers.property_update_handler),
web.post('/local_lan/property/datapoint/ack.json', query_handlers.property_update_handler),
web.post('/local_lan/node/property/datapoint.json', query_handlers.property_update_handler),
web.post('/local_lan/node/property/datapoint/ack.json',
query_handlers.property_update_handler),
# TODO: Handle these if needed.
# '/local_lan/node/conn_status.json': query_handlers.connection_status_handler,
# '/local_lan/connect_status': query_handlers.module_request_handler,
# '/local_lan/status.json': query_handlers.setup_device_details_handler,
# '/local_lan/wifi_scan.json': query_handlers.module_request_handler,
# '/local_lan/wifi_scan_results.json': query_handlers.module_request_handler,
# '/local_lan/wifi_status.json': query_handlers.module_request_handler,
# '/local_lan/regtoken.json': query_handlers.module_request_handler,
# '/local_lan/wifi_stop_ap.json': query_handlers.module_request_handler
])
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, port=parsed_args.port)
await site.start()
async def mqtt_loop(mqtt_client: MqttClient):
_MQTT_LOOP_TIMEOUT = 1
while True:
mqtt_client.loop()
await asyncio.sleep(_MQTT_LOOP_TIMEOUT)
async def run(parsed_args):
notifier = Notifier(parsed_args.port, parsed_args.local_ip)
devices = []
for i in range(len(parsed_args.config)):
with open(parsed_args.config[i], 'rb') as f:
config = json.load(f)
device = Device.create(config, notifier.notify)
notifier.register_device(device)
devices.append(device)
mqtt_client = None
if parsed_args.mqtt_host:
mqtt_topics = {
'pub':
'/'.join((parsed_args.mqtt_topic, '{}', '{}', 'status')),
'sub':
'/'.join((parsed_args.mqtt_topic, '{}', '{}', 'command')),
'lwt':
'/'.join((parsed_args.mqtt_topic, 'LWT')),
'discovery':
'/'.join((parsed_args.mqtt_discovery_prefix, 'climate', '{}', 'hvac', 'config'))
}
mqtt_client = MqttClient(parsed_args.mqtt_client_id, mqtt_topics, devices)
if parsed_args.mqtt_user:
mqtt_client.username_pw_set(*parsed_args.mqtt_user.split(':', 1))
mqtt_client.will_set(mqtt_topics['lwt'], payload='offline', retain=True)
mqtt_client.connect(parsed_args.mqtt_host, parsed_args.mqtt_port)
mqtt_client.publish(mqtt_topics['lwt'], payload='online', retain=True)
for device in devices:
config = {
'name': device.name,
'unique_id': device.mac_address,
'device': {
'identifiers': [f'hisense_ac_{device.mac_address}'],
'manufacturer': f'Hisense ({device.app})',
'model': device.model,
'name': device.name,
'sw_version': device.sw_version
},
'availability': [
{
'topic': mqtt_topics['lwt']
},
{
'topic': mqtt_topics['pub'].format(device.mac_address, 'available')
},
],
'precision': 1.0,
'temperature_unit': 'F' if device.is_fahrenheit else 'C'
}
topics = device.topics
if 'env_temp' in topics:
config['current_temperature_topic'] = mqtt_topics['pub'].format(
device.mac_address, topics['env_temp'])
if 'fan_speed' in topics:
config['fan_mode_command_topic'] = mqtt_topics['sub'].format(device.mac_address,
topics['fan_speed'])
config['fan_mode_state_topic'] = mqtt_topics['pub'].format(device.mac_address,
topics['fan_speed'])
config['fan_modes'] = device.fan_modes
if 'work_mode' in topics:
config['mode_command_topic'] = mqtt_topics['sub'].format(device.mac_address,
topics['work_mode'])
config['mode_state_topic'] = mqtt_topics['pub'].format(device.mac_address,
topics['work_mode'])
config['modes'] = device.work_modes
if 'swing_mode' in topics:
config['swing_mode_command_topic'] = mqtt_topics['sub'].format(
device.mac_address, topics['swing_mode'])
config['swing_mode_state_topic'] = mqtt_topics['pub'].format(device.mac_address,
topics['swing_mode'])
config['swing_modes'] = ['on', 'off']
if 'temp' in topics:
config['temperature_command_topic'] = mqtt_topics['sub'].format(
device.mac_address, topics['temp'])
config['temperature_state_topic'] = mqtt_topics['pub'].format(device.mac_address,
topics['temp'])
config['max_temp'] = '86' if device.is_fahrenheit else '30'
config['min_temp'] = '61' if device.is_fahrenheit else '16'
if 'display_temperature' in topics:
config['display_temperature_state_topic'] = mqtt_topics['pub'].format(device.mac_address,
topics['display_temperature'])
mqtt_client.publish(mqtt_topics['discovery'].format(device.mac_address),
payload=json.dumps(config),
retain=True)
device.add_property_change_listener(mqtt_client.mqtt_publish_update)
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(connect=5.0)) as session:
await asyncio.gather(mqtt_loop(mqtt_client), setup_and_run_http_server(parsed_args, devices),
query_status_worker(devices), notifier.start(session))
def _escape_name(name: str):
safe_name = name.replace(' ', '_').lower()
return ''.join(x for x in safe_name if x.isalnum())
async def discovery(parsed_args):
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(connect=5.0)) as session:
try:
all_configs = await perform_discovery(session, parsed_args.app, parsed_args.user,
parsed_args.passwd, parsed_args.device,
parsed_args.properties)
except Exception as e:
print(f'Error occurred:\n{e!r}')
sys.exit(1)
for config in all_configs:
properties_text = ''
if 'properties' in config.keys():
properties_text = f'Properties:\n{json.dumps(config["properties"], indent=2)}'
print(
textwrap.dedent(f"""Device {config['product_name']} has:
IP address: {config['lan_ip']}
lanip_key: {config['lanip_key']}
lanip_key_id: {config['lanip_key_id']}
{properties_text}
"""))
file_content = {
'name': config['product_name'],
'app': parsed_args.app,
'model': config['oem_model'],
'sw_version': config['sw_version'],
'dsn': config['dsn'],
'temp_type': config['temp_type'],
'mac_address': config['mac'],
'ip_address': config['lan_ip'],
'lanip_key': config['lanip_key'],
'lanip_key_id': config['lanip_key_id'],
}
with open(parsed_args.prefix + _escape_name(config['product_name']) + '.json', 'w') as f:
f.write(json.dumps(file_content))
if __name__ == '__main__':
parsed_args = ParseArguments() # type: argparse.Namespace
if parsed_args.cmd == 'run':
setup_logger(parsed_args.log_level, use_stderr=parsed_args.stderr)
asyncio.run(run(parsed_args))
elif parsed_args.cmd == 'discovery':
setup_logger(parsed_args.log_level, use_stderr=True)
asyncio.run(discovery(parsed_args))