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]))
|
||||
Reference in New Issue
Block a user