1
0
mirror of https://github.com/no2chem/wideq.git synced 2025-05-16 07:10:09 -07:00
wideq/wideq/ac.py
2020-05-10 11:24:21 -07:00

400 lines
12 KiB
Python

"""A `Device` class representing air conditioning/climate devices.
"""
import enum
from .client import Device
from .util import lookup_enum
from .core import FailedRequestError
class ACVSwingMode(enum.Enum):
"""The vertical swing mode for an AC/HVAC device.
Blades are numbered vertically from 1 (topmost)
to 6.
All is 100.
"""
OFF = "@OFF"
ONE = "@1"
TWO = "@2"
THREE = "@3"
FOUR = "@4"
FIVE = "@5"
SIX = "@6"
ALL = "@100"
class ACHSwingMode(enum.Enum):
"""The horizontal swing mode for an AC/HVAC device.
Blades are numbered horizontally from 1 (leftmost)
to 5.
Left half goes from 1-3, and right half goes from
3-5.
All is 100.
"""
OFF = "@OFF"
ONE = "@1"
TWO = "@2"
THREE = "@3"
FOUR = "@4"
FIVE = "@5"
LEFT_HALF = "@13"
RIGHT_HALF = "@35"
ALL = "@100"
class ACMode(enum.Enum):
"""The operation mode for an AC/HVAC device."""
COOL = "@AC_MAIN_OPERATION_MODE_COOL_W"
DRY = "@AC_MAIN_OPERATION_MODE_DRY_W"
FAN = "@AC_MAIN_OPERATION_MODE_FAN_W"
AI = "@AC_MAIN_OPERATION_MODE_AI_W"
HEAT = "@AC_MAIN_OPERATION_MODE_HEAT_W"
AIRCLEAN = "@AC_MAIN_OPERATION_MODE_AIRCLEAN_W"
ACO = "@AC_MAIN_OPERATION_MODE_ACO_W"
AROMA = "@AC_MAIN_OPERATION_MODE_AROMA_W"
ENERGY_SAVING = "@AC_MAIN_OPERATION_MODE_ENERGY_SAVING_W"
ENERGY_SAVER = "@AC_MAIN_OPERATION_MODE_ENERGY_SAVER_W"
class ACFanSpeed(enum.Enum):
"""The fan speed for an AC/HVAC device."""
SLOW = '@AC_MAIN_WIND_STRENGTH_SLOW_W'
SLOW_LOW = '@AC_MAIN_WIND_STRENGTH_SLOW_LOW_W'
LOW = '@AC_MAIN_WIND_STRENGTH_LOW_W'
LOW_MID = '@AC_MAIN_WIND_STRENGTH_LOW_MID_W'
MID = '@AC_MAIN_WIND_STRENGTH_MID_W'
MID_HIGH = '@AC_MAIN_WIND_STRENGTH_MID_HIGH_W'
HIGH = '@AC_MAIN_WIND_STRENGTH_HIGH_W'
POWER = '@AC_MAIN_WIND_STRENGTH_POWER_W'
AUTO = '@AC_MAIN_WIND_STRENGTH_AUTO_W'
NATURE = '@AC_MAIN_WIND_STRENGTH_NATURE_W'
R_LOW = '@AC_MAIN_WIND_STRENGTH_LOW_RIGHT_W'
R_MID = '@AC_MAIN_WIND_STRENGTH_MID_RIGHT_W'
R_HIGH = '@AC_MAIN_WIND_STRENGTH_HIGH_RIGHT_W'
L_LOW = '@AC_MAIN_WIND_STRENGTH_LOW_LEFT_W'
L_MID = '@AC_MAIN_WIND_STRENGTH_MID_LEFT_W'
L_HIGH = '@AC_MAIN_WIND_STRENGTH_HIGH_LEFT_W'
L_LOWR_LOW = '@AC_MAIN_WIND_STRENGTH_LOW_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_LOW_RIGHT_W'
L_LOWR_MID = '@AC_MAIN_WIND_STRENGTH_LOW_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_MID_RIGHT_W'
L_LOWR_HIGH = '@AC_MAIN_WIND_STRENGTH_LOW_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_HIGH_RIGHT_W'
L_MIDR_LOW = '@AC_MAIN_WIND_STRENGTH_MID_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_LOW_RIGHT_W'
L_MIDR_MID = '@AC_MAIN_WIND_STRENGTH_MID_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_MID_RIGHT_W'
L_MIDR_HIGH = '@AC_MAIN_WIND_STRENGTH_MID_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_HIGH_RIGHT_W'
L_HIGHR_LOW = '@AC_MAIN_WIND_STRENGTH_HIGH_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_LOW_RIGHT_W'
L_HIGHR_MID = '@AC_MAIN_WIND_STRENGTH_HIGH_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_MID_RIGHT_W'
L_HIGHR_HIGH = '@AC_MAIN_WIND_STRENGTH_HIGH_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_HIGH_RIGHT_W'
AUTO_2 = '@AC_MAIN_WIND_STRENGTH_AUTO_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_AUTO_RIGHT_W'
POWER_2 = '@AC_MAIN_WIND_STRENGTH_POWER_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_POWER_RIGHT_W'
LONGPOWER = '@AC_MAIN_WIND_STRENGTH_LONGPOWER_LEFT_W|' \
'AC_MAIN_WIND_STRENGTH_LONGPOWER_RIGHT_W'
class ACOp(enum.Enum):
"""Whether a device is on or off."""
OFF = "@AC_MAIN_OPERATION_OFF_W"
RIGHT_ON = "@AC_MAIN_OPERATION_RIGHT_ON_W" # Right fan only.
LEFT_ON = "@AC_MAIN_OPERATION_LEFT_ON_W" # Left fan only.
ALL_ON = "@AC_MAIN_OPERATION_ALL_ON_W" # Both fans (or only fan) on.
class ACDevice(Device):
"""Higher-level operations on an AC/HVAC device, such as a heat
pump.
"""
@property
def f2c(self):
"""Get a dictionary mapping Fahrenheit to Celsius temperatures for
this device.
Unbelievably, SmartThinQ devices have their own lookup tables
for mapping the two temperature scales. You can get *close* by
using a real conversion between the two temperature scales, but
precise control requires using the custom LUT.
"""
mapping = self.model.value('TempFahToCel').options
return {int(f): c for f, c in mapping.items()}
@property
def c2f(self):
"""Get an inverse mapping from Celsius to Fahrenheit.
Just as unbelievably, this is not exactly the inverse of the
`f2c` map. There are a few values in this reverse mapping that
are not in the other.
"""
mapping = self.model.value('TempCelToFah').options
out = {}
for c, f in mapping.items():
try:
c_num = int(c)
except ValueError:
c_num = float(c)
out[c_num] = f
return out
@property
def supported_operations(self):
"""Get a list of the ACOp Operations the device supports.
"""
mapping = self.model.value('Operation').options
return [ACOp(o) for i, o in mapping.items()]
@property
def supported_on_operation(self):
"""Get the most correct "On" operation the device supports.
:raises ValueError: If ALL_ON is not supported, but there are
multiple supported ON operations. If a model raises this,
its behaviour needs to be determined so this function can
make a better decision.
"""
operations = self.supported_operations
operations.remove(ACOp.OFF)
# This ON operation appears to be supported in newer AC models
if ACOp.ALL_ON in operations:
return ACOp.ALL_ON
# Older models, or possibly just the LP1419IVSM, do not support ALL_ON,
# instead advertising only a single operation of RIGHT_ON.
# Thus, if there's only one ON operation, we use that.
if len(operations) == 1:
return operations[0]
# Hypothetically, the API could return multiple ON operations, neither
# of which are ALL_ON. This will raise in that case, as we don't know
# what that model will expect us to do to turn everything on.
# Or, this code will never actually be reached! We can only hope. :)
raise ValueError(
f"could not determine correct 'on' operation:"
f" too many reported operations: '{str(operations)}'")
def set_celsius(self, c):
"""Set the device's target temperature in Celsius degrees.
"""
self._set_control('TempCfg', c)
def set_fahrenheit(self, f):
"""Set the device's target temperature in Fahrenheit degrees.
"""
self.set_celsius(self.f2c[f])
def set_zones(self, zones):
"""Turn off or on the device's zones.
The `zones` parameter is a list of dicts with these keys:
- "No": The zone index. A string containing a number,
starting from 1.
- "Cfg": Whether the zone is enabled. A string, either "1" or
"0".
- "State": Whether the zone is open. Also "1" or "0".
"""
# Ensure at least one zone is enabled: we can't turn all zones
# off simultaneously.
on_count = sum(int(zone['State']) for zone in zones)
if on_count > 0:
zone_cmd = '/'.join(
'{}_{}'.format(zone['No'], zone['State'])
for zone in zones if zone['Cfg'] == '1'
)
self._set_control('DuctZone', zone_cmd)
def get_zones(self):
"""Get the status of the zones, including whether a zone is
configured.
The result is a list of dicts with the same format as described in
`set_zones`.
"""
return self._get_config('DuctZone')
def set_fan_speed(self, speed):
"""Set the fan speed to a value from the `ACFanSpeed` enum.
"""
speed_value = self.model.enum_value('WindStrength', speed.value)
self._set_control('WindStrength', speed_value)
def set_horz_swing(self, swing):
"""Set the horizontal swing to a value from the `ACHSwingMode` enum.
"""
swing_value = self.model.enum_value('WDirHStep', swing.value)
self._set_control('WDirHStep', swing_value)
def set_vert_swing(self, swing):
"""Set the vertical swing to a value from the `ACVSwingMode` enum.
"""
swing_value = self.model.enum_value('WDirVStep', swing.value)
self._set_control('WDirVStep', swing_value)
def set_mode(self, mode):
"""Set the device's operating mode to an `OpMode` value.
"""
mode_value = self.model.enum_value('OpMode', mode.value)
self._set_control('OpMode', mode_value)
def set_on(self, is_on):
"""Turn on or off the device (according to a boolean).
"""
op = self.supported_on_operation if is_on else ACOp.OFF
op_value = self.model.enum_value('Operation', op.value)
self._set_control('Operation', op_value)
def get_filter_state(self):
"""Get information about the filter."""
return self._get_config('Filter')
def get_mfilter_state(self):
"""Get information about the "MFilter" (not sure what this is).
"""
return self._get_config('MFilter')
def get_energy_target(self):
"""Get the configured energy target data."""
return self._get_config('EnergyDesiredValue')
def get_outdoor_power(self):
"""Get instant power usage in watts of the outdoor unit"""
value = self._get_config('OutTotalInstantPower')
return value['OutTotalInstantPower']
def get_power(self):
"""Get the instant power usage in watts of the whole unit"""
value = self._get_config('InOutInstantPower')
return value['InOutInstantPower']
def get_light(self):
"""Get a Boolean indicating whether the display light is on."""
try:
value = self._get_control('DisplayControl')
return value == '0' # Seems backwards, but isn't.
except FailedRequestError:
# Device does not support reporting display light status.
# Since it's probably not changeable the it must be on.
return True
def get_volume(self):
"""Get the speaker volume level."""
try:
value = self._get_control('SpkVolume')
return int(value)
except FailedRequestError:
return 0 # Device does not support volume control.
def poll(self):
"""Poll the device's current state.
Monitoring must be started first with `monitor_start`. Return
either an `ACStatus` object or `None` if the status is not yet
available.
"""
# Abort if monitoring has not started yet.
if not hasattr(self, 'mon'):
return None
res = self.mon.poll_json()
if res:
return ACStatus(self, res)
else:
return None
class ACStatus(object):
"""Higher-level information about an AC device's current status.
"""
def __init__(self, ac, data):
self.ac = ac
self.data = data
@staticmethod
def _str_to_num(s):
"""Convert a string to either an `int` or a `float`.
Troublingly, the API likes values like "18", without a trailing
".0", for whole numbers. So we use `int`s for integers and
`float`s for non-whole numbers.
"""
f = float(s)
if f == int(f):
return int(f)
else:
return f
@property
def temp_cur_c(self):
return self._str_to_num(self.data['TempCur'])
@property
def temp_cur_f(self):
return self.ac.c2f[self.temp_cur_c]
@property
def temp_cfg_c(self):
return self._str_to_num(self.data['TempCfg'])
@property
def temp_cfg_f(self):
return self.ac.c2f[self.temp_cfg_c]
@property
def mode(self):
return ACMode(lookup_enum('OpMode', self.data, self.ac))
@property
def fan_speed(self):
return ACFanSpeed(lookup_enum('WindStrength', self.data, self.ac))
@property
def horz_swing(self):
return ACHSwingMode(lookup_enum('WDirHStep', self.data, self.ac))
@property
def vert_swing(self):
return ACVSwingMode(lookup_enum('WDirVStep', self.data, self.ac))
@property
def is_on(self):
op = ACOp(lookup_enum('Operation', self.data, self.ac))
return op != ACOp.OFF