1
0
mirror of https://github.com/no2chem/wideq.git synced 2025-05-16 15:20:09 -07:00

Initial thinq2 support, works with LA090HSV5 ac

This commit is contained in:
Michael Wei 2020-11-20 19:43:04 +00:00
parent 267a880340
commit 8617831453
5 changed files with 324 additions and 214 deletions

2
.gitignore vendored
View File

@ -6,3 +6,5 @@ __pycache__
.vscode/ .vscode/
# Ignore Mac system files # Ignore Mac system files
.DS_Store .DS_Store
# Ignore devcontainer for now (should this go in source control?)
.devcontainer

View File

@ -9,6 +9,7 @@ import re
import os.path import os.path
import logging import logging
from typing import List from typing import List
from pprint import pprint
STATE_FILE = "wideq_state.json" STATE_FILE = "wideq_state.json"
LOGGER = logging.getLogger("wideq.example") LOGGER = logging.getLogger("wideq.example")
@ -34,6 +35,13 @@ def ls(client):
print("{0.id}: {0.name} ({0.type.name} {0.model_id})".format(device)) print("{0.id}: {0.name} ({0.type.name} {0.model_id})".format(device))
def info(client, device_id):
"""Dump info on a device."""
device = client.get_device(device_id)
pprint(vars(device), indent=4, width=1)
def gen_mon(client, device_id): def gen_mon(client, device_id):
"""Monitor any other device but AC device, """Monitor any other device but AC device,
displaying generic information about its status. displaying generic information about its status.
@ -51,8 +59,10 @@ def gen_mon(client, device_id):
if data: if data:
try: try:
res = model.decode_monitor(data) res = model.decode_monitor(data)
print(res)
except ValueError: except ValueError:
print("status data: {!r}".format(data)) print("status data: {!r}".format(data))
"""
else: else:
for key, value in res.items(): for key, value in res.items():
try: try:
@ -66,13 +76,9 @@ def gen_mon(client, device_id):
) )
) )
elif isinstance(desc, wideq.RangeValue): elif isinstance(desc, wideq.RangeValue):
print( print('- {0}: {1} ({2.min}-{2.max})'.format(
"- {0}: {1} ({2.min}-{2.max})".format( key, value, desc,
key, )) """
value,
desc,
)
)
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
@ -204,6 +210,7 @@ EXAMPLE_COMMANDS = {
"set-temp-freezer": set_temp_freezer, "set-temp-freezer": set_temp_freezer,
"turn": turn, "turn": turn,
"ac-config": ac_config, "ac-config": ac_config,
"info": info,
} }

View File

@ -155,6 +155,7 @@ class ACOp(enum.Enum):
"""Whether a device is on or off.""" """Whether a device is on or off."""
OFF = "@AC_MAIN_OPERATION_OFF_W" OFF = "@AC_MAIN_OPERATION_OFF_W"
ON = "@AC_MAIN_OPERATION_ON_W" # (single) on
RIGHT_ON = "@AC_MAIN_OPERATION_RIGHT_ON_W" # Right fan only. RIGHT_ON = "@AC_MAIN_OPERATION_RIGHT_ON_W" # Right fan only.
LEFT_ON = "@AC_MAIN_OPERATION_LEFT_ON_W" # Left 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. ALL_ON = "@AC_MAIN_OPERATION_ALL_ON_W" # Both fans (or only fan) on.
@ -202,7 +203,7 @@ class ACDevice(Device):
def supported_operations(self): def supported_operations(self):
"""Get a list of the ACOp Operations the device supports.""" """Get a list of the ACOp Operations the device supports."""
mapping = self.model.value("Operation").options mapping = self.model.value("airState.operation").options
return [ACOp(o) for i, o in mapping.items()] return [ACOp(o) for i, o in mapping.items()]
@property @property
@ -239,7 +240,7 @@ class ACDevice(Device):
def set_celsius(self, c): def set_celsius(self, c):
"""Set the device's target temperature in Celsius degrees.""" """Set the device's target temperature in Celsius degrees."""
self._set_control("TempCfg", c) self._set_control("airState.tempState.target", c)
def set_fahrenheit(self, f): def set_fahrenheit(self, f):
"""Set the device's target temperature in Fahrenheit degrees.""" """Set the device's target temperature in Fahrenheit degrees."""
@ -276,86 +277,80 @@ class ACDevice(Device):
`set_zones`. `set_zones`.
""" """
return self._get_config("DuctZone") # don't have api data for v2 zones, not sure about format
return []
def set_jet_mode(self, jet_opt): def set_jet_mode(self, jet_opt):
"""Set jet mode to a value from the `ACJetMode` enum.""" """Set jet mode to a value from the `ACJetMode` enum."""
jet_opt_value = self.model.enum_value("Jet", jet_opt.value) jet_opt_value = self.model.enum_value(
self._set_control("Jet", jet_opt_value) "airState.wMode.jet", jet_opt.value
)
self._set_control("airState.wMode.jet", jet_opt_value)
def set_fan_speed(self, speed): def set_fan_speed(self, speed):
"""Set the fan speed to a value from the `ACFanSpeed` enum.""" """Set the fan speed to a value from the `ACFanSpeed` enum."""
speed_value = self.model.enum_value("WindStrength", speed.value) speed_value = self.model.enum_value(
self._set_control("WindStrength", speed_value) "airState.windStrength", speed.value
)
self._set_control("airState.windStrength", speed_value)
def set_horz_swing(self, swing): def set_horz_swing(self, swing):
"""Set the horizontal swing to a value from the `ACHSwingMode` enum.""" """Set the horizontal swing to a value from the `ACHSwingMode` enum."""
swing_value = self.model.enum_value("WDirHStep", swing.value) swing_value = self.model.enum_value("airState.wDir.hStep", swing.value)
self._set_control("WDirHStep", swing_value) self._set_control("airState.wDir.hStep", swing_value)
def set_vert_swing(self, swing): def set_vert_swing(self, swing):
"""Set the vertical swing to a value from the `ACVSwingMode` enum.""" """Set the vertical swing to a value from the `ACVSwingMode` enum."""
swing_value = self.model.enum_value("WDirVStep", swing.value) swing_value = self.model.enum_value("airState.wDir.vStep", swing.value)
self._set_control("WDirVStep", swing_value) self._set_control("airState.wDir.vStep", swing_value)
def set_mode(self, mode): def set_mode(self, mode):
"""Set the device's operating mode to an `OpMode` value.""" """Set the device's operating mode to an `OpMode` value."""
mode_value = self.model.enum_value("OpMode", mode.value) mode_value = self.model.enum_value("airState.opMode", mode.value)
self._set_control("OpMode", mode_value) self._set_control("airState.opMode", mode_value)
def set_on(self, is_on): def set_on(self, is_on):
"""Turn on or off the device (according to a boolean).""" """Turn on or off the device (according to a boolean)."""
op = self.supported_on_operation if is_on else ACOp.OFF op = self.supported_on_operation if is_on else ACOp.OFF
op_value = self.model.enum_value("Operation", op.value) op_value = self.model.enum_value("airState.operation", op.value)
self._set_control("Operation", op_value) self._set_control("airState.operation", op_value, command="Operation")
def get_filter_state(self): def get_filter_state(self):
"""Get information about the filter.""" """Get information about the filter."""
return self._get_config("Filter") return self.get_status().filter_state
def get_mfilter_state(self): def get_mfilter_state(self):
"""Get information about the "MFilter" (not sure what this is).""" """Get information about the "MFilter" (not sure what this is)."""
return self._get_config("MFilter") return self.get_status().filter_state_max_time
def get_energy_target(self): def get_energy_target(self):
"""Get the configured energy target data.""" """Get the configured energy target data."""
return self._get_config("EnergyDesiredValue") return self.get_status().energy_on_current
def get_outdoor_power(self): def get_outdoor_power(self):
"""Get instant power usage in watts of the outdoor unit""" """Get instant power usage in watts of the outdoor unit"""
try: return self.get_status().energy_on_current
value = self._get_config("OutTotalInstantPower")
return value["OutTotalInstantPower"]
except InvalidRequestError:
# Device does not support outdoor unit instant power usage
return 0
def get_power(self): def get_power(self):
"""Get the instant power usage in watts of the whole unit""" """Get the instant power usage in watts of the whole unit"""
try: return self.get_status().energy_on_current
value = self._get_config("InOutInstantPower")
return value["InOutInstantPower"]
except InvalidRequestError:
# Device does not support whole unit instant power usage
return 0
def get_light(self): def get_light(self):
"""Get a Boolean indicating whether the display light is on.""" """Get a Boolean indicating whether the display light is on."""
try: try:
value = self._get_control("DisplayControl") return self.get_status().light
return value == "0" # Seems backwards, but isn't.
except FailedRequestError: except FailedRequestError:
# Device does not support reporting display light status. # Device does not support reporting display light status.
# Since it's probably not changeable the it must be on. # Since it's probably not changeable the it must be on.
@ -363,13 +358,15 @@ class ACDevice(Device):
def get_volume(self): def get_volume(self):
"""Get the speaker volume level.""" """Get the speaker volume level."""
try:
value = self._get_control("SpkVolume")
return int(value)
except FailedRequestError:
return 0 # Device does not support volume control. return 0 # Device does not support volume control.
def get_status(self):
"""Get status information
This method retrieves the entire device snapshot....
"""
res = self._get_deviceinfo_from_snapshot()
return ACStatus(self, res)
def poll(self): def poll(self):
"""Poll the device's current state. """Poll the device's current state.
@ -413,7 +410,7 @@ class ACStatus(object):
@property @property
def temp_cur_c(self): def temp_cur_c(self):
return self._str_to_num(self.data["TempCur"]) return self._str_to_num(self.data["airState.tempState.current"])
@property @property
def temp_cur_f(self): def temp_cur_f(self):
@ -421,7 +418,7 @@ class ACStatus(object):
@property @property
def temp_cfg_c(self): def temp_cfg_c(self):
return self._str_to_num(self.data["TempCfg"]) return self._str_to_num(self.data["airState.tempState.target"])
@property @property
def temp_cfg_f(self): def temp_cfg_f(self):
@ -429,23 +426,47 @@ class ACStatus(object):
@property @property
def mode(self): def mode(self):
return ACMode(lookup_enum("OpMode", self.data, self.ac)) return ACMode(lookup_enum("airState.opMode", self.data, self.ac))
@property @property
def fan_speed(self): def fan_speed(self):
return ACFanSpeed(lookup_enum("WindStrength", self.data, self.ac)) return ACFanSpeed(
lookup_enum("airState.windStrength", self.data, self.ac)
)
@property @property
def horz_swing(self): def horz_swing(self):
return ACHSwingMode(lookup_enum("WDirHStep", self.data, self.ac)) return ACHSwingMode(
lookup_enum("airState.wDir.hStep", self.data, self.ac)
)
@property @property
def vert_swing(self): def vert_swing(self):
return ACVSwingMode(lookup_enum("WDirVStep", self.data, self.ac)) return ACVSwingMode(
lookup_enum("airState.wDir.vStep", self.data, self.ac)
)
@property
def filter_state(self):
return self._str_to_num(self.data["airState.filterMngStates.useTime"])
@property
def filter_state_max_time(self):
return self._str_to_num(self.data["airState.filterMngStates.maxTime"])
@property
def energy_on_current(self):
return self._str_to_num(self.data["airState.energy.onCurrent"])
@property
def light(self):
return self._str_to_num(
self.data["airState.lightingState.displayControl"]
)
@property @property
def is_on(self): def is_on(self):
op = ACOp(lookup_enum("Operation", self.data, self.ac)) op = ACOp(lookup_enum("airState.operation", self.data, self.ac))
return op != ACOp.OFF return op != ACOp.OFF
def __str__(self): def __str__(self):

View File

@ -1,6 +1,7 @@
"""A high-level, convenient abstraction for interacting with the LG """A high-level, convenient abstraction for interacting with the LG
SmartThinQ API for most use cases. SmartThinQ API for most use cases.
""" """
from io import UnsupportedOperation
import json import json
import enum import enum
import logging import logging
@ -31,23 +32,29 @@ class Monitor(object):
self.device_id = device_id self.device_id = device_id
def start(self) -> None: def start(self) -> None:
self.work_id = self.session.monitor_start(self.device_id) """Nothing to do for v2"""
# self.work_id = self.session.monitor_start(self.device_id)
def stop(self) -> None: def stop(self) -> None:
self.session.monitor_stop(self.device_id, self.work_id) """Nothing to do for v2"""
# self.session.monitor_stop(self.device_id, self.work_id)
def poll(self) -> Optional[bytes]: def poll(self) -> Dict[str, Any]:
"""Get the current status data (a bytestring) or None if the """Get the current status data (a bytestring) or None if the
device is not yet ready. device is not yet ready.
""" """
# return self.session.monitor_poll(self.device_id, self.work_id)
# in v2, the data is available only in the snapshot,
# getting better info without querying all devices seems to require
# mqtt
devices = self.session.get_devices()
for device in (DeviceInfo(d) for d in devices):
if device.id == self.device_id:
pollDevice = device
if pollDevice is None:
raise core.DeviceNotFoundError()
try: return pollDevice.data["snapshot"]
return self.session.monitor_poll(self.device_id, self.work_id)
except core.MonitorError:
# Try to restart the task.
self.stop()
self.start()
return None
@staticmethod @staticmethod
def decode_json(data: bytes) -> Dict[str, Any]: def decode_json(data: bytes) -> Dict[str, Any]:
@ -60,8 +67,7 @@ class Monitor(object):
decoded status result (or None if status is not available). decoded status result (or None if status is not available).
""" """
data = self.poll() return self.poll()
return self.decode_json(data) if data else None
def __enter__(self) -> "Monitor": def __enter__(self) -> "Monitor":
self.start() self.start()
@ -172,7 +178,11 @@ class Client(object):
if "auth" in state: if "auth" in state:
data = state["auth"] data = state["auth"]
client._auth = core.Auth( client._auth = core.Auth(
client.gateway, data["access_token"], data["refresh_token"] client.gateway,
data["access_token"],
data["refresh_token"],
data["user_number"],
data["oauth_root"],
) )
if "session" in state: if "session" in state:
@ -225,13 +235,9 @@ class Client(object):
to reload the gateway servers and restart the session. to reload the gateway servers and restart the session.
""" """
client = cls( # This operation is no longer supported in v2, the
country=country or core.DEFAULT_COUNTRY, # user no is required.
language=language or core.DEFAULT_LANGUAGE, raise UnsupportedOperation("User number required in v2")
)
client._auth = core.Auth(client.gateway, None, refresh_token)
client.refresh()
return client
def model_info(self, device: "DeviceInfo") -> "ModelInfo": def model_info(self, device: "DeviceInfo") -> "ModelInfo":
"""For a DeviceInfo object, get a ModelInfo object describing """For a DeviceInfo object, get a ModelInfo object describing
@ -284,7 +290,7 @@ class DeviceInfo(object):
@property @property
def model_id(self) -> str: def model_id(self) -> str:
return self.data["modelNm"] return self.data["modelName"]
@property @property
def id(self) -> str: def id(self) -> str:
@ -292,7 +298,7 @@ class DeviceInfo(object):
@property @property
def model_info_url(self) -> str: def model_info_url(self) -> str:
return self.data["modelJsonUrl"] return self.data["modelJsonUri"]
@property @property
def name(self) -> str: def name(self) -> str:
@ -306,7 +312,7 @@ class DeviceInfo(object):
def load_model_info(self): def load_model_info(self):
"""Load JSON data describing the model's capabilities.""" """Load JSON data describing the model's capabilities."""
return requests.get(self.model_info_url).json() return requests.get(self.model_info_url, verify=False).json()
BitValue = namedtuple("BitValue", ["options"]) BitValue = namedtuple("BitValue", ["options"])
@ -333,21 +339,21 @@ class ModelInfo(object):
:raises ValueError: If an unsupported type is encountered. :raises ValueError: If an unsupported type is encountered.
""" """
d = self.data["Value"][name] d = self.data["Value"][name]
if d["type"] in ("Enum", "enum"): if d["data_type"] in ("Enum", "enum"):
return EnumValue(d["option"]) return EnumValue(d["value_mapping"])
elif d["type"] == "Range": elif d["data_type"] == "Range":
return RangeValue( return RangeValue(
d["option"]["min"], d["option"]["min"],
d["option"]["max"], d["option"]["max"],
d["option"].get("step", 1), d["option"].get("step", 1),
) )
elif d["type"].lower() == "bit": elif d["data_type"].lower() == "bit":
bit_values = {opt["startbit"]: opt["value"] for opt in d["option"]} bit_values = {opt["startbit"]: opt["value"] for opt in d["option"]}
return BitValue(bit_values) return BitValue(bit_values)
elif d["type"].lower() == "reference": elif d["data_type"].lower() == "reference":
ref = d["option"][0] ref = d["option"][0]
return ReferenceValue(self.data[ref]) return ReferenceValue(self.data[ref])
elif d["type"].lower() == "string": elif d["data_type"].lower() == "string":
return StringValue(d.get("_comment", "")) return StringValue(d.get("_comment", ""))
else: else:
raise ValueError( raise ValueError(
@ -368,17 +374,17 @@ class ModelInfo(object):
def enum_name(self, key, value): def enum_name(self, key, value):
"""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: if str(int(value)) not in options:
LOGGER.warning( LOGGER.warning(
"Value `%s` for key `%s` not in options: %s. Values from API: " "Value `%s` for key `%s` not in options: %s. Values from API: "
"%s", "%s",
value, str(int(value)),
key, key,
options, options,
self.data["Value"][key]["option"], self.data["Value"][key]["value_mapping"],
) )
return _UNKNOWN return _UNKNOWN
return options[value] return options[str(int(value))]
def reference_name(self, key: str, value: Any) -> Optional[str]: def reference_name(self, key: str, value: Any) -> Optional[str]:
"""Look up the friendly name for an encoded reference value. """Look up the friendly name for an encoded reference value.
@ -418,10 +424,11 @@ class ModelInfo(object):
def decode_monitor(self, data): def decode_monitor(self, data):
"""Decode status data.""" """Decode status data."""
if self.binary_monitor_data: return data
return self.decode_monitor_binary(data) # if self.binary_monitor_data:
else: # return self.decode_monitor_binary(data)
return self.decode_monitor_json(data) # else:
# return self.decode_monitor_json(data)
class Device(object): class Device(object):
@ -440,21 +447,36 @@ class Device(object):
self.device = device self.device = device
self.model: ModelInfo = client.model_info(device) self.model: ModelInfo = client.model_info(device)
def _set_control(self, key, value): def _get_deviceinfo_from_snapshot(self):
# probably should cache the snapshot somehow
devices = self.client.session.get_devices()
for device in (DeviceInfo(d) for d in devices):
if device.id == self.device.id:
pollDevice = device
if pollDevice is None:
raise core.DeviceNotFoundError()
return pollDevice.data["snapshot"]
def _set_control(self, key, value, command="Set", ctrlKey="basicCtrl"):
"""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.device_control(
self.device.id, self.device.id,
{key: value}, {
"ctrlKey": ctrlKey,
"command": command,
"dataKey": key,
"dataValue": value,
},
) )
def _get_config(self, key): def _get_config(self, key, ctrlKey="basicCtrl"):
"""Look up a device's configuration for a given value. """Look up a device's configuration for a given value.
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.device_control(
self.device.id, self.device.id,
key, {"ctrlKey": ctrlKey, "command": "Get", "dataKey": key},
) )
data = base64.b64decode(data).decode("utf8") data = base64.b64decode(data).decode("utf8")
try: try:
@ -470,15 +492,9 @@ class Device(object):
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( # unsupported api in v2- use snapshot or mqtt
self.device.id, # currently just returns empty value
key, return ""
"Control",
)
# The response comes in a funky key/value format: "(key:value)".
_, value = data[1:-1].split(":")
return value
def monitor_start(self): def monitor_start(self):
"""Start monitoring the device's status.""" """Start monitoring the device's status."""

View File

@ -1,6 +1,7 @@
"""A low-level, general abstraction for the LG SmartThinQ API. """A low-level, general abstraction for the LG SmartThinQ API.
""" """
import base64 import base64
from enum import Enum
import uuid import uuid
from urllib.parse import urljoin, urlencode, urlparse, parse_qs from urllib.parse import urljoin, urlencode, urlparse, parse_qs
import hashlib import hashlib
@ -12,18 +13,39 @@ from typing import Any, Dict, List, Tuple
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry from requests.packages.urllib3.util.retry import Retry
GATEWAY_URL = "https://kic.lgthinq.com:46030/api/common/gatewayUriList" GATEWAY_URL = (
APP_KEY = "wideq" "https://route.lgthinq.com:46030/v1/service/application/gateway-uri"
)
SECURITY_KEY = "nuts_securitykey" SECURITY_KEY = "nuts_securitykey"
DATA_ROOT = "lgedmRoot" APP_KEY = "wideq"
DATA_ROOT = "result"
POST_DATA_ROOT = "lgedmRoot"
RETURN_CODE_ROOT = "resultCode"
RETURN_MESSAGE_ROOT = "returnMsg"
SVC_CODE = "SVC202" SVC_CODE = "SVC202"
CLIENT_ID = "LGAO221A02"
OAUTH_SECRET_KEY = "c053c2a6ddeb7ad97cb0eed0dcb31cf8" OAUTH_SECRET_KEY = "c053c2a6ddeb7ad97cb0eed0dcb31cf8"
OAUTH_CLIENT_KEY = "LGAO221A02" OAUTH_CLIENT_KEY = "LGAO221A02"
DATE_FORMAT = "%a, %d %b %Y %H:%M:%S +0000" DATE_FORMAT = "%a, %d %b %Y %H:%M:%S +0000"
DEFAULT_COUNTRY = "US" DEFAULT_COUNTRY = "US"
DEFAULT_LANGUAGE = "en-US" DEFAULT_LANGUAGE = "en-US"
# v2
API_KEY = "VGhpblEyLjAgU0VSVklDRQ=="
# the client id is a SHA512 hash of the phone MFR,MODEL,SERIAL,
# and the build id of the thinq app it can also just be a random
# string, we use the same client id used for oauth
CLIENT_ID = OAUTH_CLIENT_KEY
MESSAGE_ID = "wideq"
SVC_PHASE = "OP"
APP_LEVEL = "PRD"
APP_OS = "ANDROID"
APP_TYPE = "NUTS"
APP_VER = "3.5.1200"
OAUTH_REDIRECT_URI = "https://kr.m.lgaccount.com/login/iabClose"
RETRY_COUNT = 5 # Anecdotally this seems sufficient. RETRY_COUNT = 5 # Anecdotally this seems sufficient.
RETRY_FACTOR = 0.5 RETRY_FACTOR = 0.5
RETRY_STATUSES = (502, 503, 504) RETRY_STATUSES = (502, 503, 504)
@ -129,7 +151,7 @@ def get_list(obj, key: str) -> List[Dict[str, Any]]:
class APIError(Exception): class APIError(Exception):
"""An error reported by the API.""" """An error reported by the API."""
def __init__(self, code, message): def __init__(self, code, message=None):
self.code = code self.code = code
self.message = message self.message = message
@ -159,6 +181,10 @@ class InvalidRequestError(APIError):
"""The server rejected a request as invalid.""" """The server rejected a request as invalid."""
class DeviceNotFoundError:
"""The device couldn't be found."""
class MonitorError(APIError): class MonitorError(APIError):
"""Monitoring a device failed, possibly because the monitoring """Monitoring a device failed, possibly because the monitoring
session failed and needs to be restarted. session failed and needs to be restarted.
@ -185,7 +211,21 @@ API_ERRORS = {
} }
def lgedm_post(url, data=None, access_token=None, session_id=None): class RequestMethod(Enum):
GET = "get"
POST = "post"
def thinq_request(
method,
url,
data=None,
access_token=None,
session_id=None,
user_number=None,
country=DEFAULT_COUNTRY,
language=DEFAULT_LANGUAGE,
):
"""Make an HTTP request in the format used by the API servers. """Make an HTTP request in the format used by the API servers.
In this format, the request POST data sent as JSON under a special In this format, the request POST data sent as JSON under a special
@ -197,30 +237,45 @@ def lgedm_post(url, data=None, access_token=None, session_id=None):
the gateway server data or to start a session. the gateway server data or to start a session.
""" """
headers = { headers = {
"x-thinq-application-key": APP_KEY,
"x-thinq-security-key": SECURITY_KEY,
"Accept": "application/json", "Accept": "application/json",
"x-api-key": API_KEY,
"x-client-id": CLIENT_ID,
"x-country-code": country,
"x-language-code": language,
"x-message-id": MESSAGE_ID,
"x-service-code": SVC_CODE,
"x-service-phase": SVC_PHASE,
"x-thinq-app-level": APP_LEVEL,
"x-thinq-app-os": APP_OS,
"x-thinq-app-type": APP_TYPE,
"x-thinq-app-ver": APP_VER,
} }
if access_token: if access_token:
headers["x-thinq-token"] = access_token headers["x-emp-token"] = access_token
if session_id: if user_number:
headers["x-thinq-jsessionId"] = session_id headers["x-user-no"] = user_number
with retry_session() as session: with retry_session() as session:
res = session.post(url, json={DATA_ROOT: data}, headers=headers) if method == RequestMethod.POST:
out = res.json()[DATA_ROOT] res = session.post(url, json=data, headers=headers)
elif method == RequestMethod.GET:
res = session.get(url, headers=headers)
else:
raise ValueError("Unsupported request method")
out = res.json()
# Check for API errors. # Check for API errors.
if "returnCd" in out: if RETURN_CODE_ROOT in out:
code = out["returnCd"] code = out[RETURN_CODE_ROOT]
if code != "0000": if code != "0000":
message = out["returnMsg"]
if code in API_ERRORS: if code in API_ERRORS:
raise API_ERRORS[code](code, message) raise API_ERRORS[code](code)
else: else:
raise APIError(code, message) raise APIError(code)
return out return out[DATA_ROOT]
def oauth_url(auth_base, country, language): def oauth_url(auth_base, country, language):
@ -228,18 +283,20 @@ def oauth_url(auth_base, country, language):
authenticated session. authenticated session.
""" """
url = urljoin(auth_base, "login/sign_in") url = urljoin(auth_base, "spx/login/signIn")
query = urlencode( query = urlencode(
{ {
"country": country, "country": country,
"language": language, "language": language,
"svcCode": SVC_CODE, "svc_list": SVC_CODE,
"authSvr": "oauth2",
"client_id": CLIENT_ID, "client_id": CLIENT_ID,
"division": "ha", "division": "ha",
"grant_type": "password", "redirect_uri": OAUTH_REDIRECT_URI,
"state": uuid.uuid1().hex,
"show_thirdparty_login": "AMZ,FBK",
} }
) )
return "{}?{}".format(url, query) return "{}?{}".format(url, query)
@ -250,35 +307,37 @@ def parse_oauth_callback(url):
""" """
params = parse_qs(urlparse(url).query) params = parse_qs(urlparse(url).query)
return params["access_token"][0], params["refresh_token"][0] return (
params["oauth2_backend_url"][0],
params["code"][0],
params["user_number"][0],
)
def login(api_root, access_token, country, language): class OAuthGrant(Enum):
"""Use an access token to log into the API and obtain a session and REFRESH_TOKEN = "refresh_token"
return information about the session. AUTHORIZATION_CODE = "authorization_code"
"""
url = urljoin(api_root + "/", "member/login")
data = {
"countryCode": country,
"langCode": language,
"loginType": "EMP",
"token": access_token,
}
return lgedm_post(url, data)
def refresh_auth(oauth_root, refresh_token): def oauth_request(grant, oauth_root, token):
"""Get a new access_token using a refresh_token. """Make an oauth_request with a specific grant type
May raise a `TokenError`. May raise a `TokenError`.
""" """
token_url = urljoin(oauth_root, "/oauth2/token") oauth_path = "/oauth/1.0/oauth2/token"
data = { token_url = urljoin(oauth_root, oauth_path)
"grant_type": "refresh_token", data = {}
"refresh_token": refresh_token,
} if grant == OAuthGrant.REFRESH_TOKEN:
data["grant_type"] = "refresh_token"
data["refresh_token"] = token
elif grant == OAuthGrant.AUTHORIZATION_CODE:
data["code"] = token
data["grant_type"] = "authorization_code"
data["redirect_uri"] = OAUTH_REDIRECT_URI
else:
raise ValueError("Unsupported grant")
# The timestamp for labeling OAuth requests can be obtained # The timestamp for labeling OAuth requests can be obtained
# through a request to the date/time endpoint: # through a request to the date/time endpoint:
@ -289,17 +348,15 @@ def refresh_auth(oauth_root, refresh_token):
# The signature for the requests is on a string consisting of two # The signature for the requests is on a string consisting of two
# parts: (1) a fake request URL containing the refresh token, and (2) # parts: (1) a fake request URL containing the refresh token, and (2)
# the timestamp. # the timestamp.
req_url = ( req_url = "{}?{}".format(oauth_path, urlencode(data))
"/oauth2/token?grant_type=refresh_token&refresh_token=" + refresh_token
)
sig = oauth2_signature( sig = oauth2_signature(
"{}\n{}".format(req_url, timestamp), OAUTH_SECRET_KEY "{}\n{}".format(req_url, timestamp), OAUTH_SECRET_KEY
) )
headers = { headers = {
"lgemp-x-app-key": OAUTH_CLIENT_KEY, "x-lge-appkey": CLIENT_ID,
"lgemp-x-signature": sig, "x-lge-oauth-signature": sig,
"lgemp-x-date": timestamp, "x-lge-oauth-date": timestamp,
"Accept": "application/json", "Accept": "application/json",
} }
@ -307,16 +364,16 @@ def refresh_auth(oauth_root, refresh_token):
res = session.post(token_url, data=data, headers=headers) res = session.post(token_url, data=data, headers=headers)
res_data = res.json() res_data = res.json()
if res_data["status"] != 1: if res.status_code != 200:
raise TokenError() raise TokenError()
return res_data["access_token"]
return res_data
class Gateway(object): class Gateway(object):
def __init__(self, auth_base, api_root, oauth_root, country, language): def __init__(self, auth_base, api_root, country, language):
self.auth_base = auth_base self.auth_base = auth_base
self.api_root = api_root self.api_root = api_root
self.oauth_root = oauth_root
self.country = country self.country = country
self.language = language self.language = language
@ -327,12 +384,12 @@ class Gateway(object):
`country` and `language` are codes, like "US" and "en-US," `country` and `language` are codes, like "US" and "en-US,"
respectively. respectively.
""" """
gw = lgedm_post( gw = thinq_request(
GATEWAY_URL, {"countryCode": country, "langCode": language} RequestMethod.GET,
) GATEWAY_URL,
return cls( {"countryCode": country, "langCode": language},
gw["empUri"], gw["thinqUri"], gw["oauthUri"], country, language
) )
return cls(gw["empUri"], gw["thinq2Uri"], country, language)
def oauth_url(self): def oauth_url(self):
return oauth_url(self.auth_base, self.country, self.language) return oauth_url(self.auth_base, self.country, self.language)
@ -341,7 +398,6 @@ class Gateway(object):
return { return {
"auth_base": self.auth_base, "auth_base": self.auth_base,
"api_root": self.api_root, "api_root": self.api_root,
"oauth_root": self.oauth_root,
"country": self.country, "country": self.country,
"language": self.language, "language": self.language,
} }
@ -351,56 +407,68 @@ class Gateway(object):
return cls( return cls(
data["auth_base"], data["auth_base"],
data["api_root"], data["api_root"],
data["oauth_root"],
data.get("country", DEFAULT_COUNTRY), data.get("country", DEFAULT_COUNTRY),
data.get("language", DEFAULT_LANGUAGE), data.get("language", DEFAULT_LANGUAGE),
) )
class Auth(object): class Auth(object):
def __init__(self, gateway, access_token, refresh_token): def __init__(
self, gateway, access_token, refresh_token, user_number, oauth_root
):
self.gateway = gateway self.gateway = gateway
self.access_token = access_token self.access_token = access_token
self.refresh_token = refresh_token self.refresh_token = refresh_token
self.user_number = user_number
self.oauth_root = oauth_root
@classmethod @classmethod
def from_url(cls, gateway, url): def from_url(cls, gateway, url):
"""Create an authentication using an OAuth callback URL.""" """Create an authentication using an OAuth callback URL."""
access_token, refresh_token = parse_oauth_callback(url) oauth_root, auth_code, user_number = parse_oauth_callback(url)
return cls(gateway, access_token, refresh_token) out = oauth_request(
OAuthGrant.AUTHORIZATION_CODE, oauth_root, auth_code
)
return cls(
gateway,
out["access_token"],
out["refresh_token"],
user_number,
oauth_root,
)
def start_session(self) -> Tuple["Session", List[Dict[str, Any]]]: def start_session(self) -> Tuple["Session", List[Dict[str, Any]]]:
"""Start an API session for the logged-in user. Return the """Start an API session for the logged-in user. Return the
Session object and a list of the user's devices. Session object and a list of the user's devices.
""" """
return Session(self), []
session_info = login(
self.gateway.api_root,
self.access_token,
self.gateway.country,
self.gateway.language,
)
session_id = session_info["jsessionId"]
return Session(self, session_id), get_list(session_info, "item")
def refresh(self): def refresh(self):
"""Refresh the authentication, returning a new Auth object.""" """Refresh the authentication, returning a new Auth object."""
new_access_token = refresh_auth( new_access_token = oauth_request(
self.gateway.oauth_root, self.refresh_token OAuthGrant.REFRESH_TOKEN, self.oauth_root, self.refresh_token
)["access_token"]
return Auth(
self.gateway,
new_access_token,
self.refresh_token,
self.user_number,
self.oauth_root,
) )
return Auth(self.gateway, new_access_token, self.refresh_token)
def serialize(self) -> Dict[str, str]: def serialize(self) -> Dict[str, str]:
return { return {
"access_token": self.access_token, "access_token": self.access_token,
"refresh_token": self.refresh_token, "refresh_token": self.refresh_token,
"user_number": self.user_number,
"oauth_root": self.oauth_root,
} }
class Session(object): class Session(object):
def __init__(self, auth, session_id) -> None: def __init__(self, auth, session_id=None) -> None:
self.auth = auth self.auth = auth
self.session_id = session_id self.session_id = session_id
@ -412,7 +480,30 @@ class Session(object):
""" """
url = urljoin(self.auth.gateway.api_root + "/", path) url = urljoin(self.auth.gateway.api_root + "/", path)
return lgedm_post(url, data, self.auth.access_token, self.session_id) return thinq_request(
RequestMethod.POST,
url,
data,
self.auth.access_token,
user_number=self.auth.user_number,
)
def get(self, path):
"""Make a GET request to the API server.
This is like `lgedm_get`, but it pulls the context for the
request from an active Session.
"""
url = urljoin(self.auth.gateway.api_root + "/", path)
return thinq_request(
RequestMethod.GET,
url,
access_token=self.auth.access_token,
user_number=self.auth.user_number,
country=self.auth.gateway.country,
language=self.auth.gateway.language,
)
def get_devices(self) -> List[Dict[str, Any]]: def get_devices(self) -> List[Dict[str, Any]]:
"""Get a list of devices associated with the user's account. """Get a list of devices associated with the user's account.
@ -420,7 +511,7 @@ class Session(object):
Return a list of dicts with information about the devices. Return a list of dicts with information about the devices.
""" """
return get_list(self.post("device/deviceList"), "item") return get_list(self.get("service/application/dashboard"), "item")
def monitor_start(self, device_id): def monitor_start(self, device_id):
"""Begin monitoring a device's status. """Begin monitoring a device's status.
@ -488,40 +579,13 @@ class Session(object):
}, },
) )
def set_device_controls(self, device_id, values): def device_control(self, device_id, data):
"""Control a device's settings. """Control a device's settings.
`values` is a key/value map containing the settings to update. `values` is a key/value map containing the settings to update.
""" """
return self.post( controlPath = "service/devices/{}/control-sync".format(device_id)
"rti/rtiControl",
{
"cmd": "Control",
"cmdOpt": "Set",
"value": values,
"deviceId": device_id,
"workId": gen_uuid(),
"data": "",
},
)
def get_device_config(self, device_id, key, category="Config"): res = self.post(controlPath, data)
"""Get a device configuration option. return res
The `category` string should probably either be "Config" or
"Control"; the right choice appears to depend on the key.
"""
res = self.post(
"rti/rtiControl",
{
"cmd": category,
"cmdOpt": "Get",
"value": key,
"deviceId": device_id,
"workId": gen_uuid(),
"data": "",
},
)
return res["returnData"]