diff --git a/README.md b/README.md index b02acee..dcdf66d 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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! diff --git a/_test_local_access.py b/_test_local_access.py index 9cc715a..b6a280c 100644 --- a/_test_local_access.py +++ b/_test_local_access.py @@ -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() diff --git a/custom_components/generac/__init__.py b/custom_components/generac/__init__.py index 1cc0317..21e5348 100644 --- a/custom_components/generac/__init__.py +++ b/custom_components/generac/__init__.py @@ -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 @@ -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() diff --git a/custom_components/generac/api.py b/custom_components/generac/api.py index 32ddbad..6e4595d 100644 --- a/custom_components/generac/api.py +++ b/custom_components/generac/api.py @@ -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 @@ -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: diff --git a/custom_components/generac/config_flow.py b/custom_components/generac/config_flow.py index a75d5c8..8948a9a 100644 --- a/custom_components/generac/config_flow.py +++ b/custom_components/generac/config_flow.py @@ -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 @@ -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 @@ -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 @@ -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, ) diff --git a/custom_components/generac/const.py b/custom_components/generac/const.py index a4ea1bd..0b93990 100644 --- a/custom_components/generac/const.py +++ b/custom_components/generac/const.py @@ -26,7 +26,7 @@ # Defaults DEFAULT_NAME = DOMAIN -DEFAULT_SCAN_INTERVAL = 30 +DEFAULT_SCAN_INTERVAL = 120 # Platforms BINARY_SENSOR = "binary_sensor" @@ -39,6 +39,7 @@ CONF_ENABLED = "enabled" CONF_USERNAME = "username" CONF_PASSWORD = "password" +CONF_SESSION_COOKIE = "session_cookie" CONF_SCAN_INTERVAL = "scan_interval" # Options diff --git a/custom_components/generac/sensor.py b/custom_components/generac/sensor.py index 4f972ab..a88dd34 100644 --- a/custom_components/generac/sensor.py +++ b/custom_components/generac/sensor.py @@ -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 @@ -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 @@ -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 diff --git a/custom_components/generac/translations/en.json b/custom_components/generac/translations/en.json index b77708a..ea20cad 100644 --- a/custom_components/generac/translations/en.json +++ b/custom_components/generac/translations/en.json @@ -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)" } } }, diff --git a/custom_components/generac/translations/fr.json b/custom_components/generac/translations/fr.json index 805516a..49d1e05 100644 --- a/custom_components/generac/translations/fr.json +++ b/custom_components/generac/translations/fr.json @@ -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)" } } }, diff --git a/custom_components/generac/translations/nb.json b/custom_components/generac/translations/nb.json index 7258a42..615d57a 100644 --- a/custom_components/generac/translations/nb.json +++ b/custom_components/generac/translations/nb.json @@ -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)" } } }, diff --git a/custom_components/generac/translations/pt.json b/custom_components/generac/translations/pt.json index bb0cf86..e105f20 100644 --- a/custom_components/generac/translations/pt.json +++ b/custom_components/generac/translations/pt.json @@ -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)" } } }, diff --git a/setup_instructions/cookie.png b/setup_instructions/cookie.png new file mode 100644 index 0000000..93835ba Binary files /dev/null and b/setup_instructions/cookie.png differ diff --git a/setup_instructions/network-tab-dashboard.jpg b/setup_instructions/network-tab-dashboard.jpg new file mode 100644 index 0000000..341ceb4 Binary files /dev/null and b/setup_instructions/network-tab-dashboard.jpg differ diff --git a/setup_instructions/network-tab.png b/setup_instructions/network-tab.png new file mode 100644 index 0000000..9656cf2 Binary files /dev/null and b/setup_instructions/network-tab.png differ diff --git a/setup_instructions/options.png b/setup_instructions/options.png new file mode 100644 index 0000000..8fdbb8d Binary files /dev/null and b/setup_instructions/options.png differ diff --git a/test_local_access.py b/test_local_access.py new file mode 100644 index 0000000..b6a280c --- /dev/null +++ b/test_local_access.py @@ -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()) diff --git a/tests/test_api.py b/tests/test_api.py index 82c146d..77c9580 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 3d20bd7..ba48c1c 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -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() @@ -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 diff --git a/tests/test_local.py b/tests/test_local.py new file mode 100644 index 0000000..b6a280c --- /dev/null +++ b/tests/test_local.py @@ -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()) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 8fa775e..59be2bf 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -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,