diff --git a/.devcontainer.json b/.devcontainer.json index 5f7a2f2..96a92bf 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,6 +1,6 @@ { "name": "hacs/default", - "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.11", + "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.12", "postCreateCommand": "scripts/setup", "customizations": { "vscode": { diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt index 6171f89..df88acf 100644 --- a/.github/workflows/constraints.txt +++ b/.github/workflows/constraints.txt @@ -1,8 +1,8 @@ pip==23.2.1 -pre-commit==3.3.3 +pre-commit==4.0.1 black==23.7.0 flake8==6.1.0 reorder-python-imports==3.10.0 -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 dacite==1.8.1 pipreqs==0.4.13 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 4433a4c..372896b 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -12,9 +12,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v4.2.2 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v4.1.0 + uses: crazy-max/ghaction-github-labeler@v5.1.0 with: skip-delete: true diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4a0c9ad..d5e94db 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -11,7 +11,7 @@ on: - cron: "0 0 * * *" env: - DEFAULT_PYTHON: 3.11 + DEFAULT_PYTHON: 3.12 jobs: pre-commit: @@ -19,10 +19,10 @@ jobs: name: Pre-commit steps: - name: Check out the repository - uses: actions/checkout@v3.5.3 + uses: actions/checkout@v4.2.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v5.3.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -44,7 +44,7 @@ jobs: name: HACS steps: - name: Check out the repository - uses: "actions/checkout@v3.5.3" + uses: "actions/checkout@v4.2.2" - name: HACS validation uses: "hacs/action@main" @@ -57,7 +57,7 @@ jobs: name: Hassfest steps: - name: Check out the repository - uses: "actions/checkout@v3.5.3" + uses: "actions/checkout@v4.2.2" - name: Hassfest validation uses: "home-assistant/actions/hassfest@master" diff --git a/README.md b/README.md index f9514dc..e115f36 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,20 @@ ## Installation +### Installation via [HACS](https://hacs.xyz) + +1. [Open HACS](http://homeassistant.local:8123/hacs/dashboard) in your Home Assistant interface. +2. Add this repository as a "Custom Repository" (https://github.com/bentekkie/ha-generac). +3. On the HACs home screen, search for "Generac" and select it. +4. Click the "Download" in bottom right of the page. +5. Restart Home Assistant to apply the changes. +6. Navigate to Configuration > Integrations. +7. Click on the "+ Add Integration" button. +8. Search for "Generac" and select it. +9. Follow the on-screen instructions to complete the setup. + +### Manual Installation + 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). 2. If you do not have a `custom_components` directory (folder) there, you need to create it. 3. In the `custom_components` directory (folder) create a new folder called `generac`. @@ -41,9 +55,9 @@ custom_components/generac/sensor.py custom_components/generac/switch.py ``` -## Configuration is done in the UI +## Configuration - +Configuration is done in the UI. ## Contributions are welcome! diff --git a/custom_components/generac/__init__.py b/custom_components/generac/__init__.py index fe0b336..3a6f74a 100644 --- a/custom_components/generac/__init__.py +++ b/custom_components/generac/__init__.py @@ -46,13 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN][entry.entry_id] = coordinator - for platform in PLATFORMS: - if entry.options.get(platform, True): - coordinator.platforms.append(platform) - hass.async_add_job( - hass.config_entries.async_forward_entry_setup(entry, platform) - ) - + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.add_update_listener(async_reload_entry) return True diff --git a/custom_components/generac/api.py b/custom_components/generac/api.py index c06075d..0e109d3 100644 --- a/custom_components/generac/api.py +++ b/custom_components/generac/api.py @@ -1,4 +1,7 @@ -"""Sample API Client.""" +"""Generac API Client.""" + +# Incorporates changes from https://github.com/bentekkie/ha-generac/pull/140 + import json import logging from typing import Any @@ -8,6 +11,7 @@ from bs4 import BeautifulSoup from dacite import from_dict +from .const import ALLOWED_DEVICES from .models import Apparatus from .models import ApparatusDetail from .models import Item @@ -47,6 +51,13 @@ def __init__( self._session = session self._logged_in = False self.csrf = "" + 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", + } async def async_get_data(self) -> dict[str, Item] | None: """Get data from the API.""" @@ -57,9 +68,9 @@ async def async_get_data(self) -> dict[str, Item] | None: except SessionExpiredException: self._logged_in = False return await self.async_get_data() - return await self.get_generator_data() + return await self.get_device_data() - async def get_generator_data(self): + async def get_device_data(self): apparatuses = await self.get_endpoint("/v2/Apparatus/list") if apparatuses is None: _LOGGER.debug("Could not decode apparatuses response") @@ -70,11 +81,12 @@ async def get_generator_data(self): data: dict[str, Item] = {} for apparatus in apparatuses: apparatus = from_dict(Apparatus, apparatus) - if apparatus.type != 0: + 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}" ) @@ -89,9 +101,11 @@ async def get_generator_data(self): async def get_endpoint(self, endpoint: str): try: - response = await self._session.get( - API_BASE + endpoint, headers={"X-Csrf-Token": self.csrf} - ) + 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 @@ -111,9 +125,13 @@ async def get_endpoint(self, endpoint: str): async def login(self) -> None: """Login to API""" + headers = {**self._headers} + login_response = await ( await self._session.get( - f"{API_BASE}/Auth/SignIn?email={self._username}", allow_redirects=True + f"{API_BASE}/Auth/SignIn?email={self._username}", + headers=headers, + allow_redirects=True, ) ).text() @@ -138,9 +156,12 @@ async def login(self) -> None: ) self.csrf = sign_in_config.csrf + headers = {**self._headers} + headers["X-Csrf-Token"] = sign_in_config.csrf + self_asserted_response = await self._session.post( f"{LOGIN_BASE}/SelfAsserted", - headers={"X-Csrf-Token": sign_in_config.csrf}, + headers=headers, params={ "tx": "StateProperties=" + sign_in_config.transId, "p": "B2C_1A_SignUpOrSigninOnline", @@ -161,6 +182,7 @@ async def login(self) -> None: confirmed_response = await self._session.get( f"{LOGIN_BASE}/api/CombinedSigninAndSignup/confirmed", + headers=headers, params={ "csrf_token": sign_in_config.csrf, "tx": "StateProperties=" + sign_in_config.transId, @@ -197,7 +219,9 @@ async def submit_form(self, response: str) -> bool: form_data.add_field("state", login_state.attrs["value"]) form_data.add_field("code", login_code.attrs["value"]) - login_response = await self._session.post(action, data=form_data) + login_response = await self._session.post( + action, data=form_data, headers=self._headers + ) if login_response.status != 200: raise IOError(f"Bad api login response: {login_response.status}") diff --git a/custom_components/generac/diagnostics.py b/custom_components/generac/diagnostics.py index c80805c..0b9831e 100644 --- a/custom_components/generac/diagnostics.py +++ b/custom_components/generac/diagnostics.py @@ -42,7 +42,7 @@ def redact(data: Any, redact_all: bool): if is_ipv4(data): return "REDACTED_IPV4" if is_ipv6(data): - return "REDACTED_IPV4" + return "REDACTED_IPV6" if redact_all: return "REDACTED" return data @@ -58,7 +58,7 @@ def is_ipv4(s: str): def is_ipv6(s: str): try: - ipaddress.IPv4Network(s) + ipaddress.IPv6Network(s) return True except ValueError: return False diff --git a/custom_components/generac/entity.py b/custom_components/generac/entity.py index 6fe5db6..357458d 100644 --- a/custom_components/generac/entity.py +++ b/custom_components/generac/entity.py @@ -3,7 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION diff --git a/custom_components/generac/image.py b/custom_components/generac/image.py index 3302299..6235336 100644 --- a/custom_components/generac/image.py +++ b/custom_components/generac/image.py @@ -17,7 +17,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ): - """Setup binary_sensor platform.""" + """Setup image platform.""" coordinator: GeneracDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] data = coordinator.data if isinstance(data, dict): diff --git a/custom_components/generac/sensor.py b/custom_components/generac/sensor.py index 7587b0d..d702a09 100644 --- a/custom_components/generac/sensor.py +++ b/custom_components/generac/sensor.py @@ -2,11 +2,10 @@ from datetime import datetime from typing import Type -from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor.const import SensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS -from homeassistant.const import TEMP_FAHRENHEIT +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,7 +19,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ): - """Setup binary_sensor platform.""" + """Setup sensor platform.""" coordinator: GeneracDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] data = coordinator.data if isinstance(data, dict): @@ -61,10 +60,21 @@ def sensors(item: Item) -> list[Type[GeneracEntity]]: return lst +def parseDatetime(rawStr: str) -> datetime: + formats = ["%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z"] + ves: list[ValueError] = [] + for fmt in formats: + try: + return datetime.strptime(rawStr, fmt) + except ValueError as ve: + ves.append(ve) + raise ValueError(f"No known datetime format for raw string {rawStr}") + + class StatusSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - options = [ + _attr_options = [ "Ready", "Running", "Exercising", @@ -73,8 +83,8 @@ class StatusSensor(GeneracEntity, SensorEntity): "Communication Issue", "Unknown", ] - icon = "mdi:power" - device_class = SensorDeviceClass.ENUM + _attr_icon = "mdi:power" + _attr_device_class = SensorDeviceClass.ENUM @property def name(self): @@ -84,24 +94,27 @@ def name(self): @property def native_value(self): """Return the state of the sensor.""" + options = self.options + if options is None: + return None if self.aparatus_detail.apparatusStatus is None: - return self.options[-1] + return options[-1] index = self.aparatus_detail.apparatusStatus - 1 - if index < 0 or index > len(self.options) - 1: - index = len(self.options) - 1 - return self.options[index] + if index < 0 or index > len(options) - 1: + index = len(options) - 1 + return options[index] class DeviceTypeSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - options = [ + _attr_options = [ "Wifi", "Ethernet", "MobileData", "Unknown", ] - device_class = SensorDeviceClass.ENUM + _attr_device_class = SensorDeviceClass.ENUM @property def name(self): @@ -111,24 +124,27 @@ def name(self): @property def native_value(self): """Return the state of the sensor.""" + options = self.options + if options is None: + return None if self.aparatus_detail.deviceType is None: - return self.options[-1] + return options[-1] if self.aparatus_detail.deviceType == "wifi": - return self.options[0] + return options[0] if self.aparatus_detail.deviceType == "eth": - return self.options[1] + return options[1] if self.aparatus_detail.deviceType == "lte": - return self.options[2] + return options[2] if self.aparatus_detail.deviceType == "cdma": - return self.options[2] - return self.options[-1] + return options[2] + return options[-1] class RunTimeSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.DURATION - native_unit_of_measurement = "h" + _attr_device_class = SensorDeviceClass.DURATION + _attr_native_unit_of_measurement = "h" @property def name(self): @@ -152,8 +168,8 @@ def native_value(self): class ProtectionTimeSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.DURATION - native_unit_of_measurement = "h" + _attr_device_class = SensorDeviceClass.DURATION + _attr_native_unit_of_measurement = "h" @property def name(self): @@ -177,7 +193,7 @@ def native_value(self): class ActivationDateSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.TIMESTAMP + _attr_device_class = SensorDeviceClass.TIMESTAMP @property def name(self): @@ -189,15 +205,13 @@ def native_value(self): """Return the state of the sensor.""" if self.aparatus_detail.activationDate is None: return None - return datetime.strptime( - self.aparatus_detail.activationDate, "%Y-%m-%dT%H:%M:%S%z" - ) + return parseDatetime(self.aparatus_detail.activationDate) class LastSeenSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.TIMESTAMP + _attr_device_class = SensorDeviceClass.TIMESTAMP @property def name(self): @@ -209,15 +223,13 @@ def native_value(self): """Return the state of the sensor.""" if self.aparatus_detail.lastSeen is None: return None - return datetime.strptime( - self.aparatus_detail.lastSeen, "%Y-%m-%dT%H:%M:%S.%f%z" - ) + return parseDatetime(self.aparatus_detail.lastSeen) class ConnectionTimeSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.TIMESTAMP + _attr_device_class = SensorDeviceClass.TIMESTAMP @property def name(self): @@ -229,16 +241,14 @@ def native_value(self): """Return the state of the sensor.""" if self.aparatus_detail.connectionTimestamp is None: return None - return datetime.strptime( - self.aparatus_detail.connectionTimestamp, "%Y-%m-%dT%H:%M:%S.%f%z" - ) + return parseDatetime(self.aparatus_detail.connectionTimestamp) class BatteryVoltageSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.VOLTAGE - native_unit_of_measurement = "V" + _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_native_unit_of_measurement = "V" @property def name(self): @@ -262,7 +272,7 @@ def native_value(self): class OutdoorTemperatureSensor(GeneracEntity, SensorEntity): """generac Sensor class.""" - device_class = SensorDeviceClass.TEMPERATURE + _attr_device_class = SensorDeviceClass.TEMPERATURE @property def name(self): @@ -276,10 +286,10 @@ def native_unit_of_measurement(self): or self.aparatus_detail.weather.temperature is None or self.aparatus_detail.weather.temperature.unit is None ): - return TEMP_CELSIUS + return UnitOfTemperature.CELSIUS if "f" in self.aparatus_detail.weather.temperature.unit.lower(): - return TEMP_FAHRENHEIT - return TEMP_CELSIUS + return UnitOfTemperature.FAHRENHEIT + return UnitOfTemperature.CELSIUS @property def native_value(self): diff --git a/custom_components/generac/weather.py b/custom_components/generac/weather.py index 94ab3f7..8a890a1 100644 --- a/custom_components/generac/weather.py +++ b/custom_components/generac/weather.py @@ -3,8 +3,7 @@ from homeassistant.components.weather import WeatherEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS -from homeassistant.const import TEMP_FAHRENHEIT +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,7 +17,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ): - """Setup binary_sensor platform.""" + """Setup weather platform.""" coordinator: GeneracDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] data = coordinator.data if isinstance(data, dict): @@ -163,10 +162,10 @@ def native_temperature_unit(self): or self.aparatus_detail.weather.temperature is None or self.aparatus_detail.weather.temperature.unit is None ): - return TEMP_CELSIUS + return UnitOfTemperature.CELSIUS if "f" in self.aparatus_detail.weather.temperature.unit.lower(): - return TEMP_FAHRENHEIT - return TEMP_CELSIUS + return UnitOfTemperature.FAHRENHEIT + return UnitOfTemperature.CELSIUS @property def native_temperature(self): diff --git a/requirements.txt b/requirements.txt index 63d1186..7d5b2eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -aiohttp==3.8.5 -beautifulsoup4==4.12.2 +aiohttp==3.11.11 +beautifulsoup4==4.12.3 dacite==1.8.1 -homeassistant==2023.8.2 -voluptuous==0.13.1 -pre-commit==3.3.3 -reorder-python-imports==3.10.0 +pre-commit==4.0.1 +reorder-python-imports==3.14.0 +homeassistant==2024.12.5 +voluptuous==0.15.2