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

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

View File

@@ -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'}