Skip to content
Merged
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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Click this button to skip steps 1 and 2 below: [![Open your Home Assistant insta
5. Once Home Assistant comes back online, go to Settings -> Integrations
6. Click the `Add Integration` button
7. Search the list for `generac` and select it
8. Enter the credentials you use to login for https://app.mobilelinkgen.com/ and submit the form
8. Enter the credentials you use to login for https://app.mobilelinkgen.com/ and submit the form. As an alternative, if username/password do not work for you, follow the instructions below for Cookie-based authentication.
9. The integration should initialize and begin pulling your device information within seconds

## Installation (without HACS)
Expand All @@ -44,6 +44,30 @@ Click this button to skip steps 1 and 2 below: [![Open your Home Assistant insta

## Configuration is done in the UI

## Cookie-based Authentication

Using Username+Password to login is currently broken, due to Generac blocking automated scripts from logging in on the MobileLink app via a Captcha. Instead, the recommended method of authentication is to manually login and retrieve your session cookie. This requires you to do the following:

1. Log into https://app.mobilelinkgen.com/ until you reach the main dashboard with your devices.
2. Open the web-browser Developer Tools aka "devtools" (e.g. in Chrome, right-click the page and hit the Inspect option).
3. Go to the Network tab in the devtools panel and refresh your browser.
4. The Network tab will now have a long list of things it just loaded, but the one you care about is named "dashboard" and should be the first or one of the first items in the list, as seen [here](./setup_instructions/network-tab.png). Select it.
5. Select the "dashboard" item in the list, and it will open a panel to the right-hand side that shows you more details, which looks like [this](./setup_instructions/network-tab-dashboard.jpg).
6. Scroll down in that right-hand panel until you find a field named Cookie, which has a LARGE block of text (it will likely start with the letters "incap_ses", like [this](./setup_instructions/cookie.png)). That large block of text is what you want.
7. Double click the large block of text to select it all.
8. Copy-paste it into the "Session Cookie" field for the Generac setup UI in Home Assistant.
9. Hit submit and enjoy your integration!

> [!IMPORTANT]
>
> ## Unusual Integration Setup
>
> Status Quo in summer 2025: This integration requires an unusual setup process to be able to access the data of your Generac devices. This is because Generac has changed (once again) the original authentication workflow to actively block third-party access. They state that Home Assistant users were overloading their API. We have since adjusted accordingly to adapt to the new authenatication method and reduced our default polling interval from every 30 seconds to every 120 seconds, to be a "good" user of the API and cut the volume of traffic to their servers by 75%. This polling interval can also be tuned to your needs in the options panel of the HA integration once you have set it up, as seen [here](./setup_instructions/options.png).
>
> This approach implies that when Generac is going to change something in their non-public/undocumented API, it's quite likely that the integration will break instantly.
>
> **It's impossible to predict** when this will happen, but **I will try** to keep the integration up-to-date and working **as long as possible**.

<!---->

## Contributions are welcome!
Expand Down
2 changes: 1 addition & 1 deletion _test_local_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async def main():
jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False)
async with aiohttp.ClientSession(cookie_jar=jar) as session:
api = GeneracApiClient(
os.environ["GENERAC_USER"], os.environ["GENERAC_PASS"], session
session, os.environ["GENERAC_USER"], os.environ["GENERAC_PASS"]
)
await api.login()
device_data = await api.get_device_data()
Expand Down
6 changes: 4 additions & 2 deletions custom_components/generac/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from .api import GeneracApiClient
from .const import CONF_PASSWORD
from .const import CONF_SESSION_COOKIE
from .const import CONF_USERNAME
from .const import DOMAIN
from .const import PLATFORMS
Expand All @@ -28,11 +29,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
hass.data.setdefault(DOMAIN, {})
_LOGGER.info(STARTUP_MESSAGE)

username = entry.data.get(CONF_USERNAME, "")
username = entry.data.get(CONF_USERNAME, "generac")
password = entry.data.get(CONF_PASSWORD, "")
session_cookie = entry.data.get(CONF_SESSION_COOKIE, "")

session = await async_client_session(hass)
client = GeneracApiClient(username, password, session)
client = GeneracApiClient(session, username, password, session_cookie)

coordinator = GeneracDataUpdateCoordinator(hass, client=client, config_entry=entry)
await coordinator.async_config_entry_first_refresh()
Expand Down
12 changes: 10 additions & 2 deletions custom_components/generac/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,17 @@ def get_setting_json(page: str) -> Mapping[str, Any] | None:

class GeneracApiClient:
def __init__(
self, username: str, password: str, session: aiohttp.ClientSession
self,
session: aiohttp.ClientSession,
username: str,
password: str,
session_cookie: str,
) -> None:
"""Sample API Client."""
self._username = username
self._passeword = 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
Expand All @@ -60,7 +65,10 @@ def __init__(
async def async_get_data(self) -> dict[str, Item] | None:
"""Get data from the API."""
try:
if not self._logged_in:
if self._session_cookie:
self._headers["Cookie"] = self._session_cookie
self._logged_in = True
elif not self._logged_in:
await self.login()
self._logged_in = True
except SessionExpiredException:
Expand Down
20 changes: 14 additions & 6 deletions custom_components/generac/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .api import InvalidCredentialsException
from .const import CONF_OPTIONS
from .const import CONF_PASSWORD
from .const import CONF_SESSION_COOKIE
from .const import CONF_USERNAME
from .const import DOMAIN
from .utils import async_client_session
Expand Down Expand Up @@ -36,11 +37,13 @@ async def async_step_user(self, user_input=None):

if user_input is not None:
error = await self._test_credentials(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
user_input.get(CONF_USERNAME, ""),
user_input.get(CONF_PASSWORD, ""),
user_input.get(CONF_SESSION_COOKIE, ""),
)
if error is None:
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
title=user_input.get(CONF_USERNAME, "generac"), data=user_input
)
else:
self._errors["base"] = error
Expand All @@ -59,16 +62,20 @@ async def _show_config_form(self, user_input): # pylint: disable=unused-argumen
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
{
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Optional(CONF_SESSION_COOKIE): str,
}
),
errors=self._errors,
)

async def _test_credentials(self, username, password):
async def _test_credentials(self, username, password, session_cookie):
"""Return true if credentials is valid."""
try:
session = await async_client_session(self.hass)
client = GeneracApiClient(username, password, session)
client = GeneracApiClient(session, username, password, session_cookie)
await client.async_get_data()
return None
except InvalidCredentialsException as e: # pylint: disable=broad-except
Expand Down Expand Up @@ -115,5 +122,6 @@ async def async_step_user(self, user_input=None):
async def _update_options(self):
"""Update config entry options."""
return self.async_create_entry(
title=self.config_entry.data.get(CONF_USERNAME), data=self.options
title=self.config_entry.data.get(CONF_USERNAME, "generac"),
data=self.options,
)
3 changes: 2 additions & 1 deletion custom_components/generac/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

# Defaults
DEFAULT_NAME = DOMAIN
DEFAULT_SCAN_INTERVAL = 30
DEFAULT_SCAN_INTERVAL = 120

# Platforms
BINARY_SENSOR = "binary_sensor"
Expand All @@ -39,6 +39,7 @@
CONF_ENABLED = "enabled"
CONF_USERNAME = "username"
CONF_PASSWORD = "password"
CONF_SESSION_COOKIE = "session_cookie"
CONF_SCAN_INTERVAL = "scan_interval"

# Options
Expand Down
6 changes: 3 additions & 3 deletions custom_components/generac/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ def name(self):
@property
def native_value(self):
"""Return the state of the sensor."""
val = get_prop_value(self.aparatus_detail.properties, 70, 0)
val = get_prop_value(self.aparatus_detail.properties, 71, 0)
if isinstance(val, str):
val = float(val)
return val
Expand All @@ -217,7 +217,7 @@ def name(self):
@property
def native_value(self):
"""Return the state of the sensor."""
val = get_prop_value(self.aparatus_detail.properties, 31, 0)
val = get_prop_value(self.aparatus_detail.properties, 32, 0)
if isinstance(val, str):
val = float(val)
return val
Expand Down Expand Up @@ -294,7 +294,7 @@ def name(self):
@property
def native_value(self):
"""Return the state of the sensor."""
val = get_prop_value(self.aparatus_detail.properties, 69, 0)
val = get_prop_value(self.aparatus_detail.properties, 70, 0)
if isinstance(val, str):
val = float(val)
return val
Expand Down
3 changes: 2 additions & 1 deletion custom_components/generac/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"description": "If you need help with the configuration have a look here: https://github.com/binarydev/ha-generac",
"data": {
"username": "Username",
"password": "Password"
"password": "Password",
"session_cookie": "Session Cookie (optional alternative to username/password)"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion custom_components/generac/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"description": "Si vous avez besoin d'aide pour la configuration, regardez ici: https://github.com/binarydev/ha-generac",
"data": {
"username": "Identifiant",
"password": "Mot de Passe"
"password": "Mot de Passe",
"session_cookie": "Cookie de session (alternative optionnelle à l'identifiant/mot de passe)"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion custom_components/generac/translations/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"description": "Hvis du trenger hjep til konfigurasjon ta en titt her: https://github.com/binarydev/ha-generac",
"data": {
"username": "Brukernavn",
"password": "Passord"
"password": "Passord",
"session_cookie": "Sesjonskake (valgfritt alternativ til brukernavn/passord)"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion custom_components/generac/translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"description": "Se precisar de ajuda com a configuração, consulte: https://github.com/binarydev/ha-generac",
"data": {
"username": "Nome de utilizador",
"password": "Palavra-passe"
"password": "Palavra-passe",
"session_cookie": "Cookie de sessão (alternativa opcional ao nome de utilizador/palavra-passe)"
}
}
},
Expand Down
Binary file added setup_instructions/cookie.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added setup_instructions/network-tab-dashboard.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added setup_instructions/network-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added setup_instructions/options.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions test_local_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# This script is a standalone test for the Generac API client.
# It logs in to the Generac API using credentials from environment variables
# and prints the device data in JSON format using a custom JSON encoder.
# Make sure to set the environment variables GENERAC_USER and GENERAC_PASS before running this script
# to avoid hardcoding sensitive information in the script.
# This script is intended to be run outside of Home Assistant, for testing purposes.
# It uses the aiohttp library for asynchronous HTTP requests and custom JSON encoding for dataclasses.
# Ensure you have aiohttp installed in your environment.
# If you're using Home Assistant, aiohttp is already included.
# You can install it using pip: pip install aiohttp
import asyncio
import dataclasses
import json
import logging
import os

import aiohttp # type: ignore
from custom_components.generac.api import GeneracApiClient

logging.basicConfig(level=logging.DEBUG)


# Your custom JSON encoder
class EnhancedJSONEncoder(json.JSONEncoder):
def default(self, o):
if dataclasses.is_dataclass(o):
return dataclasses.asdict(o)
return super().default(o)


# Async main logic
async def main():
jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False)
async with aiohttp.ClientSession(cookie_jar=jar) as session:
api = GeneracApiClient(
session, os.environ["GENERAC_USER"], os.environ["GENERAC_PASS"]
)
await api.login()
device_data = await api.get_device_data()
print(json.dumps(device_data, cls=EnhancedJSONEncoder))


# Run it using asyncio.run (preferred method in Python 3.7+)
if __name__ == "__main__":
asyncio.run(main())
6 changes: 4 additions & 2 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ async def test_api_flow():
)

async with aiohttp.ClientSession() as session:
client = GeneracApiClient("test-username", "test-password", session)
client = GeneracApiClient(
session, "test-username", "test-password", "test-session-cookie"
)
data = await client.async_get_data()
assert data is not None
assert data["12345"].apparatus.apparatusId == 12345
# This is a bit of a hack, but we don't have the ApparatusDetail model fully defined for this test
# assert data["12345"].detail.raw == {"key": "value"}
assert client.csrf == "test-csrf"
# assert client.csrf == "test-csrf"
2 changes: 2 additions & 0 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ async def test_form(hass: HomeAssistant) -> None:
{
"username": "test-username",
"password": "test-password",
"session_cookie": "test-session-cookie",
},
)
await hass.async_block_till_done()
Expand All @@ -37,5 +38,6 @@ async def test_form(hass: HomeAssistant) -> None:
assert result2["data"] == {
"username": "test-username",
"password": "test-password",
"session_cookie": "test-session-cookie",
}
assert len(mock_setup_entry.mock_calls) == 1
45 changes: 45 additions & 0 deletions tests/test_local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# This script is a standalone test for the Generac API client.
# It logs in to the Generac API using credentials from environment variables
# and prints the device data in JSON format using a custom JSON encoder.
# Make sure to set the environment variables GENERAC_USER and GENERAC_PASS before running this script
# to avoid hardcoding sensitive information in the script.
# This script is intended to be run outside of Home Assistant, for testing purposes.
# It uses the aiohttp library for asynchronous HTTP requests and custom JSON encoding for dataclasses.
# Ensure you have aiohttp installed in your environment.
# If you're using Home Assistant, aiohttp is already included.
# You can install it using pip: pip install aiohttp
import asyncio
import dataclasses
import json
import logging
import os

import aiohttp # type: ignore
from custom_components.generac.api import GeneracApiClient

logging.basicConfig(level=logging.DEBUG)


# Your custom JSON encoder
class EnhancedJSONEncoder(json.JSONEncoder):
def default(self, o):
if dataclasses.is_dataclass(o):
return dataclasses.asdict(o)
return super().default(o)


# Async main logic
async def main():
jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False)
async with aiohttp.ClientSession(cookie_jar=jar) as session:
api = GeneracApiClient(
session, os.environ["GENERAC_USER"], os.environ["GENERAC_PASS"]
)
await api.login()
device_data = await api.get_device_data()
print(json.dumps(device_data, cls=EnhancedJSONEncoder))


# Run it using asyncio.run (preferred method in Python 3.7+)
if __name__ == "__main__":
asyncio.run(main())
6 changes: 3 additions & 3 deletions tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ def get_mock_item(
apparatusDetail=ApparatusDetail(
apparatusStatus=status,
properties=[
MagicMock(type=70, value=run_time),
MagicMock(type=31, value=protection_time),
MagicMock(type=69, value=battery_voltage),
MagicMock(type=71, value=run_time),
MagicMock(type=32, value=protection_time),
MagicMock(type=70, value=battery_voltage),
],
activationDate=activation_date,
lastSeen=last_seen,
Expand Down