mirror of
https://github.com/no2chem/wideq.git
synced 2025-05-18 00:00:17 -07:00
Merge pull request #29 from boralyl/feature/add-dryer-device
Add dryer as a supported device.
This commit is contained in:
commit
7ac6ba4c1f
@ -11,8 +11,7 @@ requires = [
|
|||||||
"requests"
|
"requests"
|
||||||
]
|
]
|
||||||
description-file = "README.md"
|
description-file = "README.md"
|
||||||
requires-python = ">=3.4"
|
requires-python = ">=3.5"
|
||||||
|
|
||||||
[tool.flit.metadata.requires-extra]
|
[tool.flit.metadata.requires-extra]
|
||||||
test = [
|
test = [
|
||||||
"responses"
|
"responses"
|
||||||
|
5089
tests/fixtures/client.json
vendored
Normal file
5089
tests/fixtures/client.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
78
tests/test_dryer.py
Normal file
78
tests/test_dryer.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from wideq.client import Client, DeviceInfo
|
||||||
|
from wideq.dryer import (
|
||||||
|
DryerDevice, DryLevel, DryerState, DryerStatus, TempControl, TimeDry)
|
||||||
|
|
||||||
|
|
||||||
|
POLL_DATA = {
|
||||||
|
'Course': '2',
|
||||||
|
'CurrentDownloadCourse': '100',
|
||||||
|
'DryLevel': '3',
|
||||||
|
'Error': '0',
|
||||||
|
'Initial_Time_H': '1',
|
||||||
|
'Initial_Time_M': '11',
|
||||||
|
'LoadItem': '0',
|
||||||
|
'MoreLessTime': '0',
|
||||||
|
'Option1': '0',
|
||||||
|
'Option2': '168',
|
||||||
|
'PreState': '1',
|
||||||
|
'Remain_Time_H': '0',
|
||||||
|
'Remain_Time_M': '54',
|
||||||
|
'SmartCourse': '0',
|
||||||
|
'State': '50',
|
||||||
|
'TempControl': '4',
|
||||||
|
'TimeDry': '0',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DryerStatusTest(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
with open('./tests/fixtures/client.json') as fp:
|
||||||
|
state = json.load(fp)
|
||||||
|
self.client = Client.load(state)
|
||||||
|
self.device_info = DeviceInfo({
|
||||||
|
'alias': 'DRYER',
|
||||||
|
'deviceId': '33330ba80-107d-11e9-96c8-0051ede85d3f',
|
||||||
|
'deviceType': 202,
|
||||||
|
'modelJsonUrl': (
|
||||||
|
'https://aic.lgthinq.com:46030/api/webContents/modelJSON?'
|
||||||
|
'modelName=RV13B6ES_D_US_WIFI&countryCode=WW&contentsId='
|
||||||
|
'JS11260025236447318&authKey=thinq'),
|
||||||
|
'modelNm': 'RV13B6ES_D_US_WIFI',
|
||||||
|
})
|
||||||
|
self.dryer = DryerDevice(self.client, self.device_info)
|
||||||
|
|
||||||
|
def test_properties(self):
|
||||||
|
status = DryerStatus(self.dryer, POLL_DATA)
|
||||||
|
self.assertEqual(self.dryer, status.dryer)
|
||||||
|
self.assertEqual(POLL_DATA, status.data)
|
||||||
|
self.assertEqual(DryerState.DRYING, status.state)
|
||||||
|
self.assertEqual(DryerState.INITIAL, status.previous_state)
|
||||||
|
self.assertEqual(DryLevel.NORMAL, status.dry_level)
|
||||||
|
self.assertTrue(status.is_on)
|
||||||
|
self.assertEqual(54, status.remaining_time)
|
||||||
|
self.assertEqual(71, status.initial_time)
|
||||||
|
self.assertEqual('Towels', status.course)
|
||||||
|
self.assertEqual('Off', status.smart_course)
|
||||||
|
self.assertEqual('No Error', status.error)
|
||||||
|
self.assertEqual(TempControl.MID_HIGH, status.temperature_control)
|
||||||
|
self.assertEqual(TimeDry.OFF, status.time_dry)
|
||||||
|
|
||||||
|
@mock.patch('wideq.client.logging')
|
||||||
|
def test_properties_unknown_enum_value(self, mock_logging):
|
||||||
|
"""
|
||||||
|
This should not raise an error for an invalid enum value and instead
|
||||||
|
use the `UNKNOWN` enum value.
|
||||||
|
"""
|
||||||
|
data = dict(POLL_DATA, State='5000')
|
||||||
|
status = DryerStatus(self.dryer, data)
|
||||||
|
self.assertEqual(DryerState.UNKNOWN, status.state)
|
||||||
|
expected_call = mock.call(
|
||||||
|
'Value `%s` for key `%s` not in options: %s. Values from API: %s',
|
||||||
|
'5000', 'State', mock.ANY, mock.ANY)
|
||||||
|
self.assertEqual(expected_call, mock_logging.warning.call_args)
|
14
wideq/ac.py
14
wideq/ac.py
@ -2,7 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
from .client import Device, Monitor
|
from .client import Device
|
||||||
|
|
||||||
|
|
||||||
class ACMode(enum.Enum):
|
class ACMode(enum.Enum):
|
||||||
@ -174,18 +174,6 @@ class ACDevice(Device):
|
|||||||
value = self._get_control('SpkVolume')
|
value = self._get_control('SpkVolume')
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
def monitor_start(self):
|
|
||||||
"""Start monitoring the device's status."""
|
|
||||||
|
|
||||||
mon = Monitor(self.client.session, self.device.id)
|
|
||||||
mon.start()
|
|
||||||
self.mon = mon
|
|
||||||
|
|
||||||
def monitor_stop(self):
|
|
||||||
"""Stop monitoring the device's status."""
|
|
||||||
|
|
||||||
self.mon.stop()
|
|
||||||
|
|
||||||
def poll(self):
|
def poll(self):
|
||||||
"""Poll the device's current state.
|
"""Poll the device's current state.
|
||||||
|
|
||||||
|
@ -3,14 +3,18 @@ SmartThinQ API for most use cases.
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import enum
|
import enum
|
||||||
|
import logging
|
||||||
import requests
|
import requests
|
||||||
import base64
|
import base64
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from . import core
|
from . import core
|
||||||
|
|
||||||
DEFAULT_COUNTRY = 'US'
|
DEFAULT_COUNTRY = 'US'
|
||||||
DEFAULT_LANGUAGE = 'en-US'
|
DEFAULT_LANGUAGE = 'en-US'
|
||||||
|
#: Represents an unknown enum value.
|
||||||
|
_UNKNOWN = 'Unknown'
|
||||||
|
|
||||||
|
|
||||||
class Monitor(object):
|
class Monitor(object):
|
||||||
@ -349,8 +353,26 @@ class ModelInfo(object):
|
|||||||
"""Look up the friendly enum name for an encoded value.
|
"""Look up the friendly enum name for an encoded value.
|
||||||
"""
|
"""
|
||||||
options = self.value(key).options
|
options = self.value(key).options
|
||||||
|
if value not in options:
|
||||||
|
logging.warning(
|
||||||
|
'Value `%s` for key `%s` not in options: %s. Values from API: '
|
||||||
|
'%s', value, key, options, self.data['Value'][key]['option'])
|
||||||
|
return _UNKNOWN
|
||||||
return options[value]
|
return options[value]
|
||||||
|
|
||||||
|
def reference_name(self, key: str, value: Any) -> Optional[str]:
|
||||||
|
"""Look up the friendly name for an encoded reference value.
|
||||||
|
|
||||||
|
:param key: The referenced key.
|
||||||
|
:param value: The value whose name we want to look up.
|
||||||
|
:returns: The friendly name for the referenced value. If no name
|
||||||
|
can be found None will be returned.
|
||||||
|
"""
|
||||||
|
value = str(value)
|
||||||
|
reference = self.value(key).reference
|
||||||
|
if value in reference:
|
||||||
|
return reference[value]['_comment']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def binary_monitor_data(self):
|
def binary_monitor_data(self):
|
||||||
"""Check that type of monitoring is BINARY(BYTE).
|
"""Check that type of monitoring is BINARY(BYTE).
|
||||||
@ -390,19 +412,16 @@ class Device(object):
|
|||||||
regarding the device.
|
regarding the device.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, client, device):
|
def __init__(self, client: Client, device: DeviceInfo):
|
||||||
"""Create a wrapper for a `DeviceInfo` object associated with a
|
"""Create a wrapper for a `DeviceInfo` object associated with a
|
||||||
`Client`.
|
`Client`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.client = client
|
self.client = client
|
||||||
self.device = device
|
self.device = device
|
||||||
self.model = client.model_info(device)
|
self.model: ModelInfo = client.model_info(device)
|
||||||
|
|
||||||
def _set_control(self, key, value):
|
def _set_control(self, key, value):
|
||||||
"""Set a device's control for `key` to `value`.
|
"""Set a device's control for `key` to `value`."""
|
||||||
"""
|
|
||||||
|
|
||||||
self.client.session.set_device_controls(
|
self.client.session.set_device_controls(
|
||||||
self.device.id,
|
self.device.id,
|
||||||
{key: value},
|
{key: value},
|
||||||
@ -413,7 +432,6 @@ class Device(object):
|
|||||||
|
|
||||||
The response is parsed as base64-encoded JSON.
|
The response is parsed as base64-encoded JSON.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
data = self.client.session.get_device_config(
|
data = self.client.session.get_device_config(
|
||||||
self.device.id,
|
self.device.id,
|
||||||
key,
|
key,
|
||||||
@ -421,9 +439,7 @@ class Device(object):
|
|||||||
return json.loads(base64.b64decode(data).decode('utf8'))
|
return json.loads(base64.b64decode(data).decode('utf8'))
|
||||||
|
|
||||||
def _get_control(self, key):
|
def _get_control(self, key):
|
||||||
"""Look up a device's control value.
|
"""Look up a device's control value."""
|
||||||
"""
|
|
||||||
|
|
||||||
data = self.client.session.get_device_config(
|
data = self.client.session.get_device_config(
|
||||||
self.device.id,
|
self.device.id,
|
||||||
key,
|
key,
|
||||||
@ -433,3 +449,13 @@ class Device(object):
|
|||||||
# The response comes in a funky key/value format: "(key:value)".
|
# The response comes in a funky key/value format: "(key:value)".
|
||||||
_, value = data[1:-1].split(':')
|
_, value = data[1:-1].split(':')
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def monitor_start(self):
|
||||||
|
"""Start monitoring the device's status."""
|
||||||
|
mon = Monitor(self.client.session, self.device.id)
|
||||||
|
mon.start()
|
||||||
|
self.mon = mon
|
||||||
|
|
||||||
|
def monitor_stop(self):
|
||||||
|
"""Stop monitoring the device's status."""
|
||||||
|
self.mon.stop()
|
||||||
|
201
wideq/dryer.py
Normal file
201
wideq/dryer.py
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .client import Device, _UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
class DryerState(enum.Enum):
|
||||||
|
"""The state of the dryer device."""
|
||||||
|
|
||||||
|
COOLING = '@WM_STATE_COOLING_W'
|
||||||
|
END = '@WM_STATE_END_W'
|
||||||
|
ERROR = '@WM_STATE_ERROR_W'
|
||||||
|
DRYING = '@WM_STATE_DRYING_W'
|
||||||
|
INITIAL = '@WM_STATE_INITIAL_W'
|
||||||
|
OFF = '@WM_STATE_POWER_OFF_W'
|
||||||
|
PAUSE = '@WM_STATE_PAUSE_W'
|
||||||
|
RUNNING = '@WM_STATE_RUNNING_W'
|
||||||
|
SMART_DIAGNOSIS = '@WM_STATE_SMART_DIAGNOSIS_W'
|
||||||
|
WRINKLE_CARE = '@WM_STATE_WRINKLECARE_W'
|
||||||
|
UNKNOWN = _UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
class DryLevel(enum.Enum):
|
||||||
|
"""Represents the dry level setting of the dryer."""
|
||||||
|
|
||||||
|
CUPBOARD = '@WM_DRY27_DRY_LEVEL_CUPBOARD_W'
|
||||||
|
DAMP = '@WM_DRY27_DRY_LEVEL_DAMP_W'
|
||||||
|
EXTRA = '@WM_DRY27_DRY_LEVEL_EXTRA_W'
|
||||||
|
IRON = '@WM_DRY27_DRY_LEVEL_IRON_W'
|
||||||
|
LESS = '@WM_DRY27_DRY_LEVEL_LESS_W'
|
||||||
|
MORE = '@WM_DRY27_DRY_LEVEL_MORE_W'
|
||||||
|
NORMAL = '@WM_DRY27_DRY_LEVEL_NORMAL_W'
|
||||||
|
OFF = '-'
|
||||||
|
VERY = '@WM_DRY27_DRY_LEVEL_VERY_W'
|
||||||
|
UNKNOWN = _UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
class DryerError(enum.Enum):
|
||||||
|
"""A dryer error."""
|
||||||
|
|
||||||
|
ERROR_AE = '@WM_US_DRYER_ERROR_AE_W'
|
||||||
|
ERROR_CE1 = '@WM_US_DRYER_ERROR_CE1_W'
|
||||||
|
ERROR_DE4 = '@WM_WW_FL_ERROR_DE4_W'
|
||||||
|
ERROR_DOOR = '@WM_US_DRYER_ERROR_DE_W'
|
||||||
|
ERROR_DRAINMOTOR = '@WM_US_DRYER_ERROR_OE_W'
|
||||||
|
ERROR_EMPTYWATER = '@WM_US_DRYER_ERROR_EMPTYWATER_W'
|
||||||
|
ERROR_F1 = '@WM_US_DRYER_ERROR_F1_W'
|
||||||
|
ERROR_LE1 = '@WM_US_DRYER_ERROR_LE1_W'
|
||||||
|
ERROR_LE2 = '@WM_US_DRYER_ERROR_LE2_W'
|
||||||
|
ERROR_NOFILTER = '@WM_US_DRYER_ERROR_NOFILTER_W'
|
||||||
|
ERROR_NP = '@WM_US_DRYER_ERROR_NP_GAS_W'
|
||||||
|
ERROR_PS = '@WM_US_DRYER_ERROR_PS_W'
|
||||||
|
ERROR_TE1 = '@WM_US_DRYER_ERROR_TE1_W'
|
||||||
|
ERROR_TE2 = '@WM_US_DRYER_ERROR_TE2_W'
|
||||||
|
ERROR_TE5 = '@WM_US_DRYER_ERROR_TE5_W'
|
||||||
|
ERROR_TE6 = '@WM_US_DRYER_ERROR_TE6_W'
|
||||||
|
UNKNOWN = _UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
class TempControl(enum.Enum):
|
||||||
|
"""Represents temperature control setting."""
|
||||||
|
|
||||||
|
OFF = '-'
|
||||||
|
ULTRA_LOW = '@WM_DRY27_TEMP_ULTRA_LOW_W'
|
||||||
|
LOW = '@WM_DRY27_TEMP_LOW_W'
|
||||||
|
MEDIUM = '@WM_DRY27_TEMP_MEDIUM_W'
|
||||||
|
MID_HIGH = '@WM_DRY27_TEMP_MID_HIGH_W'
|
||||||
|
HIGH = '@WM_DRY27_TEMP_HIGH_W'
|
||||||
|
UNKNOWN = _UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
class TimeDry(enum.Enum):
|
||||||
|
"""Represents a timed dry setting."""
|
||||||
|
|
||||||
|
OFF = '-'
|
||||||
|
TWENTY = '20'
|
||||||
|
THIRTY = '30'
|
||||||
|
FOURTY = '40'
|
||||||
|
FIFTY = '50'
|
||||||
|
SIXTY = '60'
|
||||||
|
UNKNOWN = _UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
class DryerDevice(Device):
|
||||||
|
"""A higher-level interface for a dryer."""
|
||||||
|
|
||||||
|
def poll(self) -> Optional['DryerStatus']:
|
||||||
|
"""Poll the device's current state.
|
||||||
|
|
||||||
|
Monitoring must be started first with `monitor_start`.
|
||||||
|
|
||||||
|
:returns: Either a `DryerStatus` instance 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 DryerStatus(self, res)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class DryerStatus(object):
|
||||||
|
"""Higher-level information about a dryer's current status.
|
||||||
|
|
||||||
|
:param dryer: The DryerDevice instance.
|
||||||
|
:param data: JSON data from the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dryer: DryerDevice, data: dict):
|
||||||
|
self.dryer = dryer
|
||||||
|
self.data = data
|
||||||
|
|
||||||
|
def get_bit(self, key: str, index: int) -> str:
|
||||||
|
bit_value = int(self.data[key])
|
||||||
|
bit_index = 2 ** index
|
||||||
|
mode = bin(bit_value & bit_index)
|
||||||
|
if mode == bin(0):
|
||||||
|
return 'OFF'
|
||||||
|
else:
|
||||||
|
return 'ON'
|
||||||
|
|
||||||
|
def _lookup_enum(self, attr: str) -> str:
|
||||||
|
"""Looks up an enum value for the provided attr.
|
||||||
|
|
||||||
|
:param attr: The attribute to lookup in the enum.
|
||||||
|
:returns: The enum value.
|
||||||
|
"""
|
||||||
|
return self.dryer.model.enum_name(attr, self.data[attr])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> DryerState:
|
||||||
|
"""Get the state of the dryer."""
|
||||||
|
return DryerState(self._lookup_enum('State'))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def previous_state(self) -> DryerState:
|
||||||
|
"""Get the previous state of the dryer."""
|
||||||
|
return DryerState(self._lookup_enum('PreState'))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dry_level(self) -> DryLevel:
|
||||||
|
"""Get the dry level."""
|
||||||
|
return DryLevel(self._lookup_enum('DryLevel'))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def temperature_control(self) -> TempControl:
|
||||||
|
"""Get the temperature control setting."""
|
||||||
|
return TempControl(self._lookup_enum('TempControl'))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_dry(self) -> TimeDry:
|
||||||
|
"""Get the time dry setting."""
|
||||||
|
return TimeDry(self._lookup_enum('TimeDry'))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Check if the dryer is on or not."""
|
||||||
|
return self.state != DryerState.OFF
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_time(self) -> int:
|
||||||
|
"""Get the remaining time in minutes."""
|
||||||
|
return (int(self.data['Remain_Time_H']) * 60 +
|
||||||
|
int(self.data['Remain_Time_M']))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initial_time(self) -> int:
|
||||||
|
"""Get the initial time in minutes."""
|
||||||
|
return (
|
||||||
|
int(self.data['Initial_Time_H']) * 60 +
|
||||||
|
int(self.data['Initial_Time_M']))
|
||||||
|
|
||||||
|
def _lookup_reference(self, attr: str) -> str:
|
||||||
|
"""Look up a reference value for the provided attribute.
|
||||||
|
|
||||||
|
:param attr: The attribute to find the value for.
|
||||||
|
:returns: The looked up value.
|
||||||
|
"""
|
||||||
|
value = self.dryer.model.reference_name(attr, self.data[attr])
|
||||||
|
if value is None:
|
||||||
|
return 'Off'
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def course(self) -> str:
|
||||||
|
"""Get the current course."""
|
||||||
|
return self._lookup_reference('Course')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def smart_course(self) -> str:
|
||||||
|
"""Get the current smart course."""
|
||||||
|
return self._lookup_reference('SmartCourse')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error(self) -> str:
|
||||||
|
"""Get the current error."""
|
||||||
|
return self._lookup_reference('Error')
|
Loading…
x
Reference in New Issue
Block a user