- 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 исключены из версионирования.
315 lines
14 KiB
Python
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))
|