From 8dcd3f8e4fbc0ecd5addae95f5c621ab624161d0 Mon Sep 17 00:00:00 2001 From: Geert Meersman Date: Sun, 9 Apr 2023 17:11:47 +0200 Subject: [PATCH] First basic sensors --- .ruff.toml | 48 +++ SECURITY.md | 2 +- VERSION | 1 + custom_components/nexxtmove/__init__.py | 135 ++++++++ custom_components/nexxtmove/client.py | 312 ++++++++++++++++++ custom_components/nexxtmove/config_flow.py | 209 ++++++++++++ custom_components/nexxtmove/const.py | 57 ++++ custom_components/nexxtmove/entity.py | 87 +++++ custom_components/nexxtmove/exceptions.py | 37 +++ custom_components/nexxtmove/manifest.json | 13 + custom_components/nexxtmove/models.py | 38 +++ custom_components/nexxtmove/sensor.py | 137 ++++++++ .../nexxtmove/translations/en.json | 57 ++++ .../nexxtmove/translations/nl.json | 56 ++++ custom_components/nexxtmove/utils.py | 73 ++++ hacs.json | 5 + requirements.txt | 4 + setup.cfg | 62 ++++ 18 files changed, 1332 insertions(+), 1 deletion(-) create mode 100644 .ruff.toml create mode 100644 VERSION create mode 100644 custom_components/nexxtmove/__init__.py create mode 100644 custom_components/nexxtmove/client.py create mode 100644 custom_components/nexxtmove/config_flow.py create mode 100644 custom_components/nexxtmove/const.py create mode 100644 custom_components/nexxtmove/entity.py create mode 100644 custom_components/nexxtmove/exceptions.py create mode 100644 custom_components/nexxtmove/manifest.json create mode 100644 custom_components/nexxtmove/models.py create mode 100644 custom_components/nexxtmove/sensor.py create mode 100644 custom_components/nexxtmove/translations/en.json create mode 100644 custom_components/nexxtmove/translations/nl.json create mode 100644 custom_components/nexxtmove/utils.py create mode 100644 hacs.json create mode 100644 requirements.txt create mode 100644 setup.cfg diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..2578793 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,48 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py310" + +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T20", # flake8-print + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +[flake8-pytest-style] +fixture-parentheses = false + +[pyupgrade] +keep-runtime-typing = true + +[mccabe] +max-complexity = 40 diff --git a/SECURITY.md b/SECURITY.md index 6b62bc2..44f99cb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,5 +2,5 @@ ## Reporting a Vulnerability -If you found a vulnerability or suspect one to be present, please encode it [here](https://github.com/geertmeersman/telenet/security/advisories/new) +If you found a vulnerability or suspect one to be present, please encode it [here](https://github.com/geertmeersman/nexxtmove/security/advisories/new) Each vulnerability will be treated with the needed care and priority diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..45c7a58 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v0.0.1 diff --git a/custom_components/nexxtmove/__init__.py b/custom_components/nexxtmove/__init__.py new file mode 100644 index 0000000..83dd21b --- /dev/null +++ b/custom_components/nexxtmove/__init__.py @@ -0,0 +1,135 @@ +"""Nexxtmove integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import UpdateFailed +from requests.exceptions import ConnectionError + +from .client import NexxtmoveClient +from .const import _LOGGER +from .const import COORDINATOR_UPDATE_INTERVAL +from .const import DOMAIN +from .const import PLATFORMS +from .exceptions import NexxtmoveException +from .exceptions import NexxtmoveServiceException +from .models import NexxtmoveItem +from .utils import log_debug + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Nexxtmove from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + client = NexxtmoveClient( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + + dev_reg = dr.async_get(hass) + hass.data[DOMAIN][entry.entry_id] = coordinator = NexxtmoveDataUpdateCoordinator( + hass, + config_entry_id=entry.entry_id, + dev_reg=dev_reg, + client=client, + ) + + await coordinator.async_config_entry_first_refresh() + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class NexxtmoveDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for Nexxtmove.""" + + data: list[NexxtmoveItem] + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry_id: str, + dev_reg: dr.DeviceRegistry, + client: NexxtmoveClient, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=COORDINATOR_UPDATE_INTERVAL, + ) + self._config_entry_id = config_entry_id + self._device_registry = dev_reg + self.client = client + self.hass = hass + + async def _async_update_data(self) -> dict | None: + """Update data.""" + try: + items = await self.hass.async_add_executor_job( + self.client.fetch_data + ) + except ConnectionError as exception: + raise UpdateFailed(f"ConnectionError {exception}") from exception + except NexxtmoveServiceException as exception: + raise UpdateFailed(f"NexxtmoveServiceException {exception}") from exception + except NexxtmoveException as exception: + raise UpdateFailed(f"NexxtmoveException {exception}") from exception + except Exception as exception: + raise UpdateFailed(f"Exception {exception}") from exception + + items: list[NexxtmoveItem] = items + + + current_items = { + list(device.identifiers)[0][1] + for device in dr.async_entries_for_config_entry( + self._device_registry, self._config_entry_id + ) + } + + if len(items) > 0: + fetched_items = { + str(items[item].device_key) for item in items + } + log_debug( + f"[init|NexxtmoveDataUpdateCoordinator|_async_update_data|fetched_items] {fetched_items}" + ) + if stale_items := current_items - fetched_items: + for device_key in stale_items: + if device := self._device_registry.async_get_device( + {(DOMAIN, device_key)} + ): + log_debug( + f"[init|NexxtmoveDataUpdateCoordinator|_async_update_data|async_remove_device] {device_key}", + True, + ) + self._device_registry.async_remove_device(device.id) + + # If there are new items, we should reload the config entry so we can + # create new devices and entities. + if self.data and fetched_items - { + str(self.data[item].device_key) for item in self.data + }: + # log_debug(f"[init|NexxtmoveDataUpdateCoordinator|_async_update_data|async_reload] {product.product_name}") + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._config_entry_id) + ) + return None + return items + return [] diff --git a/custom_components/nexxtmove/client.py b/custom_components/nexxtmove/client.py new file mode 100644 index 0000000..4a23a2f --- /dev/null +++ b/custom_components/nexxtmove/client.py @@ -0,0 +1,312 @@ +"""Nexxtmove API Client.""" +from __future__ import annotations + +from requests import ( + Session, +) + +from .const import BASE_HEADERS +from .const import CONNECTION_RETRY +from .const import DEFAULT_NEXXTMOVE_ENVIRONMENT +from .const import REQUEST_TIMEOUT +from .exceptions import BadCredentialsException +from .exceptions import NexxtmoveServiceException +from .models import NexxtmoveEnvironment +from .models import NexxtmoveItem +from .utils import format_entity_name +from .utils import log_debug + + +class NexxtmoveClient: + """Nexxtmove client.""" + + session: Session + environment: NexxtmoveEnvironment + + def __init__( + self, + session: Session | None = None, + username: str | None = None, + password: str | None = None, + headers: dict | None = BASE_HEADERS, + environment: NexxtmoveEnvironment = DEFAULT_NEXXTMOVE_ENVIRONMENT, + ) -> None: + """Initialize NexxtmoveClient.""" + self.session = session if session else Session() + self.username = username + self.password = password + self.environment = environment + self.session.headers = headers + self.profile = {} + self.request_error = {} + self.token = None + + def request( + self, + url, + caller="Not set", + data=None, + expected="200", + log=False, + retrying=False, + connection_retry_left=CONNECTION_RETRY, + ) -> dict: + """Send a request to Nexxtmove.""" + if data is None: + log_debug(f"{caller} Calling GET {url}") + response = self.session.get(url, timeout=REQUEST_TIMEOUT) + else: + log_debug(f"{caller} Calling POST {url} with {data}") + response = self.session.post(url, json=data, timeout=REQUEST_TIMEOUT) + log_debug( + f"{caller} http status code = {response.status_code} (expecting {expected})" + ) + if log: + log_debug(f"{caller} Response:\n{response.text}") + if expected is not None and response.status_code != expected: + if response.status_code == 404: + self.request_error = response.json() + return False + if ( + response.status_code != 403 + and response.status_code != 401 + and response.status_code != 500 + and connection_retry_left > 0 + and not retrying + ): + raise NexxtmoveServiceException( + f"[{caller}] Expecting HTTP {expected} | Response HTTP {response.status_code}, Response: {response.text}, Url: {response.url}" + ) + log_debug( + f"[NexxtmoveClient|request] Received a HTTP {response.status_code}, nothing to worry about! We give it another try :-)" + ) + self.login() + response = self.request( + url, caller, data, expected, log, True, connection_retry_left - 1 + ) + return response + + def login(self) -> dict: + """Start a new Nexxtmove session with a user & password.""" + + log_debug("[NexxtmoveClient|login|start]") + """Login process""" + if self.username is None or self.password is None: + return False + response = self.request( + f'{self.environment.api_endpoint}/user/authenticate', + "[NexxtmoveClient|login|authenticate]", + { + "username": self.username, + "password": self.password + }, + 200 + ) + result = response.json() + try: + self.token = result.get("authToken") + self.session.headers |= {"Authorize": f"Token {self.token}"} + self.profile = result.get("profile") + except Exception as exception: + raise BadCredentialsException( + f"HTTP {response.status_code} authToken {exception}" + ) + return self.profile + + + def fetch_data(self): + """Fetch Nexxtmove data.""" + data = {} + self.login() + company = self.company() + + device_key = format_entity_name(f"{self.username} account") + device_name = f"Profile {self.username}" + device_model = "Profile" + + key = format_entity_name(f"{self.username} company") + data[key] = NexxtmoveItem( + name=company.get('name'), + key=key, + type="company", + device_key=device_key, + device_name=device_name, + device_model=device_model, + state=company.get('type'), + extra_attributes=company + ) + key = format_entity_name(f"{self.username} profile") + data[key] = NexxtmoveItem( + name="Profile", + key=key, + type="profile", + device_key=device_key, + device_name=device_name, + device_model=device_model, + state=self.profile.get('username'), + extra_attributes=self.profile + ) + charge_latest = self.charge_latest() + if charge_latest.get('charges') and len(charge_latest.get('charges')): + key = format_entity_name(f"{self.username} recent charges") + data[key] = NexxtmoveItem( + name="Recent charges", + key=key, + type="charges", + device_key=device_key, + device_name=device_name, + device_model=device_model, + state=len(charge_latest.get('charges')), + extra_attributes=charge_latest + ) + + consumption = self.consumption() + if consumption.get('consumptionInKwh') is not None: + key = format_entity_name(f"{self.username} consumption") + data[key] = NexxtmoveItem( + name="Consumption", + key=key, + type="consumption", + device_key=device_key, + device_name=device_name, + device_model=device_model, + state=consumption.get('consumptionInKwh'), + ) + + device_list = self.device_list() + for charging_device in device_list.get('chargingDevices'): + key = format_entity_name(f"{self.username} charging device {charging_device.get('id')}") + device_key = key + device_name = charging_device.get('name') + device_model = charging_device.get('type') + data[key] = NexxtmoveItem( + name=charging_device.get('name'), + key=key, + type="charging_device", + device_key=device_key, + device_name=device_name, + device_model=device_model, + state=charging_device.get('buildingName'), + extra_attributes=charging_device + ) + events = self.device_events(charging_device.get('id')) + if events.get('events') and len(events.get('events')): + key = format_entity_name(f"{self.username} charging device {charging_device.get('id')} events") + data[key] = NexxtmoveItem( + name=f"{charging_device.get('name')} events", + key=key, + type="charging_events", + device_key=device_key, + device_name=device_name, + device_model=device_model, + state=len(events.get('events')), + extra_attributes=events + ) + + if len(charging_device.get('chargingPoints')): + for charging_point in charging_device.get('chargingPoints'): + id = charging_point.get('id') + key = format_entity_name(f"{self.username} charging point {id}") + data[key] = NexxtmoveItem( + name=charging_point.get('name'), + key=key, + type="charging_point", + device_key=device_key, + device_name=device_name, + device_model=device_model, + state=charging_point.get('status'), + extra_attributes=charging_point + ) + events = self.charging_point_events(id) + if events.get('events') and len(events.get('events')): + key = format_entity_name(f"{self.username} charging point {id} events") + data[key] = NexxtmoveItem( + name=f"{charging_point.get('name')} events", + key=key, + type="charging_events", + device_key=device_key, + device_name=device_name, + device_model=device_model, + state=len(events.get('events')), + extra_attributes=events + ) + return data + + + def company(self): + """Fetch Company info.""" + log_debug("[NexxtmoveClient|company] Fetching company info from Nexxtmove") + response = self.request( + f'{self.environment.api_endpoint}/company', + "[NexxtmoveClient|company]", + None, + 200, + ) + if response is False: + return False + return response.json() + + def device_list(self): + """Fetch Device list.""" + log_debug("[NexxtmoveClient|device_list] Fetching device list from Nexxtmove") + response = self.request( + f'{self.environment.api_endpoint}/device/list', + "[NexxtmoveClient|device_list]", + None, + 200, + ) + if response is False: + return False + return response.json() + + def device_events(self, device_id): + """Fetch Device events.""" + log_debug("[NexxtmoveClient|device_events] Fetching device events from Nexxtmove") + response = self.request( + f'{self.environment.api_endpoint}/device/{device_id}/events', + "[NexxtmoveClient|device_events]", + None, + 200, + ) + if response is False: + return False + return response.json() + + def charging_point_events(self, device_id): + """Fetch charging point events.""" + log_debug("[NexxtmoveClient|charging_point_events] Fetching charging point events from Nexxtmove") + response = self.request( + f'{self.environment.api_endpoint}/point/{device_id}/events', + "[NexxtmoveClient|charging_point_events]", + None, + 200, + ) + if response is False: + return False + return response.json() + + def charge_latest(self): + """Fetch charges.""" + log_debug("[NexxtmoveClient|charge_latest] Fetching charges from Nexxtmove") + response = self.request( + f'{self.environment.api_endpoint}/charge/latest?maxRows=20&offset=0', + "[NexxtmoveClient|charge_latest]", + None, + 200, + ) + if response is False: + return False + return response.json() + + def consumption(self): + """Fetch consumption.""" + log_debug("[NexxtmoveClient|consumption] Fetching consumption from Nexxtmove") + response = self.request( + f'{self.environment.api_endpoint}/charge/consumption', + "[NexxtmoveClient|consumption]", + None, + 200, + ) + if response is False: + return False + return response.json() diff --git a/custom_components/nexxtmove/config_flow.py b/custom_components/nexxtmove/config_flow.py new file mode 100644 index 0000000..b0048a2 --- /dev/null +++ b/custom_components/nexxtmove/config_flow.py @@ -0,0 +1,209 @@ +"""Config flow to configure the Nexxtmove integration.""" +from abc import ABC +from abc import abstractmethod +from typing import Any + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import OptionsFlow +from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowHandler +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import TextSelector +from homeassistant.helpers.selector import TextSelectorConfig +from homeassistant.helpers.selector import TextSelectorType +from homeassistant.helpers.typing import UNDEFINED + +from .client import NexxtmoveClient +from .const import DOMAIN +from .const import NAME +from .exceptions import BadCredentialsException +from .exceptions import NexxtmoveServiceException +from .models import NexxtmoveConfigEntryData +from .utils import log_debug + +DEFAULT_ENTRY_DATA = NexxtmoveConfigEntryData( + username=None, + password=None, +) + + +class NexxtmoveCommonFlow(ABC, FlowHandler): + """Base class for Nexxtmove flows.""" + + def __init__(self, initial_data: NexxtmoveConfigEntryData) -> None: + """Initialize NexxtmoveCommonFlow.""" + self.initial_data = initial_data + self.new_entry_data = NexxtmoveConfigEntryData() + self.new_title: str | None = None + + @abstractmethod + def finish_flow(self) -> FlowResult: + """Finish the flow.""" + + def new_data(self): + """Construct new data.""" + return DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data + + async def async_validate_input(self, user_input: dict[str, Any]) -> None: + """Validate user credentials.""" + + client = NexxtmoveClient( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + + profile = await self.hass.async_add_executor_job(client.login) + + return profile + + async def async_step_connection_init( + self, user_input: dict | None = None + ) -> FlowResult: + """Handle connection configuration.""" + if user_input is not None: + user_input = self.new_data() | user_input + test = await self.test_connection(user_input) + log_debug(f"Test result config flow: {test}") + if not test["errors"]: + self.new_title = test["profile"].get("username") + self.new_entry_data |= user_input + await self.async_set_unique_id( + f"{DOMAIN}_" + test["profile"].get("username") + ) + self._abort_if_unique_id_configured() + log_debug(f"New account {self.new_title} added") + return self.finish_flow() + fields = { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="username") + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, autocomplete="current-password" + ) + ), + } + return self.async_show_form( + step_id="connection_init", data_schema=vol.Schema(fields) + ) + + async def test_connection(self, user_input: dict | None = None) -> dict: + """Test the connection to Nexxtmove.""" + errors: dict = {} + profile: dict = {} + + if user_input is not None: + user_input = self.new_data() | user_input + try: + profile = await self.async_validate_input(user_input) + except AssertionError as exception: + errors["base"] = "cannot_connect" + log_debug(f"[async_step_password|login] AssertionError {exception}") + except ConnectionError: + errors["base"] = "cannot_connect" + except NexxtmoveServiceException: + errors["base"] = "service_error" + except BadCredentialsException: + errors["base"] = "invalid_auth" + except Exception as exception: + errors["base"] = "unknown" + log_debug(exception) + return {"profile": profile, "errors": errors} + + async def async_step_password(self, user_input: dict | None = None) -> FlowResult: + """Configure password.""" + errors: dict = {} + + if user_input is not None: + user_input = self.new_data() | user_input + test = await self.test_connection(user_input) + if not test["errors"]: + self.new_entry_data |= NexxtmoveConfigEntryData( + password=user_input[CONF_PASSWORD], + ) + log_debug( + f"Password changed for {test['user_details'].get('customer_number')}" + ) + return self.finish_flow() + + fields = { + vol.Required(CONF_PASSWORD): cv.string, + } + return self.async_show_form( + step_id="password", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(fields), + self.initial_data + | NexxtmoveConfigEntryData( + password=None, + ), + ), + errors=errors, + ) + + +class NexxtmoveOptionsFlow(NexxtmoveCommonFlow, OptionsFlow): + """Handle Nexxtmove options.""" + + general_settings: dict + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize Nexxtmove options flow.""" + self.config_entry = config_entry + super().__init__(initial_data=config_entry.data) # type: ignore[arg-type] + + @callback + def finish_flow(self) -> FlowResult: + """Update the ConfigEntry and finish the flow.""" + new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data + self.hass.config_entries.async_update_entry( + self.config_entry, + data=new_data, + title=self.new_title or UNDEFINED, + ) + return self.async_create_entry(title="", data={}) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage Nexxtmove options.""" + return self.async_show_menu( + step_id="options_init", + menu_options=[ + "password", + ], + ) + + +class NexxtmoveConfigFlow(NexxtmoveCommonFlow, ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nexxtmove.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize Nexxtmove Config Flow.""" + super().__init__(initial_data=DEFAULT_ENTRY_DATA) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> NexxtmoveOptionsFlow: + """Get the options flow for this handler.""" + return NexxtmoveOptionsFlow(config_entry) + + @callback + def finish_flow(self) -> FlowResult: + """Create the ConfigEntry.""" + title = self.new_title or NAME + return self.async_create_entry( + title=title, + data=DEFAULT_ENTRY_DATA | self.new_entry_data, + ) + + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_connection_init() diff --git a/custom_components/nexxtmove/const.py b/custom_components/nexxtmove/const.py new file mode 100644 index 0000000..71cc4ea --- /dev/null +++ b/custom_components/nexxtmove/const.py @@ -0,0 +1,57 @@ +"""Constants used by Nexxtmove.""" +import json +import logging +from datetime import timedelta +from pathlib import Path +from typing import Final + +from homeassistant.const import Platform + +from .models import NexxtmoveEnvironment + +SHOW_DEBUG_AS_WARNING = False + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: Final = [Platform.SENSOR] + +ATTRIBUTION: Final = "Data provided by Nexxtmove" + +DEFAULT_NEXXTMOVE_ENVIRONMENT = NexxtmoveEnvironment( + api_endpoint="https://nexxtmove.me/b2bev-app-service/api", + api_key="3f3a7b768b0c689cd2487eaf9849142a", + x_app_platform="android", +) + +BASE_HEADERS = { + "API-KEY": DEFAULT_NEXXTMOVE_ENVIRONMENT.api_key, + "X-App-Platform": DEFAULT_NEXXTMOVE_ENVIRONMENT.x_app_platform, + "Content-Type": "application/json; charset=utf-8" +} + +COORDINATOR_UPDATE_INTERVAL = timedelta(minutes=5) +CONNECTION_RETRY = 5 +REQUEST_TIMEOUT = 20 +WEBSITE = "https://nexxtmove.me/" + +DEFAULT_ICON = "mdi:help-circle-outline" + +manifestfile = Path(__file__).parent / "manifest.json" +with open(manifestfile) as json_file: + manifest_data = json.load(json_file) + +DOMAIN = manifest_data.get("domain") +NAME = manifest_data.get("name") +VERSION = manifest_data.get("version") +ISSUEURL = manifest_data.get("issue_tracker") +STARTUP = """ +------------------------------------------------------------------- +{name} +Version: {version} +This is a custom component +If you have any issues with this you need to open an issue here: +{issueurl} +------------------------------------------------------------------- +""".format( + name=NAME, version=VERSION, issueurl=ISSUEURL +) diff --git a/custom_components/nexxtmove/entity.py b/custom_components/nexxtmove/entity.py new file mode 100644 index 0000000..aaed7f1 --- /dev/null +++ b/custom_components/nexxtmove/entity.py @@ -0,0 +1,87 @@ +"""Base Nexxtmove entity.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import NexxtmoveDataUpdateCoordinator +from .const import ATTRIBUTION +from .const import DOMAIN +from .const import NAME +from .const import VERSION +from .const import WEBSITE +from .models import NexxtmoveItem +from .utils import log_debug + + +class NexxtmoveEntity(CoordinatorEntity[NexxtmoveDataUpdateCoordinator]): + """Base Nexxtmove entity.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: NexxtmoveDataUpdateCoordinator, + description: EntityDescription, + item: NexxtmoveItem, + ) -> None: + """Initialize Nexxtmove entities.""" + super().__init__(coordinator) + self.entity_description = description + self._item = item + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(self.item.device_key))}, + name=self.item.device_name, + manufacturer=NAME, + configuration_url=WEBSITE, + entry_type=DeviceEntryType.SERVICE, + model=self.item.device_model, + sw_version=VERSION, + ) + """ + extra attributes! + """ + self._attr_unique_id = ( + f"{DOMAIN}_{self.item.key}" + ) + self._key = self.item.key + self.client = coordinator.client + self.last_synced = datetime.now() + self._attr_name = f"{self.item.name}".capitalize() + self._item = item + log_debug(f"[NexxtmoveEntity|init] {self._key}") + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if len(self.coordinator.data): + for item in self.coordinator.data: + item = self.coordinator.data[item] + if self._key == item.key: + self.last_synced = datetime.now() + self._item = item + self.async_write_ha_state() + return + log_debug( + f"[NexxtmoveEntity|_handle_coordinator_update] {self._attr_unique_id}: async_write_ha_state ignored since API fetch failed or not found", + True, + ) + + @property + def item(self) -> NexxtmoveItem: + """Return the product for this entity.""" + return self._item + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._item is not None + + async def async_update(self) -> None: + """Update the entity. Only used by the generic entity update service.""" + return diff --git a/custom_components/nexxtmove/exceptions.py b/custom_components/nexxtmove/exceptions.py new file mode 100644 index 0000000..dc14506 --- /dev/null +++ b/custom_components/nexxtmove/exceptions.py @@ -0,0 +1,37 @@ +"""Exceptions used by Nexxtmove.""" + + +class NexxtmoveException(Exception): + """Base class for all exceptions raised by Nexxtmove.""" + + pass + + +class NexxtmoveServiceException(Exception): + """Raised when service is not available.""" + + pass + + +class BadCredentialsException(Exception): + """Raised when credentials are incorrect.""" + + pass + + +class NotAuthenticatedException(Exception): + """Raised when session is invalid.""" + + pass + + +class GatewayTimeoutException(NexxtmoveServiceException): + """Raised when server times out.""" + + pass + + +class BadGatewayException(NexxtmoveServiceException): + """Raised when server returns Bad Gateway.""" + + pass diff --git a/custom_components/nexxtmove/manifest.json b/custom_components/nexxtmove/manifest.json new file mode 100644 index 0000000..f952932 --- /dev/null +++ b/custom_components/nexxtmove/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "nexxtmove", + "name": "Nexxtmove", + "codeowners": ["@geertmeersman"], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/geertmeersman/nexxtmove", + "integration_type": "hub", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/geertmeersman/nexxtmove/issues", + "requirements": [], + "version": "v0.0.0" +} diff --git a/custom_components/nexxtmove/models.py b/custom_components/nexxtmove/models.py new file mode 100644 index 0000000..88bd123 --- /dev/null +++ b/custom_components/nexxtmove/models.py @@ -0,0 +1,38 @@ +"""Models used by Nexxtmove.""" +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field +from typing import TypedDict + + +class NexxtmoveConfigEntryData(TypedDict): + """Config entry for the Nexxtmove integration.""" + + username: str | None + password: str | None + + +@dataclass +class NexxtmoveEnvironment: + """Class to describe a Nexxtmove environment.""" + + api_endpoint: str + api_key: str + x_app_platform: str + + +@dataclass +class NexxtmoveItem: + """Nexxtmove item model.""" + + name: str = "" + key: str = "" + type: str = "" + state: str = "" + device_key: str = "" + device_name: str = "" + device_model: str = "" + data: dict = field(default_factory=dict) + extra_attributes: dict = field(default_factory=dict) + native_unit_of_measurement: str = None diff --git a/custom_components/nexxtmove/sensor.py b/custom_components/nexxtmove/sensor.py new file mode 100644 index 0000000..6c6097a --- /dev/null +++ b/custom_components/nexxtmove/sensor.py @@ -0,0 +1,137 @@ +"""Nexxtmove sensor platform.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ENERGY_KILO_WATT_HOUR +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import NexxtmoveDataUpdateCoordinator +from .const import DOMAIN +from .entity import NexxtmoveEntity +from .models import NexxtmoveItem +from .utils import log_debug + + +@dataclass +class NexxtmoveSensorDescription(SensorEntityDescription): + """Class to describe a Nexxtmove sensor.""" + + value_fn: Callable[[Any], StateType] | None = None + + +SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ + NexxtmoveSensorDescription(key="company", icon="mdi:account-group"), + NexxtmoveSensorDescription(key="profile", icon="mdi:face-man"), + NexxtmoveSensorDescription( + key="consumption", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + icon="mdi:gauge", + ), + NexxtmoveSensorDescription(key="charging_device", icon="mdi:ev-station"), + NexxtmoveSensorDescription(key="charging_point", icon="mdi:ev-plug-type1"), + NexxtmoveSensorDescription(key="charging_events", icon="mdi:calendar-multiple-check"), + NexxtmoveSensorDescription(key="charges", icon="mdi:currency-eur"), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Nexxtmove sensors.""" + log_debug("[sensor|async_setup_entry|async_add_entities|start]") + coordinator: NexxtmoveDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[NexxtmoveSensor] = [] + + SUPPORTED_KEYS = { + description.key: description for description in SENSOR_DESCRIPTIONS + } + + # log_debug(f"[sensor|async_setup_entry|async_add_entities|SUPPORTED_KEYS] {SUPPORTED_KEYS}") + + if coordinator.data is not None: + for item in coordinator.data: + item = coordinator.data[item] + if description := SUPPORTED_KEYS.get(item.type): + if item.native_unit_of_measurement is not None: + native_unit_of_measurement = item.native_unit_of_measurement + else: + native_unit_of_measurement = description.native_unit_of_measurement + sensor_description = NexxtmoveSensorDescription( + key=str(item.key), + name=item.name, + value_fn=description.value_fn, + native_unit_of_measurement=native_unit_of_measurement, + icon=description.icon, + ) + + log_debug( + f"[sensor|async_setup_entry|adding] {item.name}" + ) + entities.append( + NexxtmoveSensor( + coordinator=coordinator, + description=sensor_description, + item=item, + ) + ) + else: + log_debug( + f"[sensor|async_setup_entry|no support type found] {item.name}, type: {item.type}, keys: {SUPPORTED_KEYS.get(item.type)}", + True, + ) + + async_add_entities(entities) + + +class NexxtmoveSensor(NexxtmoveEntity, SensorEntity): + """Representation of a Nexxtmove sensor.""" + + entity_description: NexxtmoveSensorDescription + + def __init__( + self, + coordinator: NexxtmoveDataUpdateCoordinator, + description: EntityDescription, + item: NexxtmoveItem, + ) -> None: + """Set entity ID.""" + super().__init__(coordinator, description, item) + self.entity_id = ( + f"sensor.{DOMAIN}_{self.item.key}" + ) + + @property + def native_value(self) -> str: + """Return the status of the sensor.""" + state = self.item.state + + if self.entity_description.value_fn: + return self.entity_description.value_fn(state) + + return state + + @property + def extra_state_attributes(self): + """Return attributes for sensor.""" + if not self.coordinator.data: + return {} + attributes = { + "last_synced": self.last_synced, + } + if len(self.item.extra_attributes) > 0: + for attr in self.item.extra_attributes: + attributes[attr] = self.item.extra_attributes[attr] + return attributes diff --git a/custom_components/nexxtmove/translations/en.json b/custom_components/nexxtmove/translations/en.json new file mode 100644 index 0000000..87de48f --- /dev/null +++ b/custom_components/nexxtmove/translations/en.json @@ -0,0 +1,57 @@ +{ + "config": { + "step": { + "connection_init": { + "title": "New Nexxtmove account", + "description": "Set up your Nexxtmove user account", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error", + "service_error": "Service unavailable" + } + }, + "options": { + "step": { + "init": { + "title": "Reconfigure Nexxtmove", + "data": { + "username": "Username", + "password": "Password" + } + }, + "password": { + "title": "Update your password", + "description": "To do when you changed your password on 'Nexxtmove'", + "data": { + "password": "Password" + } + }, + "options_init": { + "title": "Change options", + "menu_options": { + "language": "Sensor language", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error", + "service_error": "Service unavailable" + }, + "abort": { + "already_configured": "Account is already configured" + } + } +} diff --git a/custom_components/nexxtmove/translations/nl.json b/custom_components/nexxtmove/translations/nl.json new file mode 100644 index 0000000..9dc5e34 --- /dev/null +++ b/custom_components/nexxtmove/translations/nl.json @@ -0,0 +1,56 @@ +{ + "config": { + "step": { + "connection_init": { + "title": "Nieuwe Nexxtmove account", + "description": "Configureer je Nexxtmove gebruikers account", + "data": { + "username": "Gebruiker", + "password": "Wachtwoord" + } + } + }, + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout", + "service_error": "Service niet beschikbaar" + } + }, + "options": { + "step": { + "init": { + "title": "Herconfigureer Nexxtmove", + "data": { + "username": "Gebruiker", + "password": "Wachtwoord" + } + }, + "password": { + "title": "Pas je wachtwoord aan", + "description": "Te doen wanneer je je account wachtwoord gewijzigd hebt op 'Nexxtmove'", + "data": { + "password": "Wachtwoord" + } + }, + "options_init": { + "title": "Opties wijzigen", + "menu_options": { + "password": "Wachtwoord" + } + } + }, + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout", + "service_error": "Service niet beschikbaar" + } + } +} diff --git a/custom_components/nexxtmove/utils.py b/custom_components/nexxtmove/utils.py new file mode 100644 index 0000000..05b715e --- /dev/null +++ b/custom_components/nexxtmove/utils.py @@ -0,0 +1,73 @@ +"""Nexxtmove utils.""" +from __future__ import annotations + +import logging +import re + +from jsonpath import jsonpath + +from .const import SHOW_DEBUG_AS_WARNING + +_LOGGER = logging.getLogger(__name__) + + +def log_debug(input, force=False) -> None: + """Log to logger as debug or force as warning.""" + if SHOW_DEBUG_AS_WARNING is True or force is True: + _LOGGER.warning(input) + else: + _LOGGER.debug(input) + + +def str_to_float(input) -> float: + """Transform float to string.""" + return float(input.replace(",", ".")) + + +def float_to_timestring(float_time, unit_type) -> str: + """Transform float to timestring.""" + float_time = str_to_float(float_time) + if unit_type.lower() == "seconds": + float_time = float_time * 60 * 60 + elif unit_type.lower() == "minutes": + float_time = float_time * 60 + # log_debug(f"[float_to_timestring] Float Time {float_time}") + hours, seconds = divmod(float_time, 3600) # split to hours and seconds + minutes, seconds = divmod(seconds, 60) # split the seconds to minutes and seconds + result = "" + if hours: + result += f" {hours:02.0f}" + "u" + if minutes: + result += f" {minutes:02.0f}" + " min" + if seconds: + result += f" {seconds:02.0f}" + " sec" + if len(result) == 0: + result = "0 sec" + return result.strip() + + +def format_entity_name(string: str) -> str: + """Format entity name.""" + string = string.strip() + string = re.sub(r"\s+", "_", string) + string = re.sub(r"\W+", "", string).lower() + return string + + +def sizeof_fmt(num, suffix="b"): + """Convert unit to human readable.""" + for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]: + if abs(num) < 1024.0: + return f"{num:3.1f}{unit}{suffix}" + num /= 1024.0 + return f"{num:.1f}Yi{suffix}" + + +def get_json_dict_path(dictionary, path): + """Fetch info based on jsonpath from dict.""" + # log_debug(f"[get_json_dict_path] Path: {path}, Dict: {dictionary}") + json_dict = jsonpath(dictionary, path) + if isinstance(json_dict, list): + json_dict = json_dict[0] + return json_dict + diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..43a74b8 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Nexxtmove", + "render_readme": true, + "country": ["BE"] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1e5ab13 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +colorlog==6.7.0 +homeassistant==2023.3.0 +pip>=8.0.3,<23.1 +ruff==0.0.260 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..6866090 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,62 @@ +[coverage:run] +source = + custom_components + +[coverage:report] +exclude_lines = + pragma: no cover + raise NotImplemented() + if __name__ == '__main__': + main() +show_missing = true + +[tool:pytest] +testpaths = tests +norecursedirs = .git +addopts = + --strict + --cov=custom_components + +#[flake8] +# https://github.com/ambv/black#line-length +#max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +#ignore = +# E501, +# W503, +# E203, +# D202, +# W504 + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# by default isort don't check module indexes +not_skip = __init__.py +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = custom_components,tests +forced_separate = tests +combine_as_imports = true + +[mypy] +python_version = 3.7 +ignore_errors = true +follow_imports = silent +ignore_missing_imports = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true