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:
0
docs/legacy/aircon/__init__.py
Normal file
0
docs/legacy/aircon/__init__.py
Normal file
583
docs/legacy/aircon/aircon.py
Normal file
583
docs/legacy/aircon/aircon.py
Normal 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'}
|
||||
74
docs/legacy/aircon/app_mappings.py
Normal file
74
docs/legacy/aircon/app_mappings.py
Normal 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'}
|
||||
73
docs/legacy/aircon/config.py
Normal file
73
docs/legacy/aircon/config.py
Normal 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)
|
||||
104
docs/legacy/aircon/control_value.py
Normal file
104
docs/legacy/aircon/control_value.py
Normal 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)
|
||||
170
docs/legacy/aircon/discovery.py
Normal file
170
docs/legacy/aircon/discovery.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import aiohttp
|
||||
import base64
|
||||
from getmac import get_mac_address
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
from .app_mappings import *
|
||||
|
||||
_USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 9.0; SM-G850F Build/LRX22G)'
|
||||
|
||||
|
||||
async def _sign_in(user: str, passwd: str, user_server: str, app_id: str, app_secret: str,
|
||||
session: aiohttp.ClientSession, ssl_context: ssl.SSLContext):
|
||||
query = {
|
||||
'user': {
|
||||
'email': user,
|
||||
'password': passwd,
|
||||
'application': {
|
||||
'app_id': app_id,
|
||||
'app_secret': app_secret
|
||||
}
|
||||
}
|
||||
}
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'Connection': 'Keep-Alive',
|
||||
'Authorization': 'none',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': _USER_AGENT,
|
||||
'Host': user_server,
|
||||
'Accept-Encoding': 'gzip'
|
||||
}
|
||||
logging.debug('POST /users/sign_in.json, body=%r, headers=%r', json.dumps(query), headers)
|
||||
async with session.request('POST',
|
||||
f'https://{user_server}/users/sign_in.json',
|
||||
json=query,
|
||||
headers=headers,
|
||||
ssl=ssl_context) as resp:
|
||||
if resp.status != HTTPStatus.OK.value:
|
||||
logging.error('Failed to login to Hisense server:\nStatus %d: %r', resp.status, resp.reason)
|
||||
sys.exit(1)
|
||||
resp_data = await resp.text()
|
||||
try:
|
||||
tokens = json.loads(resp_data)
|
||||
except UnicodeDecodeError:
|
||||
logging.exception('Failed to parse login tokens to Hisense server:\nData: %r', resp_data)
|
||||
sys.exit(1)
|
||||
return tokens['access_token']
|
||||
|
||||
|
||||
async def _get_devices(devices_server: str, access_token: str, headers: dict,
|
||||
session: aiohttp.ClientSession, ssl_context: ssl.SSLContext):
|
||||
logging.debug('GET /apiv1/devices.json, headers=%r', headers)
|
||||
async with session.get(f'https://{devices_server}/apiv1/devices.json',
|
||||
headers=headers,
|
||||
ssl=ssl_context) as resp:
|
||||
if resp.status != HTTPStatus.OK.value:
|
||||
logging.error('Failed to get devices data from Hisense server:\nStatus %d: %r', resp.status,
|
||||
resp.reason)
|
||||
sys.exit(1)
|
||||
resp_data = await resp.text()
|
||||
try:
|
||||
devices = json.loads(resp_data)
|
||||
except UnicodeDecodeError:
|
||||
logging.exception('Failed to parse devices data from Hisense server:\nData: %r', resp_data)
|
||||
sys.exit(1)
|
||||
if not devices:
|
||||
logging.error('No device is configured! Please configure a device first.')
|
||||
sys.exit(1)
|
||||
return devices
|
||||
|
||||
|
||||
async def _get_lanip(devices_server: str, dsn: str, headers: dict, session: aiohttp.ClientSession,
|
||||
ssl_context: ssl.SSLContext):
|
||||
logging.debug(f'GET /apiv1/dsns/{dsn}/lan.json, headers=%r', headers)
|
||||
async with session.get(f'https://{devices_server}/apiv1/dsns/{dsn}/lan.json',
|
||||
headers=headers,
|
||||
ssl=ssl_context) as resp:
|
||||
if resp.status != HTTPStatus.OK.value:
|
||||
logging.error('Failed to get device data from Hisense server: %r', resp)
|
||||
sys.exit(1)
|
||||
resp_data = await resp.text()
|
||||
return json.loads(resp_data)['lanip']
|
||||
|
||||
|
||||
async def _get_device_properties(devices_server: str, dsn: str, headers: dict,
|
||||
session: aiohttp.ClientSession, ssl_context: ssl.SSLContext):
|
||||
logging.debug(f'GET /apiv1/dsns/{dsn}/properties.json, headers=%r', headers)
|
||||
async with session.get(f'https://{devices_server}/apiv1/dsns/{dsn}/properties.json',
|
||||
headers=headers,
|
||||
ssl=ssl_context) as resp:
|
||||
if resp.status != HTTPStatus.OK.value:
|
||||
logging.error('Failed to get properties data from Hisense server: %r', resp)
|
||||
sys.exit(1)
|
||||
resp_data = await resp.text()
|
||||
return json.loads(resp_data)
|
||||
|
||||
|
||||
async def perform_discovery(session: aiohttp.ClientSession,
|
||||
app: str,
|
||||
user: str,
|
||||
passwd: str,
|
||||
device_filter: str = None,
|
||||
properties_filter: bool = False) -> dict:
|
||||
if app in SECRET_ID_MAP:
|
||||
app_prefix = SECRET_ID_MAP[app]
|
||||
else:
|
||||
app_prefix = 'a-Hisense-{}-field'.format(app)
|
||||
|
||||
if app in SECRET_ID_EXTRA_MAP:
|
||||
app_id = '-'.join((app_prefix, SECRET_ID_EXTRA_MAP[app], 'id'))
|
||||
else:
|
||||
app_id = '-'.join((app_prefix, 'id'))
|
||||
|
||||
secret = base64.b64encode(SECRET_MAP[app]).decode('utf-8').rstrip('=').replace('+', '-').replace(
|
||||
'/', '_')
|
||||
app_secret = '-'.join((app_prefix, secret))
|
||||
|
||||
# Extract the region from the app ID (and fallback to US)
|
||||
region = app[-2:]
|
||||
if region not in AYLA_USER_SERVERS:
|
||||
region = 'us'
|
||||
user_server = AYLA_USER_SERVERS[region]
|
||||
devices_server = AYLA_DEVICES_SERVERS[region]
|
||||
|
||||
ssl_context = ssl.SSLContext()
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.load_default_certs()
|
||||
|
||||
access_token = await _sign_in(user, passwd, user_server, app_id, app_secret, session, ssl_context)
|
||||
|
||||
result = []
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'Connection': 'Keep-Alive',
|
||||
'Authorization': 'auth_token ' + access_token,
|
||||
'User-Agent': _USER_AGENT,
|
||||
'Host': devices_server,
|
||||
'Accept-Encoding': 'gzip'
|
||||
}
|
||||
devices = await _get_devices(devices_server, access_token, headers, session, ssl_context)
|
||||
logging.debug('Found devices: %r', devices)
|
||||
for device in devices:
|
||||
device_data = device['device']
|
||||
if device_filter and device_filter != device_data['product_name']:
|
||||
continue
|
||||
dsn = device_data['dsn']
|
||||
lanip = await _get_lanip(devices_server, dsn, headers, session, ssl_context)
|
||||
properties_text = ''
|
||||
if properties_filter:
|
||||
props = await _get_device_properties(devices_server, dsn, headers, session, ssl_context)
|
||||
device_data['properties'] = props
|
||||
|
||||
device_data['lanip_key'] = lanip['lanip_key']
|
||||
device_data['lanip_key_id'] = lanip['lanip_key_id']
|
||||
device_data['temp_type'] = 'C' if app in CELSIUS_BASED_APPS else 'F'
|
||||
# If the server doesn't know the MAC address, fetch it from the local network.
|
||||
if not device_data.get('mac'):
|
||||
mac = get_mac_address(ip=device_data['lan_ip'])
|
||||
if not mac or mac == '00:00:00:00:00:00':
|
||||
logging.error(f'Failed to fetch MAC address for AC on IP address {device_data["lan_ip"]}.' +
|
||||
'\nAre you sure it is connected? Skipping...')
|
||||
continue
|
||||
device_data['mac'] = mac.replace(':', '')
|
||||
result.append(device_data)
|
||||
return result
|
||||
11
docs/legacy/aircon/error.py
Normal file
11
docs/legacy/aircon/error.py
Normal 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
|
||||
92
docs/legacy/aircon/mqtt_client.py
Normal file
92
docs/legacy/aircon/mqtt_client.py
Normal 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)
|
||||
126
docs/legacy/aircon/notifier.py
Normal file
126
docs/legacy/aircon/notifier.py
Normal 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
|
||||
525
docs/legacy/aircon/properties.py
Normal file
525
docs/legacy/aircon/properties.py
Normal 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]
|
||||
}
|
||||
})
|
||||
157
docs/legacy/aircon/query_handlers.py
Normal file
157
docs/legacy/aircon/query_handlers.py
Normal 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
314
docs/legacy/main.py
Normal 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))
|
||||
7
docs/legacy/requirements.txt
Normal file
7
docs/legacy/requirements.txt
Normal 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
14
docs/legacy/run.sh
Executable 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 \
|
||||
"$@"
|
||||
Reference in New Issue
Block a user