Skip to content

Commit

Permalink
fix: unclosed cloud client session (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
bdraco committed Aug 5, 2023
1 parent 018307f commit b46282a
Show file tree
Hide file tree
Showing 2 changed files with 83 additions and 40 deletions.
105 changes: 69 additions & 36 deletions src/pyenphase/auth.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
"""Envoy authentication methods."""

import json
import ssl
from abc import abstractmethod, abstractproperty
from typing import Any

import httpx
import orjson
from tenacity import retry, retry_if_exception_type, wait_random_exponential

from .exceptions import EnvoyAuthenticationError


def create_default_ssl_context() -> ssl.SSLContext:
"""Return an default SSL context."""
return ssl.create_default_context()


_SSL_CONTEXT = create_default_ssl_context()


class EnvoyAuth:
"""Base class for Envoy authentication."""

Expand Down Expand Up @@ -65,43 +76,10 @@ async def setup(self, client: httpx.AsyncClient) -> None:
raise EnvoyAuthenticationError(
"Your firmware requires token authentication, but no envoy serial number was provided to obtain the token."
)

# we require a new client that checks SSL certs
self.cloud_client = httpx.AsyncClient()

# Login to Enlighten to obtain a session ID
data = {
"user[email]": self.cloud_username,
"user[password]": self.cloud_password,
}
req = await self.cloud_client.post(
"https://enlighten.enphaseenergy.com/login/login.json?", data=data
)
if req.status_code != 200:
raise EnvoyAuthenticationError(
"Unable to login to Enlighten to obtain session ID."
)
response = json.loads(req.text)
self._is_consumer = response["is_consumer"]
self._manager_token = response["manager_token"]

# Obtain the token
data = {
"session_id": response["session_id"],
"serial_num": self.envoy_serial,
"username": self.cloud_username,
}
req = await self.cloud_client.post(
"https://entrez.enphaseenergy.com/tokens", json=data
)
if req.status_code != 200:
raise EnvoyAuthenticationError(
"Unable to obtain token for Envoy authentication."
)
self._token = req.text
self._token = await self._obtain_token()

# Verify we have adequate credentials
if not self.token:
if not self._token:
raise EnvoyAuthenticationError(
"Unable to obtain token for Envoy authentication."
)
Expand All @@ -119,6 +97,61 @@ async def setup(self, client: httpx.AsyncClient) -> None:

self._cookies = req.cookies

async def _obtain_token(self) -> None:
"""Obtain the token for Envoy authentication."""
# we require a new client that checks SSL certs
async with httpx.AsyncClient(verify=_SSL_CONTEXT, timeout=10) as cloud_client:
# Login to Enlighten to obtain a session ID
response = await self._post_json_with_cloud_client(
cloud_client,
"https://enlighten.enphaseenergy.com/login/login.json?",
data={
"user[email]": self.cloud_username,
"user[password]": self.cloud_password,
},
)
if response.status_code != 200:
raise EnvoyAuthenticationError(
"Unable to login to Enlighten to obtain session ID: "
f"{response.status_code}: {response.text}"
)
response = orjson.loads(response.text)
self._is_consumer = response["is_consumer"]
self._manager_token = response["manager_token"]

# Obtain the token
response = await self._post_json_with_cloud_client(
cloud_client,
"https://entrez.enphaseenergy.com/tokens",
json={
"session_id": response["session_id"],
"serial_num": self.envoy_serial,
"username": self.cloud_username,
},
)
if response.status_code != 200:
raise EnvoyAuthenticationError(
"Unable to obtain token for Envoy authentication: "
f"{response.status_code}: {response.text}"
)
return response.text

@retry(
retry=retry_if_exception_type(
(httpx.NetworkError, httpx.TimeoutException, httpx.RemoteProtocolError)
),
wait=wait_random_exponential(multiplier=2, max=3),
)
async def _post_json_with_cloud_client(
self,
client: httpx.AsyncClient,
url: str,
data: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
) -> httpx.Response:
"""Post to the Envoy API with the cloud client."""
return await client.post(url, json=json, data=data)

@property
def token(self) -> str:
assert self._token is not None # nosec
Expand Down
18 changes: 14 additions & 4 deletions src/pyenphase/envoy.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,24 @@ class SupportedFeatures(enum.IntFlag):
class Envoy:
"""Class for communicating with an envoy."""

def __init__(self, host: str) -> None:
def __init__(
self,
host: str,
client: httpx.AsyncClient | None = None,
timeout: float | None = None,
) -> None:
"""Initialize the Envoy class."""
# We use our own httpx client session so we can disable SSL verification (Envoys use self-signed SSL certs)
self._client = httpx.AsyncClient(
verify=_NO_VERIFY_SSL_CONTEXT, timeout=TIMEOUT
self._timeout = timeout or TIMEOUT
self._client = client or httpx.AsyncClient(
verify=_NO_VERIFY_SSL_CONTEXT
) # nosec
self.auth: EnvoyAuth | None = None
self._host = host
self._firmware = EnvoyFirmware(self._client, self._host)
self._supported_features: SupportedFeatures = SupportedFeatures(0)
self._production_endpoint: str | None = None
self.data: EnvoyData | None = None

@retry(
retry=retry_if_exception_type(EnvoyFirmwareCheckError),
Expand Down Expand Up @@ -154,6 +161,7 @@ async def request(self, endpoint: str) -> Any:
cookies=self.auth.cookies,
follow_redirects=True,
auth=self.auth.auth,
timeout=self._timeout,
)
return orjson.loads(response.text)

Expand Down Expand Up @@ -256,7 +264,7 @@ async def update(self) -> EnvoyData:
}
raw["inverters"] = inverters_data

return EnvoyData(
data = EnvoyData(
system_production=system_production,
system_consumption=system_consumption,
inverters=inverters,
Expand All @@ -265,3 +273,5 @@ async def update(self) -> EnvoyData:
# avoid dispatching data if nothing has changed.
raw=raw,
)
self.data = data
return data

0 comments on commit b46282a

Please sign in to comment.