Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 149 additions & 27 deletions pyess/aio_ess.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,19 @@

import aiohttp

from pyess.constants import LOGIN_URL, TIMESYNC_URL, GRAPH_TIMESPANS, GRAPH_DEVICES, GRAPH_PARAMS, \
GRAPH_TFORMATS, SWITCH_URL, STATE_URLS, BATT_URL
from pyess.constants import (
LOGIN_URL,
TIMESYNC_URL,
LOGIN_URLS,
TIMESYNC_URLS,
GRAPH_TIMESPANS,
GRAPH_DEVICES,
GRAPH_PARAMS,
GRAPH_TFORMATS,
SWITCH_URL,
STATE_URLS,
BATT_URL,
)


class ESSException(Exception):
Expand All @@ -28,56 +39,167 @@ class ESSAuthException(ESSException):

class ESS:
@classmethod
async def create(cls, name=None, password=None, ip=None):
ess = cls(name, password, ip)
async def create(cls, name=None, password=None, ip=None, session=None):
"""
Create and log in a new ESS client.

:param session: Optional pre-built aiohttp ``ClientSession``.
When supplied (typically by tests using ``aiohttp.test_utils``),
the client uses it instead of creating its own. The caller
is then responsible for closing the session.
"""
ess = cls(name, password, ip, session=session)
await ess._login()
return ess

def __init__(self, name, pw, ip=None):
def __init__(self, name, pw, ip=None, session=None):
self.name = name
self.pw = pw
self.ip = ip
self.logged_in = False
self.auth_key = None
self.session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False),
timeout=aiohttp.ClientTimeout(connect=60,sock_read=60, sock_connect=60, total=180))
# The endpoint actually answered by the device. Set during _login
# and reused on subsequent attempts so we don't re-probe every
# time. Stays None until the first successful login.
self._login_url = None
self._timesync_url = None
if session is not None:
self.session = session
else:
self.session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False),
timeout=aiohttp.ClientTimeout(connect=60,sock_read=60, sock_connect=60, total=180))

async def _login(self, retry=1):
"""
Login to the ESS device. Called by __init__
:return:
Login to the ESS device. Called by __init__.

Tries each candidate login URL in ``pyess.constants.LOGIN_URLS``
in order, sticking with the first one that does not return 404.
This keeps the library working on both the old PMS firmware
(``/v1/user/setting/login``) and the new PMS firmware introduced
somewhere between 10.05.6511 and 10.05.7438
(``/v1/login``).

:raises ESSAuthException: if every candidate URL returns 200 but
the device still reports ``status: password mismatched``.
"""
url = LOGIN_URL.format(self.ip)
logger.info("fetching auth key")
async with self.session.put(url, json={"password": self.pw}) as r:
response_json = await r.json()
if "status" in response_json and response_json["status"] == "password mismatched":
raise ESSAuthException("wrong password")
# r = requests.put(url, json={"password": self.pw}, verify=False, headers={"Content-Type": "application/json"})
auth_key = response_json["auth_key"]
# If a previous successful login already pinned an endpoint, use
# it directly. Otherwise walk the candidate list. We look the
# list up via ``pyess.constants`` on every call so monkey-patches
# in tests (and any future re-ordering at runtime) take effect.
import pyess.constants as _const
if self._login_url is not None:
login_candidates = [self._login_url]
else:
login_candidates = list(_const.LOGIN_URLS)

auth_key = None
for url in login_candidates:
logger.info("fetching auth key via %s", url)
async with self.session.put(url, json={"password": self.pw}) as r:
if r.status == 404:
# Endpoint not implemented on this firmware — try
# the next candidate.
logger.info("login endpoint %s returned 404, trying fallback", url)
continue
try:
response_json = await r.json()
except (JSONDecodeError, ContentTypeError) as e:
raise ESSException(
f"unexpected non-JSON response from {url}: {e!r}"
) from e

# We got a JSON body. If the device says the password is
# wrong, that is a real auth failure (not a URL mismatch) —
# surface it immediately, do not try other URLs.
if (
"status" in response_json
and response_json["status"] == "password mismatched"
):
raise ESSAuthException("wrong password")

# The current LG firmware returns either ``auth_key`` (old
# build) or ``auth`` (new build). Accept both.
auth_key = response_json.get("auth_key") or response_json.get("auth")
if auth_key is None:
raise ESSException(
f"login response from {url} missing auth_key: {response_json!r}"
)

# Pin the working URL for the lifetime of this client.
self._login_url = url
break
else:
# Every candidate returned 404 — the device speaks neither
# old nor new login URL. Surface a clear error instead of
# the historical misleading "wrong password".
raise ESSException(
f"no working login endpoint found; tried: {login_candidates}"
)

# TimeSync is best-effort. Both old and new firmware builds
# answer differently: legacy returns 200, the new PMS 10.05.x
# build simply does not implement the endpoint and answers 404
# to both candidate paths. A 404 here is harmless — the device
# uses its own clock — so we log and continue instead of
# retrying the whole login. Any other failure (5xx, malformed
# body, "status != success") still falls through to the
# historical exponential-backoff retry.
timesync_info = {
"auth_key": auth_key,
"by": "phone",
"date_time": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
"date_time": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()),
}
async with self.session.put(TIMESYNC_URL.format(self.ip), json=timesync_info) as r:
try:
response_json = await r.json()
# rt = requests.put(TIMESYNC_URL.format(self.ip), json=timesync_info, verify=False,
# headers={"Content-Type": "application/json"})

assert response_json['status'] == 'success', response_json
except (JSONDecodeError, ContentTypeError):
if self._timesync_url is not None:
timesync_candidates = [self._timesync_url]
else:
timesync_candidates = list(_const.TIMESYNC_URLS)

for ts_url in timesync_candidates:
logger.info("syncing device time via %s", ts_url)
async with self.session.put(ts_url, json=timesync_info) as r:
if r.status == 404:
# Endpoint not implemented on this firmware build —
# harmless. Try the other candidate, and if that
# also 404s, give up silently.
logger.info(
"timesync endpoint %s returned 404 (not implemented on this firmware)",
ts_url,
)
self._timesync_url = ts_url
continue
try:
response_json = await r.json()
except (JSONDecodeError, ContentTypeError):
time.sleep(retry)
return await self._login(retry=retry * 2)

if response_json.get("status") == "success":
self._timesync_url = ts_url
break
# Unknown / unexpected body shape — keep the legacy
# behaviour and retry the whole login with backoff.
time.sleep(retry)
return await self._login(retry=retry * 2)
else:
# All candidates returned 404. Time sync is unavailable on
# this firmware, but we did get an auth_key, so do not loop:
# just log once and accept the device's own clock.
logger.info(
"no working timesync endpoint found on this firmware "
"(tried: %s); continuing without device time sync",
timesync_candidates,
)
self._timesync_url = None

self.auth_key = auth_key
self.logged_in = True
return auth_key

async def get_graph(self, device: str, timespan: str, date: datetime.datetime):
"""
Get the time series data about a device
:param device: the device in question ``["batt", "load", "pv"]``
:param device: the device in the question ``["batt", "load", "pv"]``
:param timespan: the timespan in question ``["day", "week", "month", "year"]``
:param date: the date specifying the time span. (for week, month and year I guess any date within the timespan
of interest will suffice)
Expand Down
55 changes: 51 additions & 4 deletions pyess/constants.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,61 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
PREFIX = "https://{}/"
LOGIN_URL = f"{PREFIX}v1/user/setting/login"
TIMESYNC_URL = f"{PREFIX}v1/user/setting/timesync"

# --- Login / TimeSync endpoint handling -------------------------------------
#
# Older LG ESS Home firmware (PMS <= 10.05.6511, April 2020) exposes:
# PUT /v1/user/setting/login
# PUT /v1/user/setting/timesync
#
# Newer LG ESS Home firmware (PMS >= 10.05.7438, ~mid 2024 onwards) has
# shortened these to:
# PUT /v1/login
# PUT /v1/timesync
#
# `gluap/pyess` historically hard-coded the old paths. On a current device
# this surfaces as "password mismatched" (the device returns 404 for the
# old URL, which the client mis-interprets as a wrong password).
#
# To stay compatible with both firmware generations, ``aio_ess.ESS`` now
# tries the new URL first and falls back to the old URL on 404. The
# constants below expose the full set of candidate URLs in priority
# order; ``aio_ess`` uses them in that order and sticks with whichever
# one the device actually answers.
#
# See https://github.com/gluap/pyess/issues/37 for the original bug
# report and the device-side verification of the new endpoints.

LOGIN_URL = f"{PREFIX}v1/user/setting/login" # legacy default (kept for back-compat with direct imports)
TIMESYNC_URL = f"{PREFIX}v1/user/setting/timesync" # legacy default

# Order matters: ESS._login walks this list from index 0 onwards, stopping
# at the first URL that does not return HTTP 404. The first element is
# intentionally the *new* URL so that devices on current firmware are
# served without an extra round trip.
LOGIN_URLS = [
f"{PREFIX}v1/login", # new firmware (PMS 10.05.7438+)
f"{PREFIX}v1/user/setting/login", # legacy firmware (PMS <= 10.05.6511)
]

# For TimeSync, the situation is asymmetric: on the new firmware the old
# path 404s *and* the new path 404s as well (the endpoint is simply not
# implemented on the current build). ``aio_ess`` therefore treats a 404
# here as "device has no time sync — skipping is safe" rather than as a
# hard failure, which prevents the tight login-retry loop that used to
# fill Home Assistant logs.
TIMESYNC_URLS = [
f"{PREFIX}v1/timesync", # new firmware (currently 404 — harmless)
f"{PREFIX}v1/user/setting/timesync", # legacy firmware (200)
]
# --- /Login / TimeSync endpoint handling ------------------------------------

STATE_URLS = {
"network": f"{PREFIX}v1/user/setting/network",
"systeminfo": f"{PREFIX}v1/user/setting/systeminfo",
"batt": f"{PREFIX}v1/user/setting/batt",
"home": f"{PREFIX}v1/user/essinfo/home",
"common": f"{PREFIX}v1/user/essinfo/common",
"home": f"{PREFIX}v1/essinfo/home",
"common": f"{PREFIX}v1/essinfo/common",
}
GRAPH_TIMESPANS = {"day", "week", "month", "year"}
GRAPH_DEVICES = {"batt", "load", "pv"}
Expand Down
Loading