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
55 changes: 43 additions & 12 deletions custom_components/generac/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@
Custom integration to integrate generac with Home Assistant.

For more details about this integration, please refer to
https://github.com/binarydev/generac
https://github.com/binarydev/ha-generac
"""

import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryNotReady

from .api import GeneracApiClient
from .const import CONF_PASSWORD
from .const import CONF_SESSION_COOKIE
from .api import InvalidCredentialsException
from .auth import GeneracAuth
from .auth import InvalidGrantError
from .const import CONF_DPOP_PEM
from .const import CONF_REFRESH_TOKEN
from .const import CONF_USERNAME
from .const import DOMAIN
from .const import PLATFORMS
Expand All @@ -29,35 +34,61 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
hass.data.setdefault(DOMAIN, {})
_LOGGER.info(STARTUP_MESSAGE)

username = entry.data.get(CONF_USERNAME, "")
password = entry.data.get(CONF_PASSWORD, "")
session_cookie = entry.data.get(CONF_SESSION_COOKIE, "")
refresh_token = entry.data.get(CONF_REFRESH_TOKEN)
pem_str = entry.data.get(CONF_DPOP_PEM)
email = entry.data.get(CONF_USERNAME)

if not refresh_token or not pem_str:
# Either a fresh v1->v2 migration with stripped data, or
# somehow the credentials were lost. Either way, reauth.
raise ConfigEntryAuthFailed("Missing refresh token or DPoP key")

session = await async_client_session(hass)
client = GeneracApiClient(session, username, password, session_cookie)
try:
auth = GeneracAuth.from_storage(session, refresh_token, pem_str, email=email)
except Exception as ex:
_LOGGER.error("Failed to load stored credentials: %s", ex)
raise ConfigEntryAuthFailed("Stored credentials are unreadable") from ex

async def _persist_rt(new_rt: str) -> None:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_REFRESH_TOKEN: new_rt}
)

auth.set_refresh_token_persist_callback(_persist_rt)

client = GeneracApiClient(session, auth)
coordinator = GeneracDataUpdateCoordinator(hass, client=client, config_entry=entry)
try:
await coordinator.async_config_entry_first_refresh()
except Exception as e:
raise ConfigEntryNotReady from e
except InvalidCredentialsException as ex:
raise ConfigEntryAuthFailed(str(ex)) from ex
except InvalidGrantError as ex:
raise ConfigEntryAuthFailed(str(ex)) from ex
except (ConfigEntryAuthFailed, ConfigEntryNotReady):
# Let HA handle these — the coordinator already raises the right
# one. Wrapping them in ConfigEntryNotReady would mask reauth.
raise
except Exception as ex:
raise ConfigEntryNotReady from ex

if not coordinator.last_update_success:
raise ConfigEntryNotReady

hass.data[DOMAIN][entry.entry_id] = coordinator

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.add_update_listener(async_reload_entry)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
hass.data[DOMAIN].pop(entry.entry_id)

# Defensive default: if a previous reload already popped the
# coordinator (e.g. mid-reconfigure race), don't KeyError.
hass.data[DOMAIN].pop(entry.entry_id, None)
return unloaded


Expand Down
149 changes: 89 additions & 60 deletions custom_components/generac/api.py
Original file line number Diff line number Diff line change
@@ -1,116 +1,145 @@
"""Generac API Client."""
"""Generac MobileLink API client.

The API itself is plain HTTPS + Bearer auth — no DPoP at this layer.
The Bearer token comes from `GeneracAuth`, which mints fresh access
tokens by exercising a DPoP-bound refresh_token against Auth0.

API versioning: `/api/v1`, `/api/v2`, and `/api/v5` were all observed
returning identical payloads for the endpoints we use. The iOS app uses
`/api/v5`; we follow suit for futureproofing.
"""

import json
import logging

import aiohttp
from dacite import from_dict

from .const import ALLOWED_DEVICES
from .auth import GeneracAuth, InvalidGrantError, USER_AGENT_API
from .const import ALLOWED_DEVICES, API_BASE
from .models import Apparatus
from .models import ApparatusDetail
from .models import Item

API_BASE = "https://app.mobilelinkgen.com/api"
LOGIN_BASE = "https://generacconnectivity.b2clogin.com/generacconnectivity.onmicrosoft.com/B2C_1A_MobileLink_SignIn"

TIMEOUT = 10


_LOGGER: logging.Logger = logging.getLogger(__package__)


class InvalidCredentialsException(Exception):
pass
"""Credentials supplied by the user were rejected."""


class SessionExpiredException(Exception):
pass
"""The current access token / refresh token is no longer valid."""


class GeneracApiClient:
"""HTTP client for the MobileLink API.

The client owns the lifetime of the underlying auth handle's access
token but does NOT persist anything; persistence happens at the
ConfigEntry layer in `__init__.py`.
"""

def __init__(
self,
session: aiohttp.ClientSession,
username: str,
password: str,
session_cookie: str,
auth: GeneracAuth,
) -> None:
"""Sample API Client."""
self._username = username
self._password = password
self._session = session
self._session_cookie = session_cookie
self._logged_in = False
self.csrf = ""
# Below is the login fix from https://github.com/bentekkie/ha-generac/pull/140
self._headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
}
self._auth = auth

async def async_get_data(self) -> dict[str, Item] | None:
"""Get data from the API."""
if self._session_cookie:
self._headers["Cookie"] = self._session_cookie
self._logged_in = True
else:
self._logged_in = False
_LOGGER.error("No session cookie provided, cannot login")
raise InvalidCredentialsException("No session cookie provided")
"""Top-level entry point used by the coordinator."""
return await self.get_device_data()

async def get_device_data(self):
apparatuses = await self.get_endpoint("/v2/Apparatus/list")
async def get_device_data(self) -> dict[str, Item] | None:
apparatuses = await self.get_endpoint("/Apparatus/list")
if apparatuses is None:
_LOGGER.debug("Could not decode apparatuses response")
return None
# Decode failure on /Apparatus/list — surface as a poll
# failure rather than treating it as "fleet has zero devices".
raise IOError("Failed to decode /Apparatus/list response")
if not isinstance(apparatuses, list):
_LOGGER.error("Expected list from /v2/Apparatus/list got %s", apparatuses)
raise IOError(
f"Expected list from /Apparatus/list, got {type(apparatuses).__name__}: "
f"{str(apparatuses)[:200]}"
)

data: dict[str, Item] = {}
for apparatus in apparatuses:
apparatus = from_dict(Apparatus, apparatus)
for raw in apparatuses:
try:
apparatus = from_dict(Apparatus, raw)
except Exception as ex:
_LOGGER.warning(
"Skipping malformed apparatus entry: %s (raw=%s)",
ex,
str(raw)[:200],
)
continue
if apparatus.type not in ALLOWED_DEVICES:
_LOGGER.debug(
"Unknown apparatus type %s %s", apparatus.type, apparatus.name
)
continue

detail_json = await self.get_endpoint(
f"/v1/Apparatus/details/{apparatus.apparatusId}"
f"/Apparatus/details/{apparatus.apparatusId}"
)
if detail_json is None:
_LOGGER.debug(
f"Could not decode respose from /v1/Apparatus/details/{apparatus.apparatusId}"
"Could not decode response from /Apparatus/details/%s",
apparatus.apparatusId,
)
continue
try:
detail = from_dict(ApparatusDetail, detail_json)
except Exception as ex:
_LOGGER.warning(
"Skipping apparatus %s due to malformed detail payload: %s",
apparatus.apparatusId,
ex,
)
continue
detail = from_dict(ApparatusDetail, detail_json)
data[str(apparatus.apparatusId)] = Item(apparatus, detail)
return data

async def get_endpoint(self, endpoint: str):
try:
headers = {**self._headers}
if self.csrf:
headers["X-Csrf-Token"] = self.csrf

response = await self._session.get(API_BASE + endpoint, headers=headers)
if response.status == 204:
# no data
return None

if response.status != 200:
raise SessionExpiredException(
"API returned status code: %s " % response.status
)
access_token = await self._auth.ensure_access_token()
except InvalidGrantError as ex:
raise InvalidCredentialsException(str(ex)) from ex

headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"User-Agent": USER_AGENT_API,
}

data = await response.json()
_LOGGER.debug("getEndpoint %s", json.dumps(data))
return data
url = API_BASE + endpoint
try:
async with self._session.get(url, headers=headers) as response:
if response.status == 204:
return None

if response.status == 401:
raise SessionExpiredException(f"API returned 401 for {endpoint}")

if response.status != 200:
body = ""
try:
body = (await response.text())[:200]
except Exception:
pass
raise SessionExpiredException(
f"API returned status code {response.status} for "
f"{endpoint}: {body}"
)

data = await response.json()
_LOGGER.debug("getEndpoint %s", json.dumps(data))
return data
except SessionExpiredException:
raise
except Exception as ex:
raise IOError() from ex
raise IOError(f"GET {url} failed: {type(ex).__name__}: {ex}") from ex
Loading