Skip to content

Commit

Permalink
add 2FA (#105)
Browse files Browse the repository at this point in the history
* 📝 add 2FA documentation

Signed-off-by: Ludy87 <[email protected]>

* Update en.json

* Update de.json

* Update strings.json

* Update manifest.json

* Update __init__.py

* Update config_flow.py

* Update const.py

* Update const_schema.py

* Update coordinator.py

* Update entitys.py

* Update sensor.py

* Update sensor.py

* Update hacs.json

---------

Signed-off-by: Ludy87 <[email protected]>
  • Loading branch information
Ludy87 authored Sep 14, 2023
1 parent ade6cd7 commit 273be68
Show file tree
Hide file tree
Showing 20 changed files with 182 additions and 44 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,17 @@ ista EcoTrend should now appear as a card under the HA Integrations page with "C

---

## Two-factor authentication (experimental)

- [Two-factor authentication deutsch](TWO_FACTOR_AUTHENTICATION_DE.md)
- [Two-factor authentication english](TWO_FACTOR_AUTHENTICATION_EN.md)

---

## Debug

```yaml
logger:
logs:
custom_components.ecotrend_ista: debug
```
---
40 changes: 40 additions & 0 deletions TWO_FACTOR_AUTHENTICATION_DE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Two-factor authentication

## Steps

### aktivier debugging <https://github.com/Ludy87/ecotrend-ista#debug>

### Gehe zu <https://ecotrend.ista.de/> und logge dich ein. Danach auf Menü, wähle "Benutzerkonto" und scrolle zu "Zwei-Stufen-Authentifizierung" und klicke auf "Hinzufügen"

![Two-factor authentication 1](./image/two_factor_authentication_1.png)

### Klicke auf "Hat der Scan nicht funktioniert?" unter dem QR-Code

![Two-factor authentication 2](./image/two_factor_authentication_2.png)

### Bei Punkt 2 wird der Schlüssel angezeigt der kopiert werden muss (am besten aufschreiben wird zweimal benötigt)

![Two-factor authentication 3](./image/two_factor_authentication_3.png)

### erstell nun einen OTP Sensor in Home-Assistant und startet Home-Assistant neu

<https://www.home-assistant.io/integrations/otp#configuration>

```yaml
# Example configuration.yaml entry
sensor:
- platform: otp
token: KEY_FROM_ECOTREND
```
### Schreibe einen Namen in "Gerätename" bevor du den Code von OTP Sensor kopierst, danach kopierst du den generierten Code und fügst ihn ein (Achtung, der Code ist nur 30sec gültig)
![Two-factor authentication 4](./image/two_factor_authentication_4.png)
### Jetzt kann die Integration [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://img.shields.io/badge/My-HACS:%20REPOSITORY-000000.svg?&style=for-the-badge&logo=home-assistant&logoColor=white&color=049cdb)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Ludy87&repository=ecotrend-ista&category=integration) angelegt werden
### gebe die Daten ein
![Two-factor authentication 5](./image/two_factor_authentication_5.png)
### Sollte etwas nicht funktionieren, lassen sie 30 minuten vergehen, bis sie es wiederholen - Ista hat eine Bruceforce detektion
40 changes: 40 additions & 0 deletions TWO_FACTOR_AUTHENTICATION_EN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Two-factor authentication

## Steps

### enable debugging <https://github.com/Ludy87/ecotrend-ista#debug>

### Go to <https://ecotrend.ista.de/> and log in. After that, click on the menu, select "Benutzerkonto" and scroll down to "Zwei-Stufen-Authentifizierung" then click on "Hinzufügen"

![Two-factor authentication 1](./image/two_factor_authentication_1.png)

### Click on "Hat der Scan nicht funktioniert?" below the QR code

![Two-factor authentication 2](./image/two_factor_authentication_2.png)

### In step 2, the key will be displayed, which needs to be copied (it's best to write it down, as it will be required twice)

![Two-factor authentication 3](./image/two_factor_authentication_3.png)

### Now create an OTP sensor in Home-Assistant and restart Home-Assistant

<https://www.home-assistant.io/integrations/otp#configuration>

```yaml
# Example configuration.yaml entry
sensor:
- platform: otp
token: KEY_FROM_ECOTREND
```
### Write a name in the "Gerätename" before copying the code from the OTP sensor; then, paste the generated code (Note: the code is only valid for 30 seconds)
![Two-factor authentication 4](./image/two_factor_authentication_4.png)
### Now the integration can be set up [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://img.shields.io/badge/My-HACS:%20REPOSITORY-000000.svg?&style=for-the-badge&logo=home-assistant&logoColor=white&color=049cdb)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Ludy87&repository=ecotrend-ista&category=integration)
### Enter the data
![Two-factor authentication 5](./image/two_factor_authentication_5.png)
### If something doesn't work, wait for 30 minutes before trying again - Ista has a brute force detection
2 changes: 1 addition & 1 deletion custom_components/ecotrend_ista/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""" ista EcoTrend Version 2 """
"""ista EcoTrend Version 2."""
from __future__ import annotations

import logging
Expand Down
45 changes: 32 additions & 13 deletions custom_components/ecotrend_ista/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Config flow for ista EcoTrend Version 2."""
from __future__ import annotations

import copy
import logging
from typing import Any

import voluptuous as vol
from pyecotrend_ista.exception_classes import LoginError
from pyecotrend_ista.pyecotrend_ista import PyEcotrendIsta
import requests
import voluptuous as vol

from homeassistant import config_entries, core
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
Expand All @@ -17,46 +19,57 @@
NumberSelectorMode,
)

from .const import CONF_UPDATE_INTERVAL, CONF_URL, DOMAIN, MANUFACTURER
from .const import CONF_MFA, CONF_UPDATE_INTERVAL, CONF_URL, DOMAIN, MANUFACTURER
from .const_schema import DATA_SCHEMA_EMAIL, URL_SELECTOR

_LOGGER = logging.getLogger(__name__)


@staticmethod
@core.callback
def login_account(hass: core.HomeAssistant, data, demo: bool = False) -> PyEcotrendIsta:
def login_account(hass: core.HomeAssistant, data: dict, demo: bool = False) -> PyEcotrendIsta:
"""Log into an Ecotrend-Ista account and return an account instance."""
account = PyEcotrendIsta(
email=data.get(CONF_EMAIL, None),
password=data.get(CONF_PASSWORD, None),
logger=_LOGGER,
hass_dir=(hass.config.path("custom_components/ecotrend_ista") if demo else None),
totp=data.get(CONF_MFA, "").replace(" ", ""),
session=requests.Session(),
)

return account


async def validate_input(hass: core.HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA_EMAIL with values provided by the user.
"""
""" # noqa: D205
if CONF_URL not in data or data[CONF_URL] != "de_url":
raise NotSupportedURL()

# pylint: disable=no-value-for-parameter
try:
vol.Email()(data.get(CONF_EMAIL))
except vol.Invalid as error:
raise vol.Invalid(error)
raise vol.Invalid(error) from error

account = login_account(hass, data)

login_info = None
try:
login_info = await account.login()
login_info = await hass.async_add_executor_job(account.login)
except LoginError as error:
_LOGGER.error(error)
raise LoginError(error) from error
except requests.ConnectionError as error:
_LOGGER.error(error)
raise requests.ConnectionError from error
except requests.ReadTimeout as error:
_LOGGER.error(error)
raise requests.ReadTimeout from error
except requests.Timeout as error:
_LOGGER.error(error)
raise requests.Timeout from error

return {
"title": f"{MANUFACTURER} {account.getSupportCode()} {'' if not login_info or login_info != 'Demo' else login_info}"
Expand Down Expand Up @@ -87,7 +100,6 @@ async def async_step_user(self, user_input=None):
async def async_step_german(self, user_input: dict[str, Any] | None = None) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}

if user_input is not None:
unique_id = f"{user_input[CONF_EMAIL]}"
await self.async_set_unique_id(unique_id)
Expand All @@ -103,13 +115,20 @@ async def async_step_german(self, user_input: dict[str, Any] | None = None) -> F
errors["base"] = "not_allowed"
except vol.Invalid:
errors["base"] = "no_email"
except requests.ConnectionError:
errors["base"] = "cannot_connect"
except (requests.ReadTimeout, requests.Timeout):
errors["base"] = "timeout_connect"
except Exception: # pylint: disable=broad-except
errors["base"] = "unknown"

if info:
return self.async_create_entry(
title=info["title"],
data={
CONF_EMAIL: user_input[CONF_EMAIL],
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_MFA: user_input.get(CONF_MFA, "").replace(" ", ""),
},
options={
CONF_URL: user_input[CONF_URL],
Expand All @@ -128,23 +147,23 @@ async def async_step_german(self, user_input: dict[str, Any] | None = None) -> F
async def async_step_import(self, import_data: dict[str, Any]):
"""Import ista EcoTrend Version 2 config from configuration.yaml."""

_import_data = import_data
_import_data = copy.deepcopy(import_data)
_import_data[CONF_PASSWORD] = "*****"
_LOGGER.debug("Starting import of sensor from configuration.yaml - %s", _import_data)
if import_data is None:
_LOGGER.error(import_data)
return self.async_abort(reason="No configuration to import.")
self._async_abort_entries_match({CONF_EMAIL: import_data[CONF_EMAIL]})
# Verarbeite die importierten Konfigurationsdaten weiter
import_data[CONF_URL] = "de_url"
import_data[CONF_UPDATE_INTERVAL] = 24
import_data[CONF_MFA] = ""
_LOGGER.debug("Starting import of sensor from configuration.yaml - %s", _import_data)
return await self.async_step_german(import_data)


def validate_options_input(user_input: dict[str, Any]) -> dict[str, str]:
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
"""Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user."""

errors = {}
if CONF_URL not in user_input or user_input[CONF_URL] != "de_url":
errors["base"] = "not_allowed"
Expand Down
2 changes: 2 additions & 0 deletions custom_components/ecotrend_ista/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
CONF_YEAR = "year"
# Deprecated config

CONF_MFA = "mfa_code"

CONF_URL: Final = "URL"
CONF_UPDATE_INTERVAL: Final = "update_interval"

Expand Down
12 changes: 11 additions & 1 deletion custom_components/ecotrend_ista/const_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@
TextSelectorType,
)

from .const import CONF_UNIT, CONF_UNIT_HEATING, CONF_UNIT_WARMWATER, CONF_UPDATE_INTERVAL, CONF_URL, CONF_YEAR, CONF_YEARMONTH
from .const import (
CONF_MFA,
CONF_UNIT,
CONF_UNIT_HEATING,
CONF_UNIT_WARMWATER,
CONF_UPDATE_INTERVAL,
CONF_URL,
CONF_YEAR,
CONF_YEARMONTH,
)

URL_SELECTORS = {
"de_url": "https://ecotrend.ista.de/",
Expand All @@ -37,6 +46,7 @@
vol.Required(CONF_EMAIL): TextSelector(TextSelectorConfig(type=TextSelectorType.EMAIL, multiline=False)),
vol.Required(CONF_PASSWORD): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD, multiline=False)),
vol.Required(CONF_URL, default="de_url"): URL_SELECTOR,
vol.Optional(CONF_MFA, default=""): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=False)),
vol.Required(CONF_UPDATE_INTERVAL, default=24): NumberSelector(
NumberSelectorConfig(mode=NumberSelectorMode.SLIDER, min=1, max=24)
),
Expand Down
28 changes: 15 additions & 13 deletions custom_components/ecotrend_ista/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
"""Coordinator for ista EcoTrend Version 2"""
"""Coordinator for ista EcoTrend Version 2."""
from __future__ import annotations

import asyncio
import datetime
from datetime import timedelta
import json
import logging
import os
from datetime import timedelta
from typing import Any

from pyecotrend_ista.helper_object_de import CustomRaw
from pyecotrend_ista.pyecotrend_ista import PyEcotrendIsta
import requests

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
Expand All @@ -23,17 +23,18 @@


async def create_directory_file(hass: HomeAssistant, consum_raw: CustomRaw, support_code: str):
paths = [
hass.config.path("www"),
]
"""Create a directory and a file with JSON content."""
paths = [hass.config.path("www")]

def mkdir() -> None:
"""Create directories if they do not exist."""
for path in paths:
if not os.path.exists(path):
_LOGGER.debug("Creating directory: %s", path)
os.makedirs(path, exist_ok=True)

def make_file() -> None:
"""Create a JSON file with the data."""
file_name = f"{DOMAIN}_{support_code}.json"
media_path = hass.config.path("www")
json_object = json.dumps(consum_raw.to_dict(), indent=4)
Expand All @@ -60,9 +61,8 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
update_interval=timedelta(hours=self._entry.options.get(CONF_UPDATE_INTERVAL, 24)),
)

async def set_controller(self) -> None:
"""
Set up the PyEcotrendIsta controller.
def set_controller(self) -> None:
"""Set up the PyEcotrendIsta controller.
This method initializes the PyEcotrendIsta controller instance with the provided email, password,
and other necessary configurations.
Expand All @@ -72,14 +72,16 @@ async def set_controller(self) -> None:

async def init(self) -> None:
"""Initialize the controller and perform the login."""
await self.set_controller()
await self.controller.login()
self.set_controller()
await self.hass.async_add_executor_job(self.controller.login)

async def _async_update_data(self):
"""Update the data from ista EcoTrend Version 2."""
try:
await self.init()
_consum_raw: dict[str, Any] = await self.controller.consum_raw(select_year=[datetime.datetime.now().year])
_consum_raw: dict[str, Any] = await self.hass.async_add_executor_job(
self.controller.consum_raw, [datetime.datetime.now().year]
)
if not isinstance(_consum_raw, dict):
return self.data
consum_raw: CustomRaw = CustomRaw.from_dict(_consum_raw)
Expand All @@ -88,5 +90,5 @@ async def _async_update_data(self):
self.data = consum_raw
self.async_set_updated_data(self.data)
return self.data
except asyncio.TimeoutError:
except requests.Timeout:
pass
6 changes: 5 additions & 1 deletion custom_components/ecotrend_ista/entitys.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from dataclasses import dataclass
from typing import Literal

from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription, SensorStateClass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfVolume

from .const import (
Expand Down
7 changes: 4 additions & 3 deletions custom_components/ecotrend_ista/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/Ludy87/ecotrend-ista/issues",
"requirements": [
"pyecotrend_ista==2.1.1",
"marshmallow-enum"
"pyecotrend_ista==2.2.7",
"pyotp==2.8.0",
"marshmallow-enum==1.5.1"
],
"version": "v2.1.2"
"version": "v2.1.3"
}
Loading

0 comments on commit 273be68

Please sign in to comment.