1
0
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:
Aaron Godfrey 2019-07-07 19:21:18 -07:00 committed by GitHub
commit 7ac6ba4c1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 5406 additions and 25 deletions

View File

@ -11,8 +11,7 @@ requires = [
"requests"
]
description-file = "README.md"
requires-python = ">=3.4"
requires-python = ">=3.5"
[tool.flit.metadata.requires-extra]
test = [
"responses"

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
View 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)

View File

@ -2,7 +2,7 @@
"""
import enum
from .client import Device, Monitor
from .client import Device
class ACMode(enum.Enum):
@ -174,18 +174,6 @@ class ACDevice(Device):
value = self._get_control('SpkVolume')
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):
"""Poll the device's current state.

View File

@ -3,14 +3,18 @@ SmartThinQ API for most use cases.
"""
import json
import enum
import logging
import requests
import base64
from collections import namedtuple
from typing import Any, Optional
from . import core
DEFAULT_COUNTRY = 'US'
DEFAULT_LANGUAGE = 'en-US'
#: Represents an unknown enum value.
_UNKNOWN = 'Unknown'
class Monitor(object):
@ -349,8 +353,26 @@ class ModelInfo(object):
"""Look up the friendly enum name for an encoded value.
"""
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]
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
def binary_monitor_data(self):
"""Check that type of monitoring is BINARY(BYTE).
@ -390,19 +412,16 @@ class Device(object):
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
`Client`.
"""
self.client = client
self.device = device
self.model = client.model_info(device)
self.model: ModelInfo = client.model_info(device)
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.device.id,
{key: value},
@ -413,7 +432,6 @@ class Device(object):
The response is parsed as base64-encoded JSON.
"""
data = self.client.session.get_device_config(
self.device.id,
key,
@ -421,9 +439,7 @@ class Device(object):
return json.loads(base64.b64decode(data).decode('utf8'))
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(
self.device.id,
key,
@ -433,3 +449,13 @@ class Device(object):
# The response comes in a funky key/value format: "(key:value)".
_, value = data[1:-1].split(':')
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
View 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')