tronikos / opower

A Python library for getting historical and forecasted usage/cost from utilities that use opower.com such as PG&E
Apache License 2.0
54 stars 49 forks source link

Exelon utilities don't work when there are multiple accounts (login to select-account instead of dashboard) #22

Closed paultyng closed 11 months ago

paultyng commented 11 months ago

I'm getting a 400 Bad Request with the following HTML:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>Request Error</title>
    <style>BODY { color: #000000; background-color: white; font-family: Verdana; margin-left: 0px; margin-top: 0px; } #content { margin-left: 30px; font-size: .70em; padding-bottom: 2em; } A:link { color: #336699; font-weight: bold; text-decoration: underline; } A:visited { color: #6699cc; font-weight: bold; text-decoration: underline; } A:active { color: #336699; font-weight: bold; text-decoration: underline; } .heading1 { background-color: #003366; border-bottom: #336699 6px solid; color: #ffffff; font-family: Tahoma; font-size: 26px; font-weight: normal;margin: 0em 0em 10px -20px; padding-bottom: 8px; padding-left: 30px;padding-top: 16px;} pre { font-size:small; background-color: #e5e5cc; padding: 5px; font-family: Courier New; margin-top: 0px; border: 1px #f0f0e0 solid; white-space: pre-wrap; white-space: -pre-wrap; word-wrap: break-word; } table { border-collapse: collapse; border-spacing: 0px; font-family: Verdana;} table th { border-right: 2px white solid; border-bottom: 2px white solid; font-weight: bold; background-color: #cecf9c;} table td { border-right: 2px white solid; border-bottom: 2px white solid; background-color: #e5e5cc;}</style>    
  </head>
  <body>
    <div id="content">
      <p class="heading1">Request Error</p>
      <p>The server encountered an error processing the request. The exception message is 'Object reference not set to an instance of an object.'. See server logs for more details. The exception stack trace is: </p>
      <p>   at Exelon.Web.WebServices.OpowerService.GetOpowerToken() in D:\a\1\s\Exelon.Web.WebServices\Services\OpowerService.svc.cs:line 516
   at SyncInvokeGetOpowerToken(Object , Object[] , Object[] )
   at System.ServiceModel.Dispatcher.SyncMethodInvoker.Invoke(Object instance, Object[] inputs, Object[]&amp; outputs)
   at System.ServiceModel.Dispatcher.DispatchOperationRuntime.InvokeBegin(MessageRpc&amp; rpc)
   at System.ServiceModel.Dispatcher.ImmutableDispatchRuntime.ProcessMessage5(MessageRpc&amp; rpc)
   at System.ServiceModel.Dispatcher.ImmutableDispatchRuntime.ProcessMessage11(MessageRpc&amp; rpc)
   at System.ServiceModel.Dispatcher.MessageRpc.Process(Boolean isOperationContextSet)</p>
    </div>
  </body>
</html>

I compared this to what I see in the browser and I can't see anything specific with the request that jumps out at me immediately. I'm going to go over it more closely and see if I can figure it out but just wanted to post this here if the server side callstack is recognizable to you as something you had seen before.

paultyng commented 11 months ago

I figured out the issue with this, I have multiple BG&E accounts (one from a house I used to live in that is closed, one from my new house), so instead of landing on the dashboard I land on an account selection page. So instead of:

if not resp.request_info.url.path.endswith("dashboard")

I needed

if not resp.request_info.url.path.endswith("dashboard") and not resp.request_info.url.path.endswith("select-account")

I then needed to send a request to set which account I was viewing in the login flow:

            async with session.post(
                "https://"
                + cls.login_domain()
                + "/api/Services/AccountList.svc/ViewAccount",
                json={
                    "accountNumber": account_number,
                },
                headers={"User-Agent": USER_AGENT},
                raise_for_status=True,
            ) as resp:
                result = await resp.text(encoding="utf-8")

I did not see a service request that returned the account numbers in the standard /api/Services/* paths, but there was one in a "/.euapi/mobile/custom/auth/accounts" path. Unfortunately, that didn't seem to easily work, so I'm guessing there is some additional auth or something that would need to take place to get the account number there.

I wonder if it would be easier to just accept the account number as an optional configuration param via home assistant, etc.

paultyng commented 11 months ago

There was an API endpoint that showed the account number was null (/api/Services/MyAccountService.svc/GetSession), so it may be possible to detect this in multi-account scenarios. I'm not sure what the API endpoint shows for a single account, if it already has an accountNumber value or not.

tronikos commented 11 months ago

If you select an account, does the opower access token give you access to all the accounts? If it does, https://github.com/tronikos/opower/pull/2 should already support all customers and you just have to pick the first account.

For a single account /api/Services/MyAccountService.svc/GetSession returns:

{
  "username": "XXX",
  "encryptedUsername": "XXX",
  "accountNumber": "XXX",
  "customerNumber": "XXX",
  "customerName": "XXX",
  "primaryEmail": "XXX",
  "token": "XXX",
  "isResidential": "True",
  "id": "XXX",
  "encryptedUID": "XXX",
  "dc": "c"
}
paultyng commented 11 months ago

Yeah I get:

{
    "username": "XXX",
    "encryptedUsername": "XXX",
    "accountNumber": null,
    "customerNumber": "XXX",
    "customerName": "XXX",
    "primaryEmail": "XXX",
    "token": "XXX",
    "isResidential": "True",
    "id": "XXX",
    "encryptedUID": "XXX",
    "dc": "e"
}

Until I submit the ViewAccount request, then one is filled in.

The demo.py implementation only seems to output a single customer/forecast. I lived in the house/had the old account almost 5 years ago though so it's possible it just doesn't show anything. Maybe if I had two active accounts the fix in #2 would just be working, I'm not sure.

I tried passing the "old" account number to the ViewAccount request, and I ended up with an HTTP Forbidden farther along, so my guess is that I have to set the "new"/"good" account number to get this to work and I won't get the old info in the list.

I am not sure if this is BGE specific, especially the ".euapi" stuff. I was able to get that request to work, it just used a bearer authorization header with the token value from GetSession. I pushed my fix for BGE at least in #25 if you want to take a look.

mperone commented 4 months ago

Re-opening this discussion... a peek at the exelon.py code seems to confirm my experience that when you have multiple accounts, home assistant will only choose the first one to set up. Was this a conscious choice or perhaps an oversight? I would love for the integration to pick up both of my accounts, and I assume anyone with multiple account would as well - or at least be able to choose which account the integration sets up.