refactor: legacy и apk перенесены в docs/, дедупликация исходников legacy

- docs/legacy/: рабочая раскладка изменённого форка (main.py + пакет
  aircon/); плоские дубликаты .py удалены, вложенный .git клона удалён
  (это изменённая копия, а не чистый клон), __pycache__ и .gitignore
  апстрима убраны. Локальный config_kata.json не версионируется
  (содержит lanip_key устройства).
- docs/apk/: манифест APK; бинарники *.apk и icon.png не версионируются.
- Обновлены пути в документации и tools/.
This commit is contained in:
2026-09-17 19:47:28 +03:00
parent 024b290b89
commit 790cee3c78
22 changed files with 15 additions and 15 deletions

View File

View File

@@ -0,0 +1,583 @@
from copy import deepcopy
from dataclasses import dataclass, field, fields
import enum
import logging
import random
import re
import string
import threading
import time
from typing import Any, Callable, Dict, List
import queue
from Crypto.Cipher import AES
from . import control_value
from .config import Config, Encryption
from .error import Error
from .properties import (AcProperties, AirFlow, AirFlowState, Economy, FanSpeed, FastColdHeat,
FglProperties, FglBProperties, HumidifierProperties, Properties, Power,
AcWorkMode, Quiet, TemperatureUnit, SleepMode)
@dataclass(order=True)
class Command:
priority: int
timestamp: int # Aligns equal priority commands in FIFO.
command: Dict = field(compare=False)
updater: Callable = field(compare=False)
class Device(object):
_FGL_DEVICES = re.compile(r'AP-W[ACDF]\dE')
_FGLB_DEVICES = re.compile(r'AP-WB\dE')
_HUMI_DEVICES = re.compile(r'0001-0401-000[12]')
def __init__(self, config: Dict[str, str], properties: Properties, notifier: Callable[[None],
None]):
self.name = config['name']
self.app = config['app']
self.model = config['model']
self.sw_version = config['sw_version']
self.mac_address = config['mac_address']
self.ip_address = config['ip_address']
self.temp_type = (TemperatureUnit.CELSIUS
if config.get('temp_type') == 'C' else TemperatureUnit.FAHRENHEIT)
self._config = Config(config['lanip_key'], config['lanip_key_id'])
self._properties = properties
self._properties_lock = threading.RLock()
self._queue_listener = notifier
self._available = None
self.topics = {}
self.work_modes = []
self.fan_modes = []
self._next_command_id = 0
self.commands_queue = queue.PriorityQueue()
self._commands_seq_no = 0
self._commands_seq_no_lock = threading.Lock()
self._updates_seq_no = 0
self._updates_seq_no_lock = threading.Lock()
self._property_change_listeners = [] # type List[Callable[[str, Any], None]]
@classmethod
def create(cls, config: Dict[str, str], notifier: Callable[[None], None]):
model = config['model']
if cls._FGL_DEVICES.fullmatch(model):
return FglDevice(config, notifier)
if cls._FGLB_DEVICES.fullmatch(model):
return FglBDevice(config, notifier)
if cls._HUMI_DEVICES.fullmatch(model):
return HumidifierDevice(config, notifier)
return AcDevice(config, notifier)
@property
def is_fahrenheit(self) -> bool:
return self.temp_type == TemperatureUnit.FAHRENHEIT
@property
def available(self) -> bool:
# Return False if was not set yet.
return self._available or False
@available.setter
def available(self, value: bool):
if self._available != value:
self._available = value
self._notify_listeners('available', 'online' if value else 'offline', retain=True)
def add_property_change_listener(self, listener: Callable[[str, Any], None]):
self._property_change_listeners.append(listener)
def remove_property_change_listener(self, listener: Callable[[str, Any], None]):
self._property_change_listeners.remove(listener)
def _notify_listeners(self, prop_name: str, value, retain: bool = False):
for listener in self._property_change_listeners:
listener(self.mac_address, prop_name, value, retain)
def get_all_properties(self) -> Properties:
with self._properties_lock:
return deepcopy(self._properties)
def get_property(self, name: str):
"""Get a stored property (or None if doesn't exist)."""
with self._properties_lock:
return getattr(self._properties, name, None)
def get_property_type(self, name: str):
return self._properties.get_type(name)
def parse_property(self, name: str, value):
return self._properties.parse_attr(name, value)
def update_property(self, name: str, value, notify_value=None) -> None:
"""Update the stored properties, if changed."""
# Update value precision for value sent from the A/C
if name == "adjust_temperature":
value = round(value * 0.1)
else:
precision = self._properties.get_update_precision(name)
if precision != 1:
value = round(value * precision)
if notify_value is None:
notify_value = value
with self._properties_lock:
old_value = getattr(self._properties, name)
logging.debug(f"Updating {self}.{name} to {value}")
if value != old_value:
setattr(self._properties, name, value)
# logging.debug('Updated properties: %s' % self._properties)
if name == 't_control_value':
self._update_controlled_properties(value)
logging.debug(f"Updated {self}.{name} to {getattr(self._properties, name)}")
self._notify_listeners(name, notify_value)
def _update_controlled_properties(self, control: int):
raise NotImplementedError()
def get_command_seq_no(self) -> int:
with self._commands_seq_no_lock:
seq_no = self._commands_seq_no
self._commands_seq_no += 1
return seq_no
def is_update_valid(self, cur_update_no: int) -> bool:
with self._updates_seq_no_lock:
# Every once in a while the sequence number is zeroed out, so accept it.
if self._updates_seq_no > cur_update_no and cur_update_no > 0:
logging.error('Stale update found %d. Last update used is %d.', cur_update_no,
self._updates_seq_no)
return False # Old update
self._updates_seq_no = cur_update_no
return True
def queue_command(self, name: str, value) -> None:
if self._properties.get_read_only(name):
raise Error('Cannot update read-only property "{}".'.format(name))
data_type = self._properties.get_type(name)
# Device mode is set using t_control_value
if issubclass(data_type, enum.Enum):
data_value = data_type[value]
elif data_type is int and type(value) is str and '.' in value:
# Round rather than fail if the input is a float.
# This is commonly the case for temperatures converted by HA from Celsius.
data_value = round(float(value))
else:
data_value = data_type(value)
# If device has set t_control_value it is being controlled by this field.
if name != 't_control_value' and self.get_property('t_control_value') and name != 't_sleep':
self._convert_to_control_value(name, data_value)
return
# Update value precision for value to be sent to the A/C
precision = self._properties.get_precision(name)
if name == 'adjust_temperature':
data_value = data_value * 10
elif precision != 1:
data_value = round(data_value / precision)
typed_value = data_value
if issubclass(data_type, enum.Enum):
data_value = data_value.value
typed_value = data_type[value]
command = self._build_command(name, data_value)
# There are (usually) no acks on commands, so also queue an update to the
# property, to be run once the command is sent.
property_updater = lambda: self.update_property(name, typed_value)
# Add as a high priority command.
self.commands_queue.put_nowait(Command(10, time.time_ns(), command, property_updater))
self._queue_listener()
def _build_command(self, name: str, data_value: int):
base_type = self._properties.get_base_type(name)
return {
'properties': [{
'property': {
'base_type': base_type,
'name': name,
'value': data_value,
'id': ''.join(random.choices(string.ascii_letters + string.digits, k=8)),
}
}]
}
def _convert_to_control_value(self, name: str, value) -> int:
raise NotImplementedError()
def queue_status(self) -> None:
for data_field in fields(self._properties):
command = {
'cmds': [{
'cmd': {
'method': 'GET',
'resource': 'property.json?name=' + data_field.name,
'uri': '/local_lan/property/datapoint.json',
'data': '',
'cmd_id': self._next_command_id,
}
}]
}
self._next_command_id += 1
# Add as a lower-priority command.
self.commands_queue.put_nowait(Command(100, time.time_ns(), command, None))
self._queue_listener()
def update_key(self, key: dict) -> dict:
return self._config.update(key)
def get_app_encryption(self) -> Encryption:
return self._config.app
def get_dev_encryption(self) -> Encryption:
return self._config.dev
class AcDevice(Device):
def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]):
super().__init__(config, AcProperties(), notifier)
self.topics = {
'env_temp': 'f_temp_in',
'fan_speed': 't_fan_speed',
'work_mode': 't_work_mode',
'power': 't_power',
'swing_mode': 't_fan_power',
'temp': 't_temp'
}
self.work_modes = ['off', 'fan_only', 'heat', 'cool', 'dry', 'auto']
self.fan_modes = ['auto', 'lower', 'low', 'medium', 'high', 'higher']
# @override to add special support for t_power.
def update_property(self, name: str, value) -> None:
with self._properties_lock:
# HomeAssistant expects an 'off' work mode when the AC is off.
notify_value = 'off' if name == 't_work_mode' and self.get_power() == Power.OFF else None
super().update_property(name, value, notify_value)
# HomeAssistant doesn't listen to changes in t_power, so notify also on a t_work_mode change.
if name == 't_power':
work_mode = 'off' if value == Power.OFF else self.get_work_mode()
self._notify_listeners('t_work_mode', work_mode)
# @override to add special support for t_power.
def queue_command(self, name: str, value) -> None:
# HomeAssistant doesn't have a designated turn on button in climate.mqtt.
# Furthermore, turn_on doesn't send the right command...
if name == 't_work_mode':
if value == 'OFF':
# Pass the command to t_power instead of t_work_mode.
name = 't_power'
else:
# Also turn on the AC (if it hasn't already).
super().queue_command('t_power', 'ON')
# Run base.
super().queue_command(name, value)
# Handle turning on FastColdHeat
if name == 't_temp_heatcold' and value == 'ON':
super().queue_command('t_fan_speed', 'AUTO')
super().queue_command('t_fan_mute', 'OFF')
super().queue_command('t_sleep', 'STOP')
super().queue_command('t_temp_eight', 'OFF')
def get_env_temp(self) -> int:
return self.get_property('f_temp_in')
def set_power(self, setting: Power) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_power(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_power', setting)
def get_power(self) -> Power:
control = self.get_property('t_control_value')
if (control):
return control_value.get_power(control)
else:
return self.get_property('t_power')
def set_temperature(self, setting: int) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_temp(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_temp', setting)
def get_temperature(self) -> int:
control = self.get_property('t_control_value')
if (control):
return control_value.get_temp(control)
else:
return self.get_property('t_temp')
def set_sleep(self, setting: SleepMode) -> None:
self.queue_command('t_control_value', setting)
def get_sleep(self) -> SleepMode:
self.get_property('t_sleep')
def set_work_mode(self, setting: AcWorkMode) -> None:
control = self.get_property('t_control_value')
if (control):
if control_value.get_power(control) == Power.OFF:
control = control_value.set_power(control, Power.ON)
control = control_value.set_work_mode(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_work_mode', setting)
def get_work_mode(self) -> AcWorkMode:
control = self.get_property('t_control_value')
if (control):
return control_value.get_work_mode(control)
else:
return self.get_property('t_work_mode')
def set_fan_speed(self, setting: FanSpeed) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_fan_speed(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_fan_speed', setting)
def get_fan_speed(self) -> FanSpeed:
control = self.get_property('t_control_value')
if (control):
return control_value.get_fan_speed(control)
else:
return self.get_property('t_fan_speed')
def set_fan_vertical(self, setting: AirFlow) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_fan_power(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_fan_power', setting)
def get_fan_vertical(self) -> AirFlow:
control = self.get_property('t_control_value')
if (control):
return control_value.get_fan_power(control)
else:
return self.get_property('t_fan_power')
def set_fan_horizontal(self, setting: AirFlow) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_fan_lr(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_fan_leftright', setting)
def get_fan_horizontal(self) -> AirFlow:
control = self.get_property('t_control_value')
if (control):
return control_value.get_fan_lr(control)
else:
return self.get_property('t_fan_leftright')
def set_fan_mute(self, setting: Quiet) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_fan_mute(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_fan_mute', setting)
def get_fan_mute(self) -> Quiet:
control = self.get_property('t_control_value')
if (control):
return control_value.get_fan_mute(control)
else:
return self.get_property('t_fan_mute')
def set_fast_heat_cold(self, setting: FastColdHeat):
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_heat_cold(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_temp_heatcold', setting)
def get_fast_heat_cold(self) -> FastColdHeat:
control = self.get_property('t_control_value')
if (control):
return control_value.get_heat_cold(control)
else:
return self.get_property('t_temp_heatcold')
def set_eco(self, setting: Economy) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_eco(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_eco', setting)
def get_eco(self) -> Economy:
control = self.get_property('t_control_value')
if (control):
return control_value.get_eco(control)
else:
return self.get_property('t_eco')
def set_temptype(self, setting: TemperatureUnit) -> None:
control = self.get_property('t_control_value')
control = control_value.clear_up_change_flags(control)
if (control):
control = control_value.set_temptype(control, setting)
self.queue_command('t_control_value', control)
else:
self.queue_command('t_temptype', setting)
def get_temptype(self) -> TemperatureUnit:
control = self.get_property('t_control_value')
if (control):
return control_value.get_temptype(control)
else:
return self.get_property('t_temptype')
def set_swing(self, setting: AirFlowState) -> None:
control = self.get_property("t_control_value")
control = control_value.clear_up_change_flags(control)
if control:
if setting == AirFlowState.OFF:
control = control_value.set_fan_power(control, AirFlow.OFF)
control = control_value.set_fan_lr(control, AirFlow.OFF)
elif setting == AirFlowState.VERTICAL_ONLY:
control = control_value.set_fan_power(control, AirFlow.ON)
control = control_value.set_fan_lr(control, AirFlow.OFF)
elif setting == AirFlowState.HORIZONTAL_ONLY:
control = control_value.set_fan_power(control, AirFlow.OFF)
control = control_value.set_fan_lr(control, AirFlow.ON)
elif setting == AirFlowState.VERTICAL_AND_HORIZONTAL:
control = control_value.set_fan_power(control, AirFlow.ON)
control = control_value.set_fan_lr(control, AirFlow.ON)
self.queue_command("t_control_value", control)
else:
if setting == AirFlowState.OFF:
self.queue_command("t_fan_speed", AirFlow.OFF)
self.queue_command("t_fan_leftright", AirFlow.OFF)
elif setting == AirFlowState.VERTICAL_ONLY:
self.queue_command("t_fan_speed", AirFlow.ON)
self.queue_command("t_fan_leftright", AirFlow.OFF)
elif setting == AirFlowState.HORIZONTAL_ONLY:
self.queue_command("t_fan_speed", AirFlow.OFF)
self.queue_command("t_fan_leftright", AirFlow.ON)
elif setting == AirFlowState.VERTICAL_AND_HORIZONTAL:
self.queue_command("t_fan_speed", AirFlow.ON)
self.queue_command("t_fan_leftright", AirFlow.ON)
def _convert_to_control_value(self, name: str, value) -> int:
if name == 't_power':
return self.set_power(value)
elif name == 't_fan_speed':
return self.set_fan_speed(value)
elif name == 't_work_mode':
return self.set_work_mode(value)
elif name == 't_temp_heatcold':
return self.set_fast_heat_cold(value)
elif name == 't_eco':
return self.set_eco(value)
elif name == 't_temp':
return self.set_temperature(value)
elif name == 't_fan_power':
return self.set_fan_vertical(value)
elif name == 't_fan_leftright':
return self.set_fan_horizontal(value)
elif name == 't_fan_mute':
return self.set_fan_mute(value)
elif name == 't_temptype':
return self.set_temptype(value)
else:
logging.error('Cannot convert to control value property {}'.format(name))
raise ValueError()
def _update_controlled_properties(self, control: int):
power = control_value.get_power(control)
self.update_property('t_power', power)
fan_speed = control_value.get_fan_speed(control)
self.update_property('t_fan_speed', fan_speed)
work_mode = control_value.get_work_mode(control)
self.update_property('t_work_mode', work_mode)
temp_heatcold = control_value.get_heat_cold(control)
self.update_property('t_temp_heatcold', temp_heatcold)
eco = control_value.get_eco(control)
self.update_property('t_eco', eco)
temp = control_value.get_temp(control)
self.update_property('t_temp', temp)
fan_power = control_value.get_fan_power(control)
self.update_property('t_fan_power', fan_power)
fan_horizontal = control_value.get_fan_lr(control)
self.update_property('t_fan_leftright', fan_horizontal)
fan_mute = control_value.get_fan_mute(control)
self.update_property('t_fan_mute', fan_mute)
temptype = control_value.get_temptype(control)
self.update_property('t_temptype', temptype)
class FglDevice(Device):
def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]):
super().__init__(config, FglProperties(), notifier)
self.topics = {
'fan_speed': 'fan_speed',
'work_mode': 'operation_mode',
'temp': 'adjust_temperature',
'display_temperature': 'display_temperature',
}
self.work_modes = ['off', 'fan_only', 'heat', 'cool', 'dry', 'auto']
self.fan_modes = ['auto', 'quiet', 'low', 'medium', 'high']
class FglBDevice(Device):
def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]):
super().__init__(config, FglBProperties(), notifier)
self.topics = {
'fan_speed': 'fan_speed',
'work_mode': 'operation_mode',
'temp': 'adjust_temperature',
'display_temperature': 'display_temperature',
}
self.work_modes = ['off', 'fan_only', 'heat', 'cool', 'dry', 'auto']
self.fan_modes = ['auto', 'quiet', 'low', 'medium', 'high']
class HumidifierDevice(Device):
def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]):
super().__init__(config, HumidifierProperties(), notifier)
self.topics = {'env_temp': 'temp', 'power': 'switch'}

View File

@@ -0,0 +1,74 @@
AYLA_USER_SERVERS = {
'us': 'user-field.aylanetworks.com',
'eu': 'user-field-eu.aylanetworks.com',
'cn': 'user-field.ayla.com.cn',
}
AYLA_DEVICES_SERVERS = {
'us': 'ads-field.aylanetworks.com',
'eu': 'ads-eu.aylanetworks.com',
'cn': 'ads-field.ayla.com.cn',
}
SECRET_MAP = {
'oem-us':
b'\x1dgAPT\xd1\xa9\xec\xe2\xa2\x01\x19\xc0\x03X\x13j\xfc\xb5\x91',
'mid-us':
b'\xdeCx\xbe\x0cq8\x0b\x99\xb4Z\x93>\xfc\xcc\x9ag\x98\xf8\x14',
'tornado-us':
b'\x87O\xf2.&;X\xfb\xf6L\xfdRq\'\x0f\t6\x0c\xfd)',
'wwh-us':
b'(\xcb9w\xc5\xc9\xb7\xab{*k8T!Yb\xaa\xcf\xd0\x85',
'winia-us':
b'\xeb_\xce\xb2\xc6\xff`\xa9\xfa\xa8r\x1c\x0bH\xf8\xe27\xa7U\xec',
'york-us':
b'\xc6A\x7fHyV<\xb2\xa2\xde<\x1f{c\xa9\rt\x9fy\xef',
'beko-eu':
b'\xa9C\n\xdb\xf7+\x01\xe2X\ne\x85\x06\x89\xaa\x88ZP+\x07>~s{\xd3\x1f\x05\x91&\x8c\x81\x84&\xe11\xef=s"*\xa4',
'oem-eu':
b'a\x1ez\xf5\xc4\x0f\x18~\xe5\xeb\xb1\x9f\xe4\xf5&B\xfe#\x88\xcb>\x06O,y\xc1\x06c\x9d\x99J\xc2x\xac\xeb\x82\x93\xe5\r\x89d',
'mid-eu':
b'\x05$\xe6\xecW\xa3\xd1B\xa0\x84\xab*\xf0\x04\x80\xce\xae\xe5`\xc4>w\xf8\xc4\xf3X\xf6<\xd2\xd2I\x14!\xd0\x98\xed\xf2\xab\xae\xc6\x03',
'haxxair':
b'\xd8\xaf\x89--\x00\xabI\x93\x83j\xab\x9acX\xac^\x90f;',
'fglair-cn':
b'\xcd\xec\xe0\xed\x8e\xb4b\x90/\xcbq\xcf\xc3\x1b\xd6.wx:\x1e',
'fglair-eu':
b'\x82\x91[T\x14h\x88\x9f\x04\xdd\x05\x89\xf9\x04T,\xb2\xf7\x8fu',
'fglair-us':
b'U\xbf\x0c@\xbf\xe5\x16&\x10\xec2\xa37G\x82\x15|\xe7)\x91',
'field-us':
b'\xc8b\x08\xfa\xce8\xf8\xf1\x81\xa5\x81\x8fX\xb4\x80\xc0\xdc\xf5\ny',
'huihe-us':
b'\xa2\xbcZ3\xbch\xfa7.`\xbc\xef0\xa3p\xa1\xf0\xaf\xf4\xd4',
'denali-us':
b'\xf1\'\xb0K \xdbZ\xd84;\xeb\x02\xa2\xee\x008\xda\x95\xfd\x93',
'hisense-eu':
b'\xc0\xedK,\xff+X\xfa\xf6p\x87\xaa\xbcV\x88\xfbI\xb4\xcf\xad',
'hisense-us':
b'x\x04\xdf\xef6\x08\x8e\x06\n\x97\xfc\xed4m\xd8\xc7\xa3=\xce\x9f',
'hismart-eu':
b'0\x07\xe9\x04a\xa6e\xc4\x1c\x08+"\r\x84w\x91\x8f\xa8)\x98',
'hismart-us':
b'\xd6+\x1f\xb0b\t\x19G\x87\x8c\xaak\xd0\xf8y\xf5\x933\xafp',
}
SECRET_ID_MAP = {
'haxxair': 'HAXXAIR',
'field-us': 'pactera-field-f624d97f-us',
'fglair-cn': 'FGLairField-cn',
'fglair-eu': 'FGLair-eu',
'fglair-us': 'CJIOSP',
'huihe-us': 'huihe-d70b5148-field-us',
'denali-us': 'DenaliAire',
'hisense-eu': 'Hisense',
'hisense-us': 'APP1',
'hismart-eu': 'Hismart',
'hismart-us': 'App1',
}
SECRET_ID_EXTRA_MAP = {
'denali-us': 'iA',
'hisense-eu': 'mw',
'hisense-us': 'pg',
'hismart-eu': 'fA',
'hismart-us': 'Lg',
}
# Most ACs are using Fahrenheit in their API. These do not:
CELSIUS_BASED_APPS = {'fglair-eu', 'hisense-eu', 'hismart-eu'}

View File

@@ -0,0 +1,73 @@
from Crypto.Cipher import AES
from dataclasses import dataclass
import hmac
import random
import string
import time
from .error import KeyIdReplaced
@dataclass
class LanConfig:
lanip_key: str
lanip_key_id: int
random_1: str
time_1: int
random_2: str
time_2: int
@dataclass
class Encryption:
sign_key: bytes
crypto_key: bytes
iv_seed: bytes
cipher: AES
def __init__(self, lanip_key: bytes, msg: bytes):
self.sign_key = self._build_key(lanip_key, msg + b'0')
self.crypto_key = self._build_key(lanip_key, msg + b'1')
self.iv_seed = self._build_key(lanip_key, msg + b'2')[:AES.block_size]
self.cipher = AES.new(self.crypto_key, AES.MODE_CBC, self.iv_seed)
@classmethod
def _build_key(cls, lanip_key: bytes, msg: bytes) -> bytes:
return cls.hmac_digest(lanip_key, cls.hmac_digest(lanip_key, msg) + msg)
@staticmethod
def hmac_digest(key: bytes, msg: bytes) -> bytes:
return hmac.digest(key, msg, 'sha256')
@dataclass
class Config:
_lan_config: LanConfig
app: Encryption
dev: Encryption
def __init__(self, lanip_key: str, lanip_key_id: int):
self._lan_config = LanConfig(lanip_key, lanip_key_id, '', 0, '', 0)
self._update_encryption()
def update(self, key: dict):
"""Updates the stored lan config, and encryption data."""
self._lan_config.random_1 = key['random_1']
self._lan_config.time_1 = key['time_1']
if key['key_id'] != self._lan_config.lanip_key_id:
raise KeyIdReplaced(
'The key_id has been replaced!!',
'Old ID was {}; new ID is {}.'.format(self._lan_config.lanip_key_id, key['key_id']))
self._lan_config.random_2 = ''.join(random.choices(string.ascii_letters + string.digits, k=16))
self._lan_config.time_2 = time.monotonic_ns()
self._update_encryption()
return {'random_2': self._lan_config.random_2, 'time_2': self._lan_config.time_2}
def _update_encryption(self):
lanip_key = self._lan_config.lanip_key.encode('utf-8')
random_1 = self._lan_config.random_1.encode('utf-8')
random_2 = self._lan_config.random_2.encode('utf-8')
time_1 = str(self._lan_config.time_1).encode('utf-8')
time_2 = str(self._lan_config.time_2).encode('utf-8')
self.app = Encryption(lanip_key, random_1 + random_2 + time_1 + time_2)
self.dev = Encryption(lanip_key, random_2 + random_1 + time_2 + time_1)

View File

@@ -0,0 +1,104 @@
from .properties import (AcWorkMode, AirFlow, Economy, FanSpeed, FastColdHeat, Quiet, Power,
TemperatureUnit)
def clear_up_change_flags(control: int) -> int:
return control & 2868817502
def get_fan_speed(control: int) -> FanSpeed:
int_val = (control >> 1) & 15
return FanSpeed(int_val)
def set_fan_speed(control: int, value: FanSpeed) -> None:
int_val = value.value
return (control & ~31) | ((int_val << 1) | 1)
def get_power(control: int) -> Power:
int_val = (control >> 6) & 1
return Power(int_val)
def set_power(control: int, value: Power) -> None:
int_val = value.value
return (control & ~(3 << 5)) | (((int_val << 1) | 1) << 5)
def get_work_mode(control: int) -> AcWorkMode:
int_val = (control >> 9) & 7
return AcWorkMode(int_val)
def set_work_mode(control: int, value: AcWorkMode) -> None:
int_val = value.value
return (control & ~(15 << 8)) | (((int_val << 1) | 1) << 8)
def get_heat_cold(control: int) -> FastColdHeat:
int_val = (control >> 13) & 1
return FastColdHeat(int_val)
def set_heat_cold(control: int, value: FastColdHeat) -> None:
int_val = value.value
return (control & ~(3 << 12)) | (((int_val << 1) | 1) << 12)
def get_eco(control: int) -> Economy:
int_val = (control >> 15) & 1
return Economy(int_val)
def set_eco(control: int, value: Economy) -> None:
int_val = value.value
return (control & ~(3 << 14)) | (((int_val << 1) | 1) << 14)
def get_temp(control: int) -> int:
return (control >> 17) & 63
def set_temp(control: int, value: int) -> None:
return (control & ~(127 << 16)) | (((value << 1) | 1) << 16)
def get_fan_power(control: int) -> AirFlow:
int_val = (control >> 25) & 1
return AirFlow(int_val)
def set_fan_power(control: int, value: AirFlow) -> None:
int_val = value.value
return (control & ~(3 << 24)) | (((int_val << 1) | 1) << 24)
def get_fan_lr(control: int) -> AirFlow:
int_val = (control >> 27) & 1
return AirFlow(int_val)
def set_fan_lr(control: int, value: AirFlow) -> None:
int_val = value.value
return (control & ~(3 << 26)) | (((int_val << 1) | 1) << 26)
def get_fan_mute(control: int) -> Quiet:
int_val = (control >> 29) & 1
return Quiet(int_val)
def set_fan_mute(control: int, value: Quiet) -> None:
int_val = value.value
return (control & ~(3 << 28)) | (((int_val << 1) | 1) << 28)
def get_temptype(control: int) -> TemperatureUnit:
int_val = (control >> 31) & 1
return TemperatureUnit(int_val)
def set_temptype(control: int, value: TemperatureUnit) -> None:
int_val = value.value
return (control & ~(3 << 30)) | (((int_val << 1) | 1) << 30)

View 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

View File

@@ -0,0 +1,11 @@
class Error(Exception):
"""Error class for AC handling."""
pass
class KeyIdReplaced(Exception):
"""Error class for key id replacement"""
def __init__(self, title, message):
self.title = title
self.message = message

View File

@@ -0,0 +1,92 @@
from dataclasses import fields
import enum
import logging
import paho.mqtt.client as mqtt
from .aircon import Device
from .properties import AcWorkMode, FglOperationMode
class MqttClient(mqtt.Client):
def __init__(self, client_id: str, mqtt_topics: dict, devices: [Device]):
super().__init__(client_id=client_id, clean_session=True)
self._mqtt_topics = mqtt_topics
self._devices = devices
self.on_connect = self.mqtt_on_connect
self.on_message = self.mqtt_on_message
def mqtt_on_connect(self, client: mqtt.Client, userdata, flags, rc):
for device in self._devices:
topics_fmt = [(self._mqtt_topics['sub'].format(device.mac_address, data_field.name), 0)
for data_field in fields(device.get_all_properties())]
logging.debug(f"Subscribing to topics{topics_fmt} for device {device}")
client.subscribe(topics_fmt)
# Subscribe to subscription updates.
client.subscribe('$SYS/broker/log/M/subscribe/#')
# Publish current status of all properties for available devices.
for device in self._devices:
if device.available:
for prop_name in fields(device.get_all_properties()):
self.mqtt_publish_update(device.mac_address,
prop_name,
device.get_property(prop_name),
retain=False)
def mqtt_on_message(self, client: mqtt.Client, userdata, message: mqtt.MQTTMessage):
logging.info('MQTT message Topic: {}, Payload {}'.format(message.topic, message.payload))
if message.topic.startswith('$SYS/broker/log/M/subscribe'):
return self.mqtt_on_subscribe(message.payload)
mac_address = message.topic.rsplit('/', 3)[1]
prop_name = message.topic.rsplit('/', 3)[2]
payload = message.payload.decode('utf-8')
if prop_name == 't_work_mode':
if payload == 'fan_only':
payload = 'FAN'
for device in self._devices:
if device.mac_address != mac_address:
continue
chosen_device = device
try:
chosen_device.queue_command(prop_name, payload.upper())
except Exception:
logging.exception('Failed to parse value {} for property {}'.format(
payload.upper(), prop_name))
def mqtt_on_subscribe(self, payload: bytes):
# The last segment in the space delimited string is the topic.
topic = payload.decode('utf-8').rsplit(' ', 1)[-1]
if topic not in self._mqtt_topics['pub']:
return
mac_address = topic.rsplit('/', 3)[1]
prop_name = topic.rsplit('/', 3)[2]
for device in self._devices:
if device.mac_address != mac_address:
continue
chosen_device = device
self.mqtt_publish_update(chosen_device.mac_address,
prop_name,
chosen_device.get_property(prop_name),
retain=False)
def mqtt_publish_update(self,
mac_address: str,
property_name: str,
value,
retain: bool = False) -> None:
if isinstance(value, enum.Enum):
payload = 'fan_only' if (value is AcWorkMode.FAN or
value is FglOperationMode.FAN_ONLY) else value.name.lower()
else:
payload = str(value)
topic = self._mqtt_topics['pub'].format(mac_address, property_name)
logging.info('Sending MQTT update Topic: {}, Payload {}'.format(topic, payload))
self.publish(topic,
payload=payload.encode('utf-8'),
retain=retain)

View File

@@ -0,0 +1,126 @@
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

View File

@@ -0,0 +1,525 @@
from dataclasses import dataclass, field
from dataclasses_json import dataclass_json
import enum
class AirFlowState(enum.IntEnum):
OFF = 0
VERTICAL_ONLY = 1
HORIZONTAL_ONLY = 2
VERTICAL_AND_HORIZONTAL = 3
class FanSpeed(enum.IntEnum):
AUTO = 0
LOWER = 5
LOW = 6
MEDIUM = 7
HIGH = 8
HIGHER = 9
class SleepMode(enum.IntEnum):
STOP = 0
ONE = 1
TWO = 2
THREE = 3
FOUR = 4
class StateMachine(enum.IntEnum):
FANONLY = 0
HEAT = 1
COOL = 2
DRY = 3
AUTO = 4
FAULTSHIELD = 5
POWEROFF = 6
OFFLINE = 7
READONLYSHARED = 8
class AcWorkMode(enum.IntEnum):
FAN = 0
HEAT = 1
COOL = 2
DRY = 3
AUTO = 4
class AirFlow(enum.Enum):
OFF = 0
ON = 1
class DeviceErrorStatus(enum.Enum):
NORMALSTATE = 0
FAULTSTATE = 1
class Dimmer(enum.Enum):
ON = 0
OFF = 1
class DoubleFrequency(enum.Enum):
OFF = 0
ON = 1
class Economy(enum.Enum):
OFF = 0
ON = 1
class EightHeat(enum.Enum):
OFF = 0
ON = 1
class FastColdHeat(enum.Enum):
OFF = 0
ON = 1
class Power(enum.Enum):
OFF = 0
ON = 1
class Quiet(enum.Enum):
OFF = 0
ON = 1
class TemperatureUnit(enum.Enum):
CELSIUS = 0
FAHRENHEIT = 1
class HumidifierWorkMode(enum.Enum):
NORMAL = 0
NIGHTLIGHT = 1
SLEEP = 2
class HumidifierWater(enum.Enum):
OK = 0
NO_WATER = 1
class Mist(enum.Enum):
SMALL = 1
MIDDLE = 2
BIG = 3
class MistState(enum.Enum):
OFF = 0
ON = 1
class FglOperationMode(enum.IntEnum):
OFF = 0
ON = 1
AUTO = 2
COOL = 3
DRY = 4
FAN_ONLY = 5
HEAT = 6
class FglFanSpeed(enum.IntEnum):
QUIET = 0
LOW = 1
MEDIUM = 2
HIGH = 3
AUTO = 4
class Properties(object):
@classmethod
def _get_metadata(cls, attr: str):
return cls.__dataclass_fields__[attr].metadata
@classmethod
def get_type(cls, attr: str):
return cls.__dataclass_fields__[attr].type
@classmethod
def parse_attr(cls, attr, value):
"""If a field supplies a parser function in its metadata, use it to parse its value from the raw data."""
# Retrieve the desired type from the class attribute type hinting
native_type = cls.__dataclass_fields__[attr].type
value_fmt = native_type(value)
# Detect parser for this attribute
parser = cls.__dataclass_fields__[attr].metadata.get('parser')
if parser:
value_fmt = parser(value)
return value_fmt
@classmethod
def get_base_type(cls, attr: str):
return cls._get_metadata(attr)['base_type']
@classmethod
def get_precision(cls, attr: str):
return cls._get_metadata(attr).get('precision', 1)
@classmethod
def get_update_precision(cls, attr: str):
metadata = cls._get_metadata(attr)
return metadata.get('update_precision', metadata.get('precision', 1))
@classmethod
def get_read_only(cls, attr: str):
return cls._get_metadata(attr)['read_only']
@dataclass_json
@dataclass
class AcProperties(Properties):
# ack_cmd: bool = field(default=None, metadata={'base_type': 'boolean', 'read_only': False})
f_electricity: int = field(default=100, metadata={'base_type': 'integer', 'read_only': True})
f_e_arkgrille: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_incoiltemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_incom: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_indisplay: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_ineeprom: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_inele: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_infanmotor: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_inhumidity: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_inkeys: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_inlow: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_intemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_invzero: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outcoiltemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outeeprom: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outgastemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outmachine2: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outmachine: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outtemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_outtemplow: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_e_push: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_filterclean: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_humidity: int = field(default=50, metadata={
'base_type': 'integer',
'read_only': True
}) # Humidity
f_power_display: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True})
f_temp_in: float = field(default=81.0, metadata={
'base_type': 'decimal',
'read_only': True
}) # EnvironmentTemperature (Fahrenheit)
f_voltage: int = field(default=0, metadata={'base_type': 'integer', 'read_only': True})
t_backlight: Dimmer = field(default=Dimmer.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Dimmer[x]
}
}) # DimmerStatus
t_control_value: int = field(default=None, metadata={'base_type': 'integer', 'read_only': False})
t_device_info: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': False})
t_display_power: bool = field(default=None, metadata={'base_type': 'boolean', 'read_only': False})
t_eco: Economy = field(default=Economy.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Economy[x]
}
})
t_fan_leftright: AirFlow = field(default=AirFlow.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: AirFlow[x]
}
}) # HorizontalAirFlow
t_fan_mute: Quiet = field(default=Quiet.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Quiet[x]
}
}) # QuietModeStatus
t_fan_power: AirFlow = field(default=AirFlow.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: AirFlow[x]
}
}) # VerticalAirFlow
t_fan_speed: FanSpeed = field(default=FanSpeed.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FanSpeed[x]
}
}) # FanSpeed
t_ftkt_start: int = field(default=None, metadata={'base_type': 'integer', 'read_only': False})
t_power: Power = field(default=Power.ON,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Power[x]
}
}) # PowerStatus
t_run_mode: DoubleFrequency = field(default=DoubleFrequency.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: DoubleFrequency[x]
}
}) # DoubleFrequency
t_setmulti_value: int = field(default=None, metadata={'base_type': 'integer', 'read_only': False})
t_sleep: SleepMode = field(default=SleepMode.STOP,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: SleepMode[x]
}
}) # SleepMode
t_temp: int = field(default=81, metadata={
'base_type': 'integer',
'read_only': False
}) # CurrentTemperature
t_temptype: TemperatureUnit = field(default=TemperatureUnit.FAHRENHEIT,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: TemperatureUnit[x]
}
}) # CurrentTemperatureUnit
t_temp_eight: EightHeat = field(default=EightHeat.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: EightHeat[x]
}
}) # EightHeatStatus
t_temp_heatcold: FastColdHeat = field(default=FastColdHeat.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FastColdHeat[x]
}
}) # FastCoolHeatStatus
t_work_mode: AcWorkMode = field(default=AcWorkMode.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: AcWorkMode[x]
}
}) # WorkModeStatus
@dataclass_json
@dataclass
class HumidifierProperties(Properties):
humi: int = field(default=0, metadata={'base_type': 'integer', 'read_only': False})
mist: Mist = field(default=Mist.SMALL,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Mist[x]
}
})
mistSt: MistState = field(default=MistState.OFF,
metadata={
'base_type': 'integer',
'read_only': True,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: MistState[x]
}
})
realhumi: int = field(default=0, metadata={'base_type': 'integer', 'read_only': True})
remain: int = field(default=0, metadata={'base_type': 'integer', 'read_only': True})
switch: Power = field(default=Power.ON,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Power[x]
}
})
temp: int = field(default=81, metadata={'base_type': 'integer', 'read_only': True})
timer: int = field(default=-1, metadata={'base_type': 'integer', 'read_only': False})
water: HumidifierWater = field(default=HumidifierWater.OK,
metadata={
'base_type': 'boolean',
'read_only': True,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: HumidifierWater[x]
}
})
workmode: HumidifierWorkMode = field(default=HumidifierWorkMode.NORMAL,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: HumidifierWorkMode[x]
}
})
@dataclass_json
@dataclass
class FglProperties(Properties):
operation_mode: FglOperationMode = field(default=FglOperationMode.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FglOperationMode[x]
}
})
fan_speed: FglFanSpeed = field(default=FglFanSpeed.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FglFanSpeed[x]
}
})
adjust_temperature: int = field(default=25,
metadata={
'base_type': 'integer',
'precision': 0.1,
'update_precision': 1.0,
'read_only': False
})
display_temperature: float = field(default=25,
metadata={
'base_type': 'integer',
'read_only': True,
'parser': lambda x: round((x-5000)/50)/2,
})
af_vertical_direction: int = field(default=3,
metadata={
'base_type': 'integer',
'read_only': False
})
af_vertical_swing: AirFlow = field(default=AirFlow.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: AirFlow[x]
}
}) # HorizontalAirFlow
af_horizontal_direction: int = field(default=3,
metadata={
'base_type': 'integer',
'read_only': False
})
af_horizontal_swing: AirFlow = field(default=AirFlow.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: AirFlow[x]
}
}) # HorizontalAirFlow
economy_mode: Economy = field(default=Economy.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Economy[x]
}
})
@dataclass_json
@dataclass
class FglBProperties(Properties):
operation_mode: FglOperationMode = field(default=FglOperationMode.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FglOperationMode[x]
}
})
fan_speed: FglFanSpeed = field(default=FglFanSpeed.AUTO,
metadata={
'base_type': 'integer',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: FglFanSpeed[x]
}
})
adjust_temperature: int = field(default=25,
metadata={
'base_type': 'integer',
'precision': 0.1,
'read_only': False
})
display_temperature: float = field(default=25,
metadata={
'base_type': 'float',
'read_only': True,
'parser': lambda x: round((x-5000)/50)/2,
})
af_vertical_move_step1: int = field(default=3,
metadata={
'base_type': 'integer',
'read_only': False
})
af_horizontal_move_step1: int = field(default=3,
metadata={
'base_type': 'integer',
'read_only': False
})
economy_mode: Economy = field(default=Economy.OFF,
metadata={
'base_type': 'boolean',
'read_only': False,
'dataclasses_json': {
'encoder': lambda x: x.name,
'decoder': lambda x: Economy[x]
}
})

View 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]))

314
docs/legacy/main.py Normal file
View File

@@ -0,0 +1,314 @@
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))

View File

@@ -0,0 +1,7 @@
aiohttp==3.13.1
dataclasses_json
pycryptodome
paho-mqtt==1.6.1
tenacity
get-mac
retry

14
docs/legacy/run.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -e
. /opt/aircon/.venv/bin/activate
python /opt/aircon/main.py \
--log_level DEBUG \
--stderr \
run \
--port 3355 \
--mqtt_host 192.168.88.1 \
--mqtt_user "aircon:ic6ahrahbaijaVe3eTip" \
--local_ip 192.168.88.1 \
--config /opt/aircon/config_kata.json \
"$@"