mirror of
https://github.com/no2chem/wideq.git
synced 2025-05-17 15:50:10 -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"
|
||||
]
|
||||
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
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
|
||||
|
||||
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.
|
||||
|
||||
|
@ -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
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