From 3b9679df0c0a245ba2cfe006fb6e8e669c6f9851 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 4 Feb 2024 19:25:32 +0100 Subject: [PATCH 01/26] Redo approach --- custom_components/stromer/__init__.py | 3 ++ custom_components/stromer/config_flow.py | 59 ++++++++++++++++++++---- custom_components/stromer/const.py | 3 ++ custom_components/stromer/manifest.json | 2 +- custom_components/stromer/stromer.py | 41 ++++++++++------ 5 files changed, 85 insertions(+), 23 deletions(-) diff --git a/custom_components/stromer/__init__.py b/custom_components/stromer/__init__.py index 5c73517..319eeb5 100644 --- a/custom_components/stromer/__init__.py +++ b/custom_components/stromer/__init__.py @@ -36,6 +36,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Initialize connection to stromer stromer = Stromer(username, password, client_id, client_secret) try: + stromer.bike_id = entry.data["bike_id"] + stromer.bike_name = entry.data["nickname"] + stromer.bike_model = entry.data["model"] await stromer.stromer_connect() except ApiError as ex: raise ConfigEntryNotReady("Error while communicating to Stromer API") from ex diff --git a/custom_components/stromer/config_flow.py b/custom_components/stromer/config_flow.py index a280752..99b26b2 100644 --- a/custom_components/stromer/config_flow.py +++ b/custom_components/stromer/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN, LOGGER +from .const import BIKE_DETAILS, CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN, LOGGER from .stromer import Stromer STEP_USER_DATA_SCHEMA = vol.Schema( @@ -24,7 +24,7 @@ ) -async def validate_input(_: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input(_: HomeAssistant, data: dict[str, Any]) -> dict: """Validate the user input allows us to connect.""" username = data[CONF_USERNAME] password = data[CONF_PASSWORD] @@ -36,8 +36,10 @@ async def validate_input(_: HomeAssistant, data: dict[str, Any]) -> dict[str, An if not await stromer.stromer_connect(): raise InvalidAuth + bikes_data = await stromer.stromer_detect() + # Return info that you want to store in the config entry. - return {"title": stromer.bike_name} + return bikes_data class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg, misc] @@ -45,6 +47,36 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call VERSION = 1 + async def async_step_bike( + self, user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle selecting bike step.""" + if user_input is None: + STEP_BIKE_DATA_SCHEMA = vol.Schema( + { vol.Required(BIKE_DETAILS): vol.In(self.bikes), } + ) + log = f"bikes = {self.bikes}" + LOGGER.debug(log) + LOGGER.debug("calling show form on bike") + return self.async_show_form( + step_id="bike", data_schema=STEP_BIKE_DATA_SCHEMA + ) + + # log = f"Completed bike selection = {user_input}" + # LOGGER.debug(log) + bike_data = user_input[BIKE_DETAILS].split(":") + self.user_input_data["bike_id"] = bike_data[0] + self.user_input_data["nickname"] = bike_data[1] + self.user_input_data["model"] = bike_data[2] + log = f"Completed bike data = {self.user_input_data}" + + LOGGER.debug("processing") + + await self.async_set_unique_id(f"stromerbike-{self.user_input_data['bike_id']}") + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=self.user_input_data["nickname"], data=self.user_input_data) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -56,11 +88,22 @@ async def async_step_user( errors = {} - await self.async_set_unique_id("stromerbike") - self._abort_if_unique_id_configured() - try: - info = await validate_input(self.hass, user_input) + bikes_data = await validate_input(self.hass, user_input) + # Handle single bike as multi-bike + self.bikes = [] + for bike in bikes_data: + self.bikes.append(f"{bike['bikeid']}:{bike['nickname']}:{bike['biketype']}") + + # Save account info + self.user_input_data = user_input + # log = f"User input: {user_input}" + # LOGGER.debug(log) + log = f"Bikes: {self.bikes}" + LOGGER.debug(log) + # Display available bikes + return await self.async_step_bike() + except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -68,8 +111,6 @@ async def async_step_user( except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected exception") errors["base"] = "unknown" - else: - return self.async_create_entry(title=info["title"], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors diff --git a/custom_components/stromer/const.py b/custom_components/stromer/const.py index e17329c..a71629d 100644 --- a/custom_components/stromer/const.py +++ b/custom_components/stromer/const.py @@ -9,3 +9,6 @@ CONF_CLIENT_ID = "client_id" CONF_CLIENT_SECRET = "client_secret" + +BIKE_DETAILS = "bike_details" + diff --git a/custom_components/stromer/manifest.json b/custom_components/stromer/manifest.json index 5fa4d0e..8f51e59 100644 --- a/custom_components/stromer/manifest.json +++ b/custom_components/stromer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/CoMPaTech/stromer/issues", "requirements": [], - "version": "0.3.3" + "version": "0.4.0a2" } diff --git a/custom_components/stromer/stromer.py b/custom_components/stromer/stromer.py index f46a723..888525f 100644 --- a/custom_components/stromer/stromer.py +++ b/custom_components/stromer/stromer.py @@ -1,6 +1,6 @@ """Stromer module for Home Assistant Core.""" -__version__ = "0.1.1" +__version__ = "0.2.0" import json import logging @@ -36,6 +36,7 @@ def __init__(self, username: str, password: str, client_id: str, client_secret: self._code: str | None = None self._token: str | None = None + self.full_data: dict = {} self.bike_id: str | None = None self.bike_name: str | None = None self.bike_model: str | None = None @@ -51,15 +52,28 @@ async def stromer_connect(self) -> dict: # Retrieve access token await self.stromer_get_access_token() +# try: +# await self.stromer_update() +# except Exception as e: +# log = f"Stromer unable to update: {e}" +# LOGGER.error(log) + + LOGGER.debug("Stromer connected!") + +# return self.status + return True + + async def stromer_detect(self) -> dict: + """Get full data (to determine bike(s)).""" try: - await self.stromer_update() + self.full_data = await self.stromer_call_api(endpoint="bike/", full=True) except Exception as e: - log = f"Stromer unable to update: {e}" + log = f"Stromer unable to fetch full data: {e}" LOGGER.error(log) - LOGGER.debug("Stromer connected!") - - return self.status + log = f"Stromer full_data : {self.full_data}" + LOGGER.debug(log) + return self.full_data async def stromer_update(self) -> None: """Update stromer data through API.""" @@ -72,13 +86,12 @@ async def stromer_update(self) -> None: try: log = f"Stromer attempt: {attempts}/10" LOGGER.debug(log) - self.bike = await self.stromer_call_api(endpoint="bike/") - log = f"Stromer bike: {self.bike}" - LOGGER.debug(log) +# self.full_data = await self.stromer_call_api(endpoint="bike/") +# log = f"Stromer bike: {self.full_data}" +# LOGGER.debug(log) - self.bike_id = self.bike["bikeid"] - self.bike_name = self.bike["nickname"] - self.bike_model = self.bike["biketype"] +# self.bike_name = self.bike["nickname"] +# self.bike_model = self.bike["biketype"] endpoint = f"bike/{self.bike_id}/state/" self.status = await self.stromer_call_api(endpoint=endpoint) @@ -206,7 +219,7 @@ async def stromer_reset_trip_data(self) -> None: if res.status != 204: raise ApiError - async def stromer_call_api(self, endpoint: str) -> Any: + async def stromer_call_api(self, endpoint: str, full=False) -> Any: """Retrieve data from the API.""" url = f"{self.base_url}/rapi/mobile/v4.1/{endpoint}" if self._api_version == "v3": @@ -219,6 +232,8 @@ async def stromer_call_api(self, endpoint: str) -> Any: LOGGER.debug(log) log = "API call returns: %s" % ret LOGGER.debug(log) + if full: + return ret["data"] return ret["data"][0] From e92c2d006376a86c8ca5b005c96ac228f772b617 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 4 Feb 2024 19:26:31 +0100 Subject: [PATCH 02/26] Code spaces --- custom_components/stromer/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/stromer/__init__.py b/custom_components/stromer/__init__.py index 319eeb5..e0398ef 100644 --- a/custom_components/stromer/__init__.py +++ b/custom_components/stromer/__init__.py @@ -36,9 +36,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Initialize connection to stromer stromer = Stromer(username, password, client_id, client_secret) try: - stromer.bike_id = entry.data["bike_id"] + stromer.bike_id = entry.data["bike_id"] stromer.bike_name = entry.data["nickname"] - stromer.bike_model = entry.data["model"] + stromer.bike_model = entry.data["model"] await stromer.stromer_connect() except ApiError as ex: raise ConfigEntryNotReady("Error while communicating to Stromer API") from ex From 491861e1e8bd45d6d3256b2697e72a774be7e6d4 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Mon, 3 Jun 2024 17:24:29 +0200 Subject: [PATCH 03/26] Version bump --- custom_components/stromer/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/stromer/manifest.json b/custom_components/stromer/manifest.json index 8f51e59..36b1045 100644 --- a/custom_components/stromer/manifest.json +++ b/custom_components/stromer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/CoMPaTech/stromer/issues", "requirements": [], - "version": "0.4.0a2" + "version": "0.4.0a3" } From f82146c2daf9a6430e33028b2b10232c5e6f50fe Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Wed, 24 Jul 2024 17:20:32 +0200 Subject: [PATCH 04/26] Cleanup after rebase --- custom_components/stromer/stromer.py | 27 +++++++-------------------- pyproject.toml | 19 ++++++++++--------- 2 files changed, 17 insertions(+), 29 deletions(-) diff --git a/custom_components/stromer/stromer.py b/custom_components/stromer/stromer.py index 888525f..8ba978e 100644 --- a/custom_components/stromer/stromer.py +++ b/custom_components/stromer/stromer.py @@ -52,15 +52,8 @@ async def stromer_connect(self) -> dict: # Retrieve access token await self.stromer_get_access_token() -# try: -# await self.stromer_update() -# except Exception as e: -# log = f"Stromer unable to update: {e}" -# LOGGER.error(log) - LOGGER.debug("Stromer connected!") -# return self.status return True async def stromer_detect(self) -> dict: @@ -86,12 +79,6 @@ async def stromer_update(self) -> None: try: log = f"Stromer attempt: {attempts}/10" LOGGER.debug(log) -# self.full_data = await self.stromer_call_api(endpoint="bike/") -# log = f"Stromer bike: {self.full_data}" -# LOGGER.debug(log) - -# self.bike_name = self.bike["nickname"] -# self.bike_model = self.bike["biketype"] endpoint = f"bike/{self.bike_id}/state/" self.status = await self.stromer_call_api(endpoint=endpoint) @@ -126,7 +113,7 @@ async def stromer_get_code(self) -> None: except Exception as e: log = f"Stromer error: api call failed: {e} with content {res}" LOGGER.error(log) - raise ApiError + raise ApiError from e qs = urlencode( { @@ -186,9 +173,9 @@ async def stromer_call_lock(self, state: bool) -> None: headers = {"Authorization": f"Bearer {self._token}"} res = await self._websession.post(url, headers=headers, json=data) ret = json.loads(await res.text()) - log = "API call lock status: %s" % res.status + log = f"API call lock status: {res.status}" LOGGER.debug(log) - log = "API call lock returns: %s" % ret + log = f"API call lock returns: {ret}" LOGGER.debug(log) async def stromer_call_light(self, state: str) -> None: @@ -202,9 +189,9 @@ async def stromer_call_light(self, state: str) -> None: headers = {"Authorization": f"Bearer {self._token}"} res = await self._websession.post(url, headers=headers, json=data) ret = json.loads(await res.text()) - log = "API call light status: %s" % res.status + log = f"API call light status: {res.status}" LOGGER.debug(log) - log = "API call light returns: %s" % ret + log = f"API call light returns: {ret}" LOGGER.debug(log) async def stromer_reset_trip_data(self) -> None: @@ -228,9 +215,9 @@ async def stromer_call_api(self, endpoint: str, full=False) -> Any: headers = {"Authorization": f"Bearer {self._token}"} res = await self._websession.get(url, headers=headers, data={}) ret = json.loads(await res.text()) - log = "API call status: %s" % res.status + log = f"API call status: {res.status}" LOGGER.debug(log) - log = "API call returns: %s" % ret + log = f"API call returns: {ret}" LOGGER.debug(log) if full: return ret["data"] diff --git a/pyproject.toml b/pyproject.toml index 411fb7b..99b58a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ exclude = 'generated' [tool.ruff] target-version = "py312" -select = [ +lint.select = [ "B002", # Python does not support the unary prefix increment "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception @@ -29,7 +29,7 @@ select = [ "N804", # First argument of a class method should be named cls "N805", # First argument of a method should be named self "N815", # Variable {name} in class scope should not be mixedCase - "PGH001", # No builtin eval() allowed + "S307", # No builtin eval() allowed # warning: `PGH001` has been remapped to `S307`. "PGH004", # Use specific rule codes when using noqa "PL", # https://github.com/astral-sh/ruff/issues/7491#issuecomment-1730008111 "PLC0414", # Useless import alias. Import alias does not rename original package. @@ -69,13 +69,13 @@ select = [ "T20", # flake8-print "TID251", # Banned imports "TRY004", # Prefer TypeError exception for invalid type - "TRY200", # Use raise from to specify exception cause + "B904", # Use raise from to specify exception cause # warning: `TRY200` has been remapped to `B904`. "TRY302", # Remove exception handler; error is immediately re-raised "UP", # pyupgrade "W", # pycodestyle ] -ignore = [ +lint.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 @@ -97,7 +97,7 @@ ignore = [ "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` ] -[tool.ruff.flake8-import-conventions.extend-aliases] +[tool.ruff.lint.flake8-import-conventions.extend-aliases] voluptuous = "vol" "homeassistant.helpers.area_registry" = "ar" "homeassistant.helpers.config_validation" = "cv" @@ -106,13 +106,13 @@ voluptuous = "vol" "homeassistant.helpers.issue_registry" = "ir" "homeassistant.util.dt" = "dt_util" -[tool.ruff.flake8-pytest-style] +[tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false -[tool.ruff.flake8-tidy-imports.banned-api] +[tool.ruff.lint.flake8-tidy-imports.banned-api] "pytz".msg = "use zoneinfo instead" -[tool.ruff.isort] +[tool.ruff.lint.isort] force-sort-within-sections = true section-order = ["future", "standard-library", "first-party", "third-party", "local-folder"] known-third-party = [ @@ -130,5 +130,6 @@ forced-separate = [ combine-as-imports = true split-on-trailing-comma = false -[tool.ruff.mccabe] +[tool.ruff.lint.mccabe] max-complexity = 25 + From 5c3374a01f40cd0ca0adb580d4df006891739ec8 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 12:38:07 +0200 Subject: [PATCH 05/26] Missing brackets --- custom_components/stromer/translations/en.json | 2 +- custom_components/stromer/translations/nl.json | 2 +- custom_components/stromer/translations/pt.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/stromer/translations/en.json b/custom_components/stromer/translations/en.json index 7873f5a..7f7f3a9 100644 --- a/custom_components/stromer/translations/en.json +++ b/custom_components/stromer/translations/en.json @@ -8,7 +8,7 @@ "password": "Password", "username": "Username", "client_id": "Client ID", - "client_secret": "Client Secret (optional, not needed if you client id starts with 4P" + "client_secret": "Client Secret (optional, not needed if you client id starts with 4P)" } } }, diff --git a/custom_components/stromer/translations/nl.json b/custom_components/stromer/translations/nl.json index a7d60dc..2f77c5a 100644 --- a/custom_components/stromer/translations/nl.json +++ b/custom_components/stromer/translations/nl.json @@ -8,7 +8,7 @@ "password": "Wachtwoord", "username": "Gebruikersnaam", "client_id": "Client ID", - "client_secret": "Client Secret (optioneel, niet nodig als je client id met 4P begint" + "client_secret": "Client Secret (optioneel, niet nodig als je client id met 4P begint)" } } }, diff --git a/custom_components/stromer/translations/pt.json b/custom_components/stromer/translations/pt.json index cfe3a13..54eb6ac 100644 --- a/custom_components/stromer/translations/pt.json +++ b/custom_components/stromer/translations/pt.json @@ -8,7 +8,7 @@ "password": "Palavra-passe", "username": "Utilizador", "client_id": "Identificador de cliente", - "client_secret": "Segredo de cliente (opcional não necessário caso o teu cliente começe por 4P" + "client_secret": "Segredo de cliente (opcional não necessário caso o teu cliente começe por 4P)" } } }, From 6350ff8729859a6f8f8bf276798cb4de5103022b Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 12:38:26 +0200 Subject: [PATCH 06/26] Remove via_device to clean up view --- custom_components/stromer/entity.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/stromer/entity.py b/custom_components/stromer/entity.py index 518a7f4..67bac4c 100644 --- a/custom_components/stromer/entity.py +++ b/custom_components/stromer/entity.py @@ -3,7 +3,7 @@ from typing import Any -from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE +from homeassistant.const import ATTR_NAME from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -45,10 +45,10 @@ def __init__( self._attr_device_info.update( { ATTR_NAME: data.get("nickname"), - ATTR_VIA_DEVICE: ( - DOMAIN, - str(self.coordinator.data.bike_id), - ), +# ATTR_VIA_DEVICE: ( +# DOMAIN, +# str(self.coordinator.data.bike_id), +# ), } ) From cd76a65efdbf69601e263002c9951bd2a32c0b90 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 12:39:57 +0200 Subject: [PATCH 07/26] Rework multibike --- custom_components/stromer/__init__.py | 12 +++-- custom_components/stromer/config_flow.py | 65 +++++++++++++----------- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/custom_components/stromer/__init__.py b/custom_components/stromer/__init__.py index e0398ef..ab7d3c4 100644 --- a/custom_components/stromer/__init__.py +++ b/custom_components/stromer/__init__.py @@ -26,6 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Stromer from a config entry.""" hass.data.setdefault(DOMAIN, {}) + LOGGER.debug(f"Stromer entry: {entry}") # Fetch configuration data from config_flow username = entry.data[CONF_USERNAME] @@ -33,18 +34,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client_id = entry.data[CONF_CLIENT_ID] client_secret = entry.data.get(CONF_CLIENT_SECRET, None) - # Initialize connection to stromer + # Initialize module stromer = Stromer(username, password, client_id, client_secret) + + # Set specific bike (instead of all bikes) introduced with morebikes PR try: stromer.bike_id = entry.data["bike_id"] stromer.bike_name = entry.data["nickname"] stromer.bike_model = entry.data["model"] + except ApiError as ex: + raise ConfigEntryNotReady("Unable to determine configuration data for bike") from ex + + # Setup connection to stromer + try: await stromer.stromer_connect() except ApiError as ex: raise ConfigEntryNotReady("Error while communicating to Stromer API") from ex - LOGGER.debug(f"Stromer entry: {entry}") - # Use Bike ID as unique id if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=stromer.bike_id) diff --git a/custom_components/stromer/config_flow.py b/custom_components/stromer/config_flow.py index 99b26b2..c650179 100644 --- a/custom_components/stromer/config_flow.py +++ b/custom_components/stromer/config_flow.py @@ -14,18 +14,19 @@ from .const import BIKE_DETAILS, CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN, LOGGER from .stromer import Stromer +# TODO: reset to required STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_CLIENT_ID): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_CLIENT_ID): str, vol.Optional(CONF_CLIENT_SECRET): str, } ) async def validate_input(_: HomeAssistant, data: dict[str, Any]) -> dict: - """Validate the user input allows us to connect.""" + """Validate the user input allows us to connect by returning a dictionary with all bikes under the account.""" username = data[CONF_USERNAME] password = data[CONF_PASSWORD] client_id = data[CONF_CLIENT_ID] @@ -36,10 +37,10 @@ async def validate_input(_: HomeAssistant, data: dict[str, Any]) -> dict: if not await stromer.stromer_connect(): raise InvalidAuth - bikes_data = await stromer.stromer_detect() + # All bikes information available + all_bikes = await stromer.stromer_detect() - # Return info that you want to store in the config entry. - return bikes_data + return all_bikes class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg, misc] @@ -53,29 +54,27 @@ async def async_step_bike( """Handle selecting bike step.""" if user_input is None: STEP_BIKE_DATA_SCHEMA = vol.Schema( - { vol.Required(BIKE_DETAILS): vol.In(self.bikes), } + { vol.Required(BIKE_DETAILS): vol.In(list(self.friendly_names)), } ) - log = f"bikes = {self.bikes}" - LOGGER.debug(log) - LOGGER.debug("calling show form on bike") return self.async_show_form( step_id="bike", data_schema=STEP_BIKE_DATA_SCHEMA ) - # log = f"Completed bike selection = {user_input}" - # LOGGER.debug(log) - bike_data = user_input[BIKE_DETAILS].split(":") - self.user_input_data["bike_id"] = bike_data[0] - self.user_input_data["nickname"] = bike_data[1] - self.user_input_data["model"] = bike_data[2] - log = f"Completed bike data = {self.user_input_data}" + # Rework user friendly name to actual bike id and details + selected_bike = user_input[BIKE_DETAILS] + bike_id = self.friendly_names[selected_bike] + nickname = self.all_bikes[bike_id]["nickname"] + self.user_input_data["bike_id"] = bike_id + self.user_input_data["nickname"] = nickname + self.user_input_data["model"] = self.all_bikes[bike_id]["biketype"] - LOGGER.debug("processing") + LOGGER.info(f"Using {selected_bike} (i.e. bike ID {bike_id} to talk to the Stromer API") - await self.async_set_unique_id(f"stromerbike-{self.user_input_data['bike_id']}") + await self.async_set_unique_id(f"stromerbike-{bike_id}") self._abort_if_unique_id_configured() - return self.async_create_entry(title=self.user_input_data["nickname"], data=self.user_input_data) + LOGGER.info(f"Creating entry using {nickname} as bike device name") + return self.async_create_entry(title=nickname, data=self.user_input_data) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -90,18 +89,26 @@ async def async_step_user( try: bikes_data = await validate_input(self.hass, user_input) - # Handle single bike as multi-bike - self.bikes = [] + LOGGER.debug(f"bikes_data contains {bikes_data}") + + # Retrieve any bikes available within account + # Modify output for better display of selection + self.friendly_names = {} + self.all_bikes = {} + LOGGER.debug("Checking available bikes:") for bike in bikes_data: - self.bikes.append(f"{bike['bikeid']}:{bike['nickname']}:{bike['biketype']}") + LOGGER.debug(f"* this bike contains {bike}") + bike_id = bike["bikeid"] + nickname = bike["nickname"] + biketype = bike["biketype"] + + friendly_name = f"{nickname} ({biketype}) #{bike_id}" + + self.friendly_names[friendly_name] = bike_id + self.all_bikes[bike_id]= { "nickname": nickname, "biketype": biketype} # Save account info self.user_input_data = user_input - # log = f"User input: {user_input}" - # LOGGER.debug(log) - log = f"Bikes: {self.bikes}" - LOGGER.debug(log) - # Display available bikes return await self.async_step_bike() except CannotConnect: From 773b96027aac6f2dd97ecfbed0cb4ca0f11e3305 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 12:41:22 +0200 Subject: [PATCH 08/26] Follow up TODO --- custom_components/stromer/config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/custom_components/stromer/config_flow.py b/custom_components/stromer/config_flow.py index c650179..0bb0d0f 100644 --- a/custom_components/stromer/config_flow.py +++ b/custom_components/stromer/config_flow.py @@ -14,12 +14,11 @@ from .const import BIKE_DETAILS, CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN, LOGGER from .stromer import Stromer -# TODO: reset to required STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Optional(CONF_USERNAME): str, - vol.Optional(CONF_PASSWORD): str, - vol.Optional(CONF_CLIENT_ID): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_CLIENT_ID): str, vol.Optional(CONF_CLIENT_SECRET): str, } ) From 2caa2e1565042cfae0003d2495ade0c027106be4 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 15:22:42 +0200 Subject: [PATCH 09/26] Handle migration --- custom_components/stromer/__init__.py | 32 ++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/custom_components/stromer/__init__.py b/custom_components/stromer/__init__.py index ab7d3c4..808736d 100644 --- a/custom_components/stromer/__init__.py +++ b/custom_components/stromer/__init__.py @@ -37,23 +37,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Initialize module stromer = Stromer(username, password, client_id, client_secret) - # Set specific bike (instead of all bikes) introduced with morebikes PR - try: - stromer.bike_id = entry.data["bike_id"] - stromer.bike_name = entry.data["nickname"] - stromer.bike_model = entry.data["model"] - except ApiError as ex: - raise ConfigEntryNotReady("Unable to determine configuration data for bike") from ex - # Setup connection to stromer try: await stromer.stromer_connect() except ApiError as ex: raise ConfigEntryNotReady("Error while communicating to Stromer API") from ex + # Remove stale via_device + if "via_device" in entry.data: + new_data = {k: v for k, v in entry.data.items() if k != "via_device"} + hass.config_entries.async_update_entry(entry, data=new_data) + + # Ensure migration from v3 single bike + if "bike_id" not in entry.data: + bikedata = stromer.stromer_detect() + new_data = {**entry.data, "bike_id": bikedata["bikeid"]} + hass.config_entries.async_update_entry(entry, data=new_data) + new_data = {**entry.data, "nickname": bikedata["nickname"]} + hass.config_entries.async_update_entry(entry, data=new_data) + new_data = {**entry.data, "model": bikedata["biketype"]} + hass.config_entries.async_update_entry(entry, data=new_data) + + # Set specific bike (instead of all bikes) introduced with morebikes PR + stromer.bike_id = entry.data["bike_id"] + stromer.bike_name = entry.data["nickname"] + stromer.bike_model = entry.data["model"] + # Use Bike ID as unique id - if entry.unique_id is None: - hass.config_entries.async_update_entry(entry, unique_id=stromer.bike_id) + if entry.unique_id is None or entry.unique_id == "stromerbike": + hass.config_entries.async_update_entry(entry, unique_id=f"stromerbike-{stromer.bike_id}") # Set up coordinator for fetching data coordinator = StromerDataUpdateCoordinator(hass, stromer, SCAN_INTERVAL) # type: ignore[arg-type] From e7e6d16aa497a540123d7d947b75a0e7baf14260 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 15:35:41 +0200 Subject: [PATCH 10/26] Add reload entry --- custom_components/stromer/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/stromer/__init__.py b/custom_components/stromer/__init__.py index 808736d..d982635 100644 --- a/custom_components/stromer/__init__.py +++ b/custom_components/stromer/__init__.py @@ -57,6 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, data=new_data) new_data = {**entry.data, "model": bikedata["biketype"]} hass.config_entries.async_update_entry(entry, data=new_data) + await hass.config_entries.async_reload(entry.entry_id) # Set specific bike (instead of all bikes) introduced with morebikes PR stromer.bike_id = entry.data["bike_id"] @@ -66,6 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Use Bike ID as unique id if entry.unique_id is None or entry.unique_id == "stromerbike": hass.config_entries.async_update_entry(entry, unique_id=f"stromerbike-{stromer.bike_id}") + await hass.config_entries.async_reload(entry.entry_id) # Set up coordinator for fetching data coordinator = StromerDataUpdateCoordinator(hass, stromer, SCAN_INTERVAL) # type: ignore[arg-type] From 126bc39d3d6d1fae9b484fb91c9ec232bebc4973 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 15:36:17 +0200 Subject: [PATCH 11/26] Version bump --- custom_components/stromer/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/stromer/manifest.json b/custom_components/stromer/manifest.json index 36b1045..eb39ad8 100644 --- a/custom_components/stromer/manifest.json +++ b/custom_components/stromer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/CoMPaTech/stromer/issues", "requirements": [], - "version": "0.4.0a3" + "version": "0.4.0a4" } From 59b8876d63cbd184b120295da11fc1f8dff7a765 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 20:34:27 +0200 Subject: [PATCH 12/26] Cleanup via_device --- custom_components/stromer/__init__.py | 17 +++++++++++------ custom_components/stromer/entity.py | 12 ++---------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/custom_components/stromer/__init__.py b/custom_components/stromer/__init__.py index d982635..0aa9a19 100644 --- a/custom_components/stromer/__init__.py +++ b/custom_components/stromer/__init__.py @@ -43,11 +43,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ApiError as ex: raise ConfigEntryNotReady("Error while communicating to Stromer API") from ex - # Remove stale via_device - if "via_device" in entry.data: - new_data = {k: v for k, v in entry.data.items() if k != "via_device"} - hass.config_entries.async_update_entry(entry, data=new_data) - # Ensure migration from v3 single bike if "bike_id" not in entry.data: bikedata = stromer.stromer_detect() @@ -67,7 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Use Bike ID as unique id if entry.unique_id is None or entry.unique_id == "stromerbike": hass.config_entries.async_update_entry(entry, unique_id=f"stromerbike-{stromer.bike_id}") - await hass.config_entries.async_reload(entry.entry_id) # Set up coordinator for fetching data coordinator = StromerDataUpdateCoordinator(hass, stromer, SCAN_INTERVAL) # type: ignore[arg-type] @@ -86,6 +80,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=f"{stromer.bike_model}", ) + # Remove stale via_device + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + + if device and "via_device" in device.via_device_id: + device_registry.async_update_device( + device.id, + via_device_id=None + ) + # Set up platforms (i.e. sensors, binary_sensors) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/stromer/entity.py b/custom_components/stromer/entity.py index 67bac4c..79fe0dc 100644 --- a/custom_components/stromer/entity.py +++ b/custom_components/stromer/entity.py @@ -3,7 +3,7 @@ from typing import Any -from homeassistant.const import ATTR_NAME +from homeassistant.const import ATTR_NAME, ATTR_VIA_DEVICE from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -35,20 +35,12 @@ def __init__( name=data.get("nickname"), sw_version=data.get("suiversion"), hw_version=data.get("tntversion"), - # stromer_id=data.get("bike_id"), - # type=data.get("bikemodel"), - # frame_color=data.get("color"), - # frame_size=data.get("size"), - # hardware=data.get("hardware"), ) self._attr_device_info.update( { ATTR_NAME: data.get("nickname"), -# ATTR_VIA_DEVICE: ( -# DOMAIN, -# str(self.coordinator.data.bike_id), -# ), + ATTR_VIA_DEVICE: None, } ) From 130684540d6d01f8ccc3c5a467dafe8e1ab2d503 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 20:45:32 +0200 Subject: [PATCH 13/26] Bump version and update documentation --- CHANGELOG.md | 9 +++++++-- README.md | 2 ++ custom_components/stromer/manifest.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1009b9..0877399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,18 @@ ## Changelog -### Ongoing +### Ongoing [0.4.0a5] - Add multibike support (see 0.4 beta progress) +- Improve support for migration from <= v0.3 (single bike) +- Remove stale VIA_DEVICE (i.e. the 'Connected to' when viewing the device page) +- Below-the-surface each bike is now created with a unique id `stromerbike-xxxxx` +- Multi-bike (proper) selection using config_flow setup ### JUL 2024 [0.3.3] -- Added Portuguese Language +- Added Portuguese Language [tnx @ViPeR5000] +- Other smaller fixes and contributes [tnx @L2v@p] ### JUN 2024 [0.3.2] diff --git a/README.md b/README.md index 8c0e5bd..3fcc3a9 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ The light-switch is called 'Light mode' as depending on your bike type it will s **BETA** As with the `switch` implementation a `button` is added to reset your trip_data. +**ALPHA** Multi-bike support (see #81 / #82 for details and progress). Currently seems working with a few glitches (v0.4.0a2), hoping to provide more stability (and migration from v0.3) (v0.4.0a4 and upwards). Basically the config-flow will now detect if you have one or multiple bikes. If you have one, you can only select it (obviously). When multiple bikes are in the same account repeat the 'add integration' for each bike, selecting the other bike(s) on each iteration. + ## If you want more frequent updates Basically you'll have to trigger (through automations) the updates yourself. But it's the correct way to learn Home Assistant and the method shown below also saves some API calls to Stromer. Basically this will determine if the bike is **unlocked** and if so start frequent updates. The example will also show you how to add a button to immediately start the more frequent updates. And yes, I'm aware we can make blueprints of this, but I'll leave that for now until we have a broader user base and properly tested integration. diff --git a/custom_components/stromer/manifest.json b/custom_components/stromer/manifest.json index eb39ad8..a404961 100644 --- a/custom_components/stromer/manifest.json +++ b/custom_components/stromer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/CoMPaTech/stromer/issues", "requirements": [], - "version": "0.4.0a4" + "version": "0.4.0a5" } From 3f5c47ea57d84bf71b775b50ce444edd60263678 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 23:03:10 +0200 Subject: [PATCH 14/26] Add missing async await --- custom_components/stromer/__init__.py | 2 +- custom_components/stromer/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/stromer/__init__.py b/custom_components/stromer/__init__.py index 0aa9a19..0642f5c 100644 --- a/custom_components/stromer/__init__.py +++ b/custom_components/stromer/__init__.py @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Ensure migration from v3 single bike if "bike_id" not in entry.data: - bikedata = stromer.stromer_detect() + bikedata = await stromer.stromer_detect() new_data = {**entry.data, "bike_id": bikedata["bikeid"]} hass.config_entries.async_update_entry(entry, data=new_data) new_data = {**entry.data, "nickname": bikedata["nickname"]} diff --git a/custom_components/stromer/manifest.json b/custom_components/stromer/manifest.json index a404961..93d58f6 100644 --- a/custom_components/stromer/manifest.json +++ b/custom_components/stromer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/CoMPaTech/stromer/issues", "requirements": [], - "version": "0.4.0a5" + "version": "0.4.0a6" } From dacb5e2954afe64a50522443c296c8ff05929945 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Thu, 8 Aug 2024 23:08:08 +0200 Subject: [PATCH 15/26] First bike --- custom_components/stromer/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/stromer/__init__.py b/custom_components/stromer/__init__.py index 0642f5c..550613a 100644 --- a/custom_components/stromer/__init__.py +++ b/custom_components/stromer/__init__.py @@ -46,11 +46,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Ensure migration from v3 single bike if "bike_id" not in entry.data: bikedata = await stromer.stromer_detect() - new_data = {**entry.data, "bike_id": bikedata["bikeid"]} + new_data = {**entry.data, "bike_id": bikedata[0]["bikeid"]} hass.config_entries.async_update_entry(entry, data=new_data) - new_data = {**entry.data, "nickname": bikedata["nickname"]} + new_data = {**entry.data, "nickname": bikedata[0]["nickname"]} hass.config_entries.async_update_entry(entry, data=new_data) - new_data = {**entry.data, "model": bikedata["biketype"]} + new_data = {**entry.data, "model": bikedata[0]["biketype"]} hass.config_entries.async_update_entry(entry, data=new_data) await hass.config_entries.async_reload(entry.entry_id) From 46c2cd65b2528b92054af22e031703f955cda5e6 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 9 Aug 2024 09:51:03 +0200 Subject: [PATCH 16/26] Less is more? --- custom_components/stromer/__init__.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/custom_components/stromer/__init__.py b/custom_components/stromer/__init__.py index 550613a..38d3775 100644 --- a/custom_components/stromer/__init__.py +++ b/custom_components/stromer/__init__.py @@ -46,13 +46,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Ensure migration from v3 single bike if "bike_id" not in entry.data: bikedata = await stromer.stromer_detect() - new_data = {**entry.data, "bike_id": bikedata[0]["bikeid"]} + new_data = { + **entry.data, + "bike_id": bikedata[0]["bikeid"], + "nickname": bikedata[0]["nickname"], + "model": bikedata[0]["biketype"] + } hass.config_entries.async_update_entry(entry, data=new_data) - new_data = {**entry.data, "nickname": bikedata[0]["nickname"]} - hass.config_entries.async_update_entry(entry, data=new_data) - new_data = {**entry.data, "model": bikedata[0]["biketype"]} - hass.config_entries.async_update_entry(entry, data=new_data) - await hass.config_entries.async_reload(entry.entry_id) # Set specific bike (instead of all bikes) introduced with morebikes PR stromer.bike_id = entry.data["bike_id"] @@ -72,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Add bike to the HA device registry device_registry = dr.async_get(hass) - device_registry.async_get_or_create( + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, str(stromer.bike_id))}, manufacturer="Stromer", @@ -80,17 +80,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=f"{stromer.bike_model}", ) - # Remove stale via_device - device = device_registry.async_get_device( - identifiers={(DOMAIN, entry.entry_id)} + # Remove non-existing via device + device_registry.async_update_device( + device.id, + via_device_id=None ) - if device and "via_device" in device.via_device_id: - device_registry.async_update_device( - device.id, - via_device_id=None - ) - # Set up platforms (i.e. sensors, binary_sensors) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) From 2991ce8b79232b48ad046f4236396c7cdc27775a Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Fri, 9 Aug 2024 10:36:38 +0200 Subject: [PATCH 17/26] Correct naming and injection --- custom_components/stromer/__init__.py | 8 +++++--- custom_components/stromer/coordinator.py | 1 + custom_components/stromer/entity.py | 6 +++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/custom_components/stromer/__init__.py b/custom_components/stromer/__init__.py index 38d3775..2ad1ecc 100644 --- a/custom_components/stromer/__init__.py +++ b/custom_components/stromer/__init__.py @@ -76,14 +76,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config_entry_id=entry.entry_id, identifiers={(DOMAIN, str(stromer.bike_id))}, manufacturer="Stromer", - name=f"{stromer.bike_name}", - model=f"{stromer.bike_model}", + name=stromer.bike_name, + model=stromer.bike_model, ) # Remove non-existing via device device_registry.async_update_device( device.id, - via_device_id=None + name=stromer.bike_name, + model=stromer.bike_model, + via_device_id=None, ) # Set up platforms (i.e. sensors, binary_sensors) diff --git a/custom_components/stromer/coordinator.py b/custom_components/stromer/coordinator.py index bf62222..8c5690e 100644 --- a/custom_components/stromer/coordinator.py +++ b/custom_components/stromer/coordinator.py @@ -35,6 +35,7 @@ async def _async_update_data(self) -> StromerData: self.stromer.position["rcvts_pos"] = self.stromer.position.pop("rcvts") bike_data = self.stromer.bike + bike_data.update({"bike_model": self.stromer.bike_model, "bike_name": self.stromer.bike_name}) bike_data.update(self.stromer.status) bike_data.update(self.stromer.position) diff --git a/custom_components/stromer/entity.py b/custom_components/stromer/entity.py index 79fe0dc..8c6e792 100644 --- a/custom_components/stromer/entity.py +++ b/custom_components/stromer/entity.py @@ -31,15 +31,15 @@ def __init__( configuration_url=configuration_url, identifiers={(DOMAIN, str(coordinator.data.bike_id))}, manufacturer="Stromer", - model=data.get("bikemodel"), - name=data.get("nickname"), + model=data.get("bike_model"), + name=data.get("bike_name"), sw_version=data.get("suiversion"), hw_version=data.get("tntversion"), ) self._attr_device_info.update( { - ATTR_NAME: data.get("nickname"), + ATTR_NAME: data.get("bike_name"), ATTR_VIA_DEVICE: None, } ) From 9818651bfa7fc0846ece4374cd8b9aa654d07f87 Mon Sep 17 00:00:00 2001 From: Danilo Bargen Date: Sun, 18 Aug 2024 23:09:52 +0200 Subject: [PATCH 18/26] Fix log message --- custom_components/stromer/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/stromer/config_flow.py b/custom_components/stromer/config_flow.py index 0bb0d0f..f7f043e 100644 --- a/custom_components/stromer/config_flow.py +++ b/custom_components/stromer/config_flow.py @@ -67,7 +67,7 @@ async def async_step_bike( self.user_input_data["nickname"] = nickname self.user_input_data["model"] = self.all_bikes[bike_id]["biketype"] - LOGGER.info(f"Using {selected_bike} (i.e. bike ID {bike_id} to talk to the Stromer API") + LOGGER.info(f"Using {selected_bike} (i.e. bike ID {bike_id}) to talk to the Stromer API") await self.async_set_unique_id(f"stromerbike-{bike_id}") self._abort_if_unique_id_configured() From a1a9e0c278ea1ff9afbe03e771936afc4e418569 Mon Sep 17 00:00:00 2001 From: Danilo Bargen Date: Sun, 18 Aug 2024 23:10:36 +0200 Subject: [PATCH 19/26] Close aiohttp connection when probing API After checking the API credentials, the `Stromer` instance is dropped, and thus we should close the aiohttp session as well. --- custom_components/stromer/config_flow.py | 4 +++- custom_components/stromer/stromer.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/custom_components/stromer/config_flow.py b/custom_components/stromer/config_flow.py index f7f043e..9da2963 100644 --- a/custom_components/stromer/config_flow.py +++ b/custom_components/stromer/config_flow.py @@ -31,10 +31,12 @@ async def validate_input(_: HomeAssistant, data: dict[str, Any]) -> dict: client_id = data[CONF_CLIENT_ID] client_secret = data.get(CONF_CLIENT_SECRET, None) - # Initialize connection to stromer + # Initialize connection to stromer to validate credentials stromer = Stromer(username, password, client_id, client_secret) if not await stromer.stromer_connect(): raise InvalidAuth + LOGGER.debug("Credentials validated successfully") + await stromer.stromer_disconnect() # All bikes information available all_bikes = await stromer.stromer_detect() diff --git a/custom_components/stromer/stromer.py b/custom_components/stromer/stromer.py index 8ba978e..7eb3079 100644 --- a/custom_components/stromer/stromer.py +++ b/custom_components/stromer/stromer.py @@ -27,6 +27,8 @@ def __init__(self, username: str, password: str, client_id: str, client_secret: self._api_version = "v3" self.base_url: str = "https://api3.stromer-portal.ch" + LOGGER.debug("Initializing Stromer with API version %s", self._api_version) + self._timeout: int = timeout self._username: str = username self._password: str = password @@ -43,6 +45,7 @@ def __init__(self, username: str, password: str, client_id: str, client_secret: async def stromer_connect(self) -> dict: """Connect to stromer API.""" + LOGGER.debug("Creating aiohttp session") aio_timeout = aiohttp.ClientTimeout(total=self._timeout) self._websession = aiohttp.ClientSession(timeout=aio_timeout) @@ -56,6 +59,11 @@ async def stromer_connect(self) -> dict: return True + async def stromer_disconnect(self) -> None: + """Close API web session.""" + LOGGER.debug("Closing aiohttp session") + await self._websession.close() + async def stromer_detect(self) -> dict: """Get full data (to determine bike(s)).""" try: From 349d934e2e22fe477fb1120251e96a2e8c12ec35 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Wed, 21 Aug 2024 21:56:21 +0200 Subject: [PATCH 20/26] Follow @coderabbitai suggestion without tripping G004 --- custom_components/stromer/stromer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/stromer/stromer.py b/custom_components/stromer/stromer.py index 7eb3079..64777c7 100644 --- a/custom_components/stromer/stromer.py +++ b/custom_components/stromer/stromer.py @@ -71,6 +71,7 @@ async def stromer_detect(self) -> dict: except Exception as e: log = f"Stromer unable to fetch full data: {e}" LOGGER.error(log) + raise ApiError from e log = f"Stromer full_data : {self.full_data}" LOGGER.debug(log) From cbba02162e17ee5feed35778cb6ec5ca3c3a9f05 Mon Sep 17 00:00:00 2001 From: Danilo Bargen Date: Wed, 21 Aug 2024 23:34:09 +0200 Subject: [PATCH 21/26] Fix timestamp timezone The code wrongly assumed that the value returned by `datetime.fromtimestamp(ts)` returns a UTC datetime. However, the returned datetime is a naive datetime in the local timezone. The code would then previously replace the missing timezone with UTC. For example, when running the code in CEST (UTC+2), this resulted in all timestamps being shifted two hours in the future. Instead, we can pass the UTC timezone directly as `tz` kwarg to `datetime.fromtimestamp`, resulting in a non-naive datetime that has the correct timezone attached. An alternative approach would have been the following call: datetime.fromtimestamp(ts).astimezone() ...however using UTC based datetimes seems cleaner to me. --- custom_components/stromer/sensor.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/custom_components/stromer/sensor.py b/custom_components/stromer/sensor.py index 6887ab1..7a12536 100644 --- a/custom_components/stromer/sensor.py +++ b/custom_components/stromer/sensor.py @@ -210,26 +210,13 @@ def __init__( self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" - @staticmethod - def _ensure_timezone(timestamp: datetime | None) -> datetime | None: - """Calculate days left until domain expires.""" - if timestamp is None: - return None - - # If timezone info isn't provided by the Whois, assume UTC. - if timestamp.tzinfo is None: - return timestamp.replace(tzinfo=UTC) - - return timestamp - @property def native_value(self) -> Any: """Return the state of the sensor.""" if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: - return self._ensure_timezone( - datetime.fromtimestamp( - int(self._coordinator.data.bikedata.get(self._ent)) - ) + return datetime.fromtimestamp( + int(self._coordinator.data.bikedata.get(self._ent)), + tz=UTC, ) return self._coordinator.data.bikedata.get(self._ent) From 93473f6d67bead986f1f2c0b64c7d4367f9917a3 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 25 Aug 2024 12:44:09 +0200 Subject: [PATCH 22/26] Commit coderabbitai suggestion --- custom_components/stromer/sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/stromer/sensor.py b/custom_components/stromer/sensor.py index 7a12536..4df4b25 100644 --- a/custom_components/stromer/sensor.py +++ b/custom_components/stromer/sensor.py @@ -214,8 +214,11 @@ def __init__( def native_value(self) -> Any: """Return the state of the sensor.""" if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: + timestamp = self._coordinator.data.bikedata.get(self._ent) + if timestamp is None: + return None return datetime.fromtimestamp( - int(self._coordinator.data.bikedata.get(self._ent)), + int(timestamp), tz=UTC, ) From a9bf91abac9446de1345972b9019c51472596fa3 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 25 Aug 2024 12:54:00 +0200 Subject: [PATCH 23/26] Drop py311 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 99b58a1..31c4356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ "Bug Reports" = "https://github.com/compatech/stromer/issues" [tool.black] -target-version = ["py312", "py311"] +target-version = ["py312"] exclude = 'generated' [tool.ruff] From 2b12dbfc65d25d11c861a64ffeecd7588a08517d Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 25 Aug 2024 12:54:47 +0200 Subject: [PATCH 24/26] Bump versions and info for release --- CHANGELOG.md | 10 ++++++---- README.md | 8 ++++---- custom_components/stromer/manifest.json | 2 +- hacs.json | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0877399..4deecfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,15 @@ ## Changelog -### Ongoing [0.4.0a5] +### AUG 2024 [0.4.0] -- Add multibike support (see 0.4 beta progress) -- Improve support for migration from <= v0.3 (single bike) +- Add multibike support +- Single and Multi-bike (proper) selection using config_flow setup +- Improve support for migration from <= v0.3.x (single bike in multi-bike) - Remove stale VIA_DEVICE (i.e. the 'Connected to' when viewing the device page) - Below-the-surface each bike is now created with a unique id `stromerbike-xxxxx` -- Multi-bike (proper) selection using config_flow setup +- Improve timeout and aiohttp connection handling [tnx @dbrgn] +- Timestamp/TZ improvements [tnx @dbrgn] ### JUL 2024 [0.3.3] diff --git a/README.md b/README.md index 3fcc3a9..8abc3f0 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,14 @@ Additional the setup flow will ask you for your username (i.e. e-mail address) a In the current state it retrieves `bike`, `status` and `position` from the API every 10 minutes. -**BETA** There is an early implementation on toggling data on your bike, `light` and `lock` can be adjusted. +There is an early implementation on toggling data on your bike, `light` and `lock` can be adjusted. Do note that the switches do not immediately reflect the status (i.e. they will when you toggle them, but switch back quickly). After your 'switch' command we do request an API update to check on the status, pending that update the switch might toggle back-n-forth some time. The light-switch is called 'Light mode' as depending on your bike type it will switch on/off or between 'dim and bright'. -**BETA** As with the `switch` implementation a `button` is added to reset your trip_data. +As with the `switch` implementation a `button` is added to reset your trip_data. -**ALPHA** Multi-bike support (see #81 / #82 for details and progress). Currently seems working with a few glitches (v0.4.0a2), hoping to provide more stability (and migration from v0.3) (v0.4.0a4 and upwards). Basically the config-flow will now detect if you have one or multiple bikes. If you have one, you can only select it (obviously). When multiple bikes are in the same account repeat the 'add integration' for each bike, selecting the other bike(s) on each iteration. +Multi-bike support (see #81 / #82 for details and progress). The config-flow will now detect if you have one or multiple bikes. If you have one, you can only select it (obviously). When multiple bikes are in the same account repeat the 'add integration' for each bike, selecting the other bike(s) on each iteration. ## If you want more frequent updates @@ -134,7 +134,7 @@ icon: mdi:bike show_state: false ``` -## State: ALPHA +## State: BETA Even though available does not mean it's stable yet, the HA part is solid but the class used to interact with the API is in need of improvement (e.g. better overall handling). This might also warrant having the class available as a module from pypi. diff --git a/custom_components/stromer/manifest.json b/custom_components/stromer/manifest.json index 93d58f6..c3e11ab 100644 --- a/custom_components/stromer/manifest.json +++ b/custom_components/stromer/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/CoMPaTech/stromer/issues", "requirements": [], - "version": "0.4.0a6" + "version": "0.4.0" } diff --git a/hacs.json b/hacs.json index fcff99e..db5d74a 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "Stromer E-Bike", - "homeassistant": "2023.12.0", + "homeassistant": "2024.8.0", "render_readme": true } From d2192bc091f68c0be389d474ace63778546fe46c Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 25 Aug 2024 12:57:04 +0200 Subject: [PATCH 25/26] Update return type --- custom_components/stromer/stromer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/stromer/stromer.py b/custom_components/stromer/stromer.py index 64777c7..dbfb7fe 100644 --- a/custom_components/stromer/stromer.py +++ b/custom_components/stromer/stromer.py @@ -43,7 +43,7 @@ def __init__(self, username: str, password: str, client_id: str, client_secret: self.bike_name: str | None = None self.bike_model: str | None = None - async def stromer_connect(self) -> dict: + async def stromer_connect(self) -> bool: """Connect to stromer API.""" LOGGER.debug("Creating aiohttp session") aio_timeout = aiohttp.ClientTimeout(total=self._timeout) From b7c6244cf4da8de11ddf537f1cb15fc1118ca955 Mon Sep 17 00:00:00 2001 From: Tom Scholten Date: Sun, 25 Aug 2024 12:58:50 +0200 Subject: [PATCH 26/26] Add COMMA as suggested by coderabbitai --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8abc3f0..e542122 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The light-switch is called 'Light mode' as depending on your bike type it will s As with the `switch` implementation a `button` is added to reset your trip_data. -Multi-bike support (see #81 / #82 for details and progress). The config-flow will now detect if you have one or multiple bikes. If you have one, you can only select it (obviously). When multiple bikes are in the same account repeat the 'add integration' for each bike, selecting the other bike(s) on each iteration. +Multi-bike support (see #81 / #82 for details and progress). The config-flow will now detect if you have one or multiple bikes. If you have one, you can only select it (obviously). When multiple bikes are in the same account, repeat the 'add integration' for each bike, selecting the other bike(s) on each iteration. ## If you want more frequent updates