Skip to content

Commit

Permalink
Add support for 6 month owner tokens through enlighten API, and batte…
Browse files Browse the repository at this point in the history
…rys through 'Ensemble' API
  • Loading branch information
DanBeard committed Mar 7, 2022
1 parent bd3f65a commit fd6860e
Showing 1 changed file with 91 additions and 8 deletions.
99 changes: 91 additions & 8 deletions envoy_reader/envoy_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
ENDPOINT_URL_PRODUCTION_INVERTERS = "http{}://{}/api/v1/production/inverters"
ENDPOINT_URL_PRODUCTION = "http{}://{}/production"
ENDPOINT_URL_CHECK_JWT = "https://{}/auth/check_jwt"
ENDPOINT_URL_ENSEMBLE_INVENTORY = "http{}://{}/ivp/ensemble/inventory"

# pylint: disable=pointless-string-statement

Expand All @@ -40,6 +41,10 @@
LOGIN_URL = "https://entrez.enphaseenergy.com/login"
TOKEN_URL = "https://entrez.enphaseenergy.com/entrez_tokens"

# paths for the enlighten 6 month owner token
ENLIGHTEN_AUTH_FORM_URL = "https://enlighten.enphaseenergy.com"
ENLIGHTEN_TOKEN_URL = "https://enlighten.enphaseenergy.com/entrez-auth-token?serial_num={}"

_LOGGER = logging.getLogger(__name__)


Expand Down Expand Up @@ -85,6 +90,8 @@ def __init__( # pylint: disable=too-many-arguments
enlighten_site_id=None,
enlighten_serial_num=None,
https_flag="",
use_enlighten_owner_token=False,
token_refresh_buffer_seconds=0
):
"""Init the EnvoyReader."""
self.host = host.lower()
Expand All @@ -97,21 +104,27 @@ def __init__( # pylint: disable=too-many-arguments
self.endpoint_production_v1_results = None
self.endpoint_production_inverters = None
self.endpoint_production_results = None
self.endpoint_ensemble_json_results = None
self.isMeteringEnabled = False # pylint: disable=invalid-name
self._async_client = async_client
self._authorization_header = None
self._cookies = None
self.enlighten_user = enlighten_user
self.enlighten_pass = enlighten_pass
self.commissioned = commissioned
self.enlighten_site_id = enlighten_site_id
self.enlighten_serial_num = enlighten_serial_num
self.https_flag = https_flag
self._token = ""
self.use_enlighten_owner_token = use_enlighten_owner_token
self.token_refresh_buffer_seconds = token_refresh_buffer_seconds

@property
def async_client(self):
"""Return the httpx client."""
return self._async_client or httpx.AsyncClient(verify=False)
return self._async_client or httpx.AsyncClient(verify=False,
headers=self._authorization_header,
cookies=self._cookies)

async def _update(self):
"""Update the data."""
Expand All @@ -129,6 +142,9 @@ async def _update_from_pc_endpoint(self):
await self._update_endpoint(
"endpoint_production_json_results", ENDPOINT_URL_PRODUCTION_JSON
)
await self._update_endpoint(
"endpoint_ensemble_json_results", ENDPOINT_URL_ENSEMBLE_INVENTORY
)

async def _update_from_p_endpoint(self):
"""Update from P endpoint."""
Expand Down Expand Up @@ -168,8 +184,10 @@ async def _async_fetch_with_retry(self, url, **kwargs):
_LOGGER.debug(
"Received 401 from Envoy; refreshing token, attempt %s of 2",
attempt+1,
)
await self._getEnphaseToken()
)
could_refresh_cookies = await self._refresh_token_cookies()
if not could_refresh_cookies:
await self._getEnphaseToken()
continue
_LOGGER.debug("Fetched from %s: %s: %s", url, resp, resp.text)
return resp
Expand All @@ -191,6 +209,33 @@ async def _async_post(self, url, data, cookies=None, **kwargs):
except httpx.TransportError: # pylint: disable=try-except-raise
raise

async def _fetch_owner_token_json(self) :
"""
Try to fetch the owner token json from Enlighten API
:return:
"""
async with self.async_client as client:
# login to the enlighten UI

resp = await client.get(ENLIGHTEN_AUTH_FORM_URL)
soup = BeautifulSoup(resp.text, features="html.parser")
# grab the single use auth token for this form
authenticity_token = soup.find('input', {'name': 'authenticity_token'})["value"]
# and the form action itself
form_action = soup.find('input', {'name': 'authenticity_token'}).parent["action"]
payload_login = {
'authenticity_token': authenticity_token,
'user[email]': self.enlighten_user,
'user[password]': self.enlighten_pass,
}
resp = await client.post(ENLIGHTEN_AUTH_FORM_URL+form_action, data=payload_login)
if resp.status_code >= 400:
raise Exception("Could not Authenticate via Enlighten auth form")

# now that we're in a logged in session, we can request the 6 month owner token via enlighten
resp = await client.get(ENLIGHTEN_TOKEN_URL.format(self.enlighten_serial_num))
return resp.json()

async def _getEnphaseToken( # pylint: disable=invalid-name
self,
):
Expand All @@ -199,10 +244,16 @@ async def _getEnphaseToken( # pylint: disable=invalid-name
"password": self.enlighten_pass,
}

# Login to website and store cookie
resp = await self._async_post(LOGIN_URL, data=payload_login)
if self.use_enlighten_owner_token:
token_json = await self._fetch_owner_token_json()

self._token = token_json["token"]
time_left_days = (token_json["expires_at"] - time.time())/(24*3600)
_LOGGER.debug("Commissioned Token valid for %s days", time_left_days)

if self.commissioned == "True" or self.commissioned == "Commissioned":
elif self.commissioned == "True" or self.commissioned == "Commissioned":
# Login to website and store cookie
resp = await self._async_post(LOGIN_URL, data=payload_login)
payload_token = {
"Site": self.enlighten_site_id,
"serialNum": self.enlighten_serial_num,
Expand All @@ -218,6 +269,8 @@ async def _getEnphaseToken( # pylint: disable=invalid-name
_LOGGER.debug("Commissioned Token: %s", self._token)

else:
# Login to website and store cookie
resp = await self._async_post(LOGIN_URL, data=payload_login)
payload_token = {"uncommissioned": "true", "Site": ""}
response = await self._async_post(
TOKEN_URL, data=payload_token, cookies=resp.cookies
Expand All @@ -228,6 +281,13 @@ async def _getEnphaseToken( # pylint: disable=invalid-name
] # pylint: disable=invalid-name
_LOGGER.debug("Uncommissioned Token: %s", self._token)

await self._refresh_token_cookies()

async def _refresh_token_cookies(self):
"""
Refresh the client's cookie with the token (if valid)
:returns True if cookie refreshed, False if it couldn't be
"""
# Create HTTP Header
self._authorization_header = {"Authorization": "Bearer " + self._token}

Expand All @@ -239,7 +299,14 @@ async def _getEnphaseToken( # pylint: disable=invalid-name
# Parse the HTML return from Envoy and check the text
soup = BeautifulSoup(token_validation_html.text, features="html.parser")
token_validation = soup.find("h2").contents[0]
self._is_enphase_token_valid(token_validation)
if self._is_enphase_token_valid(token_validation) :
# set the cookies for future clients
self._cookies = token_validation_html.cookies
return True

# token not valid if we get here
return False


def _is_enphase_token_valid(self, response):
if response == "Valid token.":
Expand All @@ -254,6 +321,8 @@ def _is_enphase_token_expired(self, token):
token, options={"verify_signature": False}, algorithms="ES256"
)
exp_epoch = decode["exp"]
# allow a buffer so we can try and grab it sooner
exp_epoch -= self.token_refresh_buffer_seconds
exp_time = datetime.datetime.fromtimestamp(exp_epoch)
if datetime.datetime.now() < exp_time:
_LOGGER.debug("Token expires at: %s", exp_time)
Expand Down Expand Up @@ -641,6 +710,11 @@ async def battery_storage(self):
"""percentFull will not be available in the JSON results. The API will"""
"""only return battery data if batteries are installed."""
if "percentFull" not in raw_json["storage"][0].keys():
# "ENCHARGE" batteries are part of the "ENSEMBLE" api instead
# Check to see if it's there. Enphase has too much fun with these names
if self.endpoint_ensemble_json_results is not None:
# TODO: this is a completely different format though. Abstract this somehow?
return self.endpoint_ensemble_json_results.json()[0]["devices"]
return self.message_battery_not_available

return raw_json["storage"][0]
Expand Down Expand Up @@ -709,6 +783,13 @@ def run_in_console(self):
dest="commissioned",
help="Commissioned Envoy (True/False)",
)
parser.add_argument(
"-o",
"--ownertoken",
dest="ownertoken",
help="use the 6 month owner token from enlighten instead of the 1hr entrez token",
action='store_true'
)
parser.add_argument(
"-i",
"--siteid",
Expand All @@ -719,7 +800,7 @@ def run_in_console(self):
"-s",
"--serialnum",
dest="enlighten_serial_num",
help="Enlighten Envoy Serial Numbewr. Only used when Commissioned=True.",
help="Enlighten Envoy Serial Number. Only used when Commissioned=True.",
)
args = parser.parse_args()

Expand Down Expand Up @@ -762,6 +843,7 @@ def run_in_console(self):
enlighten_site_id=args.enlighten_site_id,
enlighten_serial_num=args.enlighten_serial_num,
https_flag=SECURE,
use_enlighten_owner_token=args.ownertoken
)
else:
TESTREADER = EnvoyReader(
Expand All @@ -775,6 +857,7 @@ def run_in_console(self):
enlighten_site_id=args.enlighten_site_id,
enlighten_serial_num=args.enlighten_serial_num,
https_flag=SECURE,
use_enlighten_owner_token=args.ownertoken
)

TESTREADER.run_in_console()

0 comments on commit fd6860e

Please sign in to comment.