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" "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

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 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.

View File

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