From 954caf61f944e62339e005fd2c9cc8874a4e4878 Mon Sep 17 00:00:00 2001 From: "ReneNulschDE.github@Nulsch.de" Date: Tue, 16 Jan 2024 19:51:06 +0100 Subject: [PATCH] Push first working version --- .github/workflows/hassfest.yaml | 14 + .github/workflows/release.yaml | 34 ++ .gitignore | 10 +- .pre-commit-config.yaml | 53 ++ .yamllint | 62 +++ LICENSE | 2 +- README.md | 69 +-- SECURITY.md | 18 +- custom_components/mysmartbike/__init__.py | 52 ++ custom_components/mysmartbike/bike.py | 92 ---- custom_components/mysmartbike/config_flow.py | 112 +++++ custom_components/mysmartbike/const.py | 29 ++ custom_components/mysmartbike/coordinator.py | 71 +++ custom_components/mysmartbike/device.py | 22 + .../mysmartbike/device_tracker.py | 127 +++-- custom_components/mysmartbike/diagnostics.py | 21 +- custom_components/mysmartbike/errors.py | 4 - custom_components/mysmartbike/exceptions.py | 16 + custom_components/mysmartbike/manifest.json | 6 +- custom_components/mysmartbike/sensor.py | 169 +++++-- custom_components/mysmartbike/services.yaml | 2 - custom_components/mysmartbike/strings.json | 45 -- .../mysmartbike/system_health.py | 18 +- .../mysmartbike/translations/en.json | 80 +-- custom_components/mysmartbike/webapi.py | 172 +++++++ hacs.json | 10 +- info.md | 83 ---- mypi.ini | 76 +++ pyproject.toml | 456 ++++++++++++++++++ requirements-dev.txt | 7 + requirements-tests.txt | 37 ++ requirements_test_pre_commit.txt | 5 + setup.cfg | 46 -- simulator/ApiServer.py | 69 +++ simulator/api/v1/objects/me-200 | 305 ++++++++++++ simulator/api/v1/users/login-200 | 89 ++++ simulator/api/v1/users/login-401 | 1 + simulator/api/v1/users/login-409 | 1 + 38 files changed, 1996 insertions(+), 489 deletions(-) create mode 100644 .github/workflows/hassfest.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 .yamllint create mode 100644 custom_components/mysmartbike/__init__.py delete mode 100644 custom_components/mysmartbike/bike.py create mode 100644 custom_components/mysmartbike/config_flow.py create mode 100644 custom_components/mysmartbike/const.py create mode 100644 custom_components/mysmartbike/coordinator.py create mode 100644 custom_components/mysmartbike/device.py delete mode 100644 custom_components/mysmartbike/errors.py create mode 100644 custom_components/mysmartbike/exceptions.py delete mode 100644 custom_components/mysmartbike/strings.json create mode 100644 custom_components/mysmartbike/webapi.py delete mode 100644 info.md create mode 100644 mypi.ini create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements-tests.txt create mode 100644 requirements_test_pre_commit.txt delete mode 100644 setup.cfg create mode 100644 simulator/ApiServer.py create mode 100644 simulator/api/v1/objects/me-200 create mode 100644 simulator/api/v1/users/login-200 create mode 100644 simulator/api/v1/users/login-401 create mode 100644 simulator/api/v1/users/login-409 diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml new file mode 100644 index 0000000..18c7d19 --- /dev/null +++ b/.github/workflows/hassfest.yaml @@ -0,0 +1,14 @@ +name: Validate with hassfest + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v2" + - uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..a6e123d --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,34 @@ +name: Release Workflow + +on: + release: + types: + - published + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v4 + - name: Get integration information + id: information + run: | + name=$(find custom_components/ -type d -maxdepth 1 | tail -n 1 | cut -d "/" -f2) + echo "name=$name" >> $GITHUB_OUTPUT + - name: Adjust version number + shell: bash + run: | + yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ + "${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}/manifest.json" + - name: Create zip file for the integration + shell: bash + run: | + cd "${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}" + zip ${{ steps.information.outputs.name }}.zip -r ./ + - name: Upload the zipfile as a release asset + uses: softprops/action-gh-release@v1 + with: + files: ${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}/${{ steps.information.outputs.name }}.zip + tag_name: ${{ github.event.release.tag_name }} diff --git a/.gitignore b/.gitignore index b7ff757..51f96a8 100644 --- a/.gitignore +++ b/.gitignore @@ -103,11 +103,7 @@ venv.bak/ # mypy .mypy_cache/ - -sec_*.txt .vscode/ -/custom_components/mbapi2020/messages/a* -/custom_components/mbapi2020/messages/v* -/custom_components/mbapi2020/messages/m* -/custom_components/mbapi2020/messages/c* -/custom_components/mbapi2020/messages/rc* \ No newline at end of file +# private dev api files +.*-dev +internal_docs/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2c24830 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,53 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.8 + hooks: + - id: ruff + args: + - --fix + - id: ruff-format + files: ^((custom_components/ha-mysmartbike|pylint|script|tests|simulator)/.+)?[^/]+\.py$ + # - repo: https://github.com/codespell-project/codespell + # rev: v2.2.2 + # hooks: + # - id: codespell + # args: + # - --ignore-words-list=alle,Sie,oder,additionals,alle,alot,bund,currenty,datas,farenheit,falsy,fo,haa,hass,iif,incomfort,ines,ist,nam,nd,pres,pullrequests,resset,rime,ser,serie,te,technik,ue,unsecure,withing,zar + # - --skip="./.*,*.csv,*.json,*.ambr" + # - --quiet-level=2 + # exclude_types: [csv, json] + # exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.32.0 + hooks: + - id: yamllint + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.0.3 + hooks: + - id: prettier + - repo: https://github.com/cdce8p/python-typing-update + rev: v0.6.0 + hooks: + # Run `python-typing-update` hook manually from time to time + # to update python typing syntax. + # Will require manual work, before submitting changes! + # pre-commit run --hook-stage manual python-typing-update --all-files + - id: python-typing-update + stages: [manual] + args: + - --py311-plus + - --force + - --keep-updates + files: ^(custom_components/ha-mysmartbike|tests|script|simulator)/.+\.py$ + - repo: local + hooks: + - id: const-check-simulator-not-disabled + name: const-check-simulator-not-disabled + entry: "USE_SIMULATOR = True" + language: pygrep + types: [python] + - id: const-check-proxy-not-disabled + name: const-check-proxy-not-disabled + entry: "USE_PROXY = True" + language: pygrep + types: [python] diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..e30374d --- /dev/null +++ b/.yamllint @@ -0,0 +1,62 @@ +ignore: | + azure-*.yml +rules: + braces: + level: error + min-spaces-inside: 0 + max-spaces-inside: 1 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: -1 + brackets: + level: error + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: -1 + colons: + level: error + max-spaces-before: 0 + max-spaces-after: 1 + commas: + level: error + max-spaces-before: 0 + min-spaces-after: 1 + max-spaces-after: 1 + comments: + level: error + require-starting-space: true + min-spaces-from-content: 1 + comments-indentation: + level: error + document-end: + level: error + present: false + document-start: + level: error + present: false + empty-lines: + level: error + max: 1 + max-start: 0 + max-end: 1 + hyphens: + level: error + max-spaces-after: 1 + indentation: + level: error + spaces: 2 + indent-sequences: true + check-multi-line-strings: false + key-duplicates: + level: error + line-length: disable + new-line-at-end-of-file: + level: error + new-lines: + level: error + type: unix + trailing-spaces: + level: error + truthy: + level: error + allowed-values: ['true', 'false', 'on'] diff --git a/LICENSE b/LICENSE index 4f9002e..d9a4b5c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Rene Nulsch +Copyright (c) 2024 Rene Nulsch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 7bbd4a2..fc514fa 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,52 @@ +# MySmartBike - -# MySmartBike - DEV in progress - NO WORKING Version yet -[![HassFest tests](https://github.com/renenulschde/ha-mysmartbike/workflows/Validate%20with%20hassfest/badge.svg)](https://developers.home-assistant.io/blog/2020/04/16/hassfest)![Validate with HACS](https://github.com/ReneNulschDE/ha-mysmartbike/workflows/Validate%20with%20HACS/badge.svg) - +[![HassFest tests](https://github.com/renenulschde/ha-mysmartbike/workflows/Validate%20with%20hassfest/badge.svg)](https://developers.home-assistant.io/blog/2020/04/16/hassfest) MySmartBike (Male powered e-bikes) platform as a Custom Component for Home Assistant. IMPORTANT: -* Please login once in the MySmartBike IOS or Android app before you install this component. Make sure you connected your bike in the app - -* Tested Countries: DE - -### Installation -* First: This is not a Home Assistant Add-On. It's a custom component. - -* There is no way to install as the code is not completed yet - -* [How to install a custom component?](https://www.google.com/search?q=how+to+install+custom+components+home+assistant) -* [How to install HACS?](https://hacs.xyz/docs/installation/prerequisites) -### Configuration - -Use the "Add Integration" in Home Assistant and select "MySmartBike". +- Please login once in the MySmartBike IOS or Android app before you install this component. Make sure you connected your bike(s) in the app -Use your MySmartBike-login email address and your Password. +- Tested Countries: DE -### Optional configuration values +### Features: -See Options dialog in the Integration under Home-Assistant/Configuration/Integration. +- Connect to MySmartBike Cloud and collect registered devices +- Create sensors and device tracker for the found devices -``` -Excluded Bikes: comma-separated list of VINs. -``` - -## Available components -Depends on your own bike. - - -### Binary Sensors +### Installation -* None +- This is a Home Assistant custom component (not an Add-in). +- Download the folder custom_component and copy it into your Home-Assistant config folder. +- [How to install a custom component?](https://www.google.com/search?q=how+to+install+custom+components+home+assistant) +- Restart HA, Refresh your HA Browser window +- (or add the github repo Url to HACS...) -### Device Tracker - -* WIP +### Configuration -### Locks +Use the "Add Integration" in Home Assistant and select "MySmartBike" and follow the following steps: -* None +1. Put in your MySmartBike email address and password in the component setup. ### Sensors -* None +- State of charge (Percent, 0-100) +- Odometer (in meters) - Conversation is WIP + +### Diagnostic Sensors -### Diagnostic Sensors [Diagnostic sensors](https://www.home-assistant.io/blog/2021/11/03/release-202111/#entity-categorization) are hidden by default, check the devices page to see the current values -* None +- None ### Services -* None +- None ### Switches -* None +- None ### Logging @@ -78,8 +60,9 @@ logger: ``` ### Open Items -* List is too long as we are on version 0.0.1 + +- List is too long as we are on version 0.0.1 ### Useful links -* [Forum post](WIP) +- [Forum post](WIP) diff --git a/SECURITY.md b/SECURITY.md index 0eb06de..b59affc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,23 +4,23 @@ **Please do not report security vulnerabilities through public GitHub issues.** -Please send an email to [secure-mysmartbike@nulsch.de](mailto:secure-mysmartbike@nulsch.de). +Please send an email to [secure-mysmartbike@nulsch.de](mailto:secure-mysmartbike@nulsch.de). You should receive a response within 24 hours. If for some reason you do not, please follow up via email and keep in mind this project is the hobby of one person. Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: - * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) - * Full paths of source file(s) related to the manifestation of the issue - * The location of the affected source code (tag/branch/commit or direct URL) - * Any special configuration required to reproduce the issue - * Step-by-step instructions to reproduce the issue - * Proof-of-concept or exploit code (if possible) - * Impact of the issue, including how an attacker might exploit the issue +- Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue This information will help me triage your report more quickly. -If you are reporting for a bug bounty, then this is the wrong project. I do not have a bug bounty programm. +If you are reporting for a bug bounty, then this is the wrong project. I do not have a bug bounty program. ## Preferred Languages diff --git a/custom_components/mysmartbike/__init__.py b/custom_components/mysmartbike/__init__.py new file mode 100644 index 0000000..b66e790 --- /dev/null +++ b/custom_components/mysmartbike/__init__.py @@ -0,0 +1,52 @@ +"""The Link2Home integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER, MYSMARTBIKE_PLATFORMS +from .coordinator import MySmartBikeDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Set up Link2Home from a config entry.""" + + username: str = config_entry.options[CONF_USERNAME] + password: str = config_entry.options[CONF_PASSWORD] + + websession = async_get_clientsession(hass) + + coordinator = MySmartBikeDataUpdateCoordinator(hass, websession, username, password) + await coordinator.async_init() + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(config_entry, MYSMARTBIKE_PLATFORMS) + + config_entry.add_update_listener(config_entry_update_listener) + + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + LOGGER.debug("Start config_entry_update_listener") + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + LOGGER.debug("Start async_unload_entry") + + # coordinator: MySmartBikeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + unload_ok = await hass.config_entries.async_unload_platforms(entry, MYSMARTBIKE_PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/custom_components/mysmartbike/bike.py b/custom_components/mysmartbike/bike.py deleted file mode 100644 index 45f9b0f..0000000 --- a/custom_components/mysmartbike/bike.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Define the objects to store bike data.""" -from typing import Any - -ODOMETER_OPTIONS = [ - "odo", -] - -LOCATION_OPTIONS = [ - "positionLat", - "positionLong", - "positionHeading"] - - -ELECTRIC_OPTIONS = [ - 'rangeelectric', - ] - - -class Bike(object): - """ Bike class, stores the bike values at runtime """ - def __init__(self): - self.finorvin = None - - self.odometer = None - self.location = None - self.entry_setup_complete = False - self._update_listeners = set() - - - def add_update_listener(self, listener): - """Add a listener for update notifications.""" - self._update_listeners.add(listener) - - def remove_update_callback(self, listener): - """Remove a listener for update notifications.""" - self._update_listeners.discard(listener) - - def publish_updates(self): - """Schedule call all registered callbacks.""" - for callback in self._update_listeners: - callback() - - -class Odometer(): - """ Stores the Odometer values at runtime """ - def __init__(self): - self.name = "Odometer" - - -class Electric(): - """ Stores the Electric values at runtime """ - def __init__(self): - self.name = "Electric" - - - -class Location(): - """ Stores the Location values at runtime """ - def __init__(self, latitude=None, longitude=None, heading=None): - self.name = "Location" - self.latitude = None - self.longitude = None - self.heading = None - if latitude is not None: - self.latitude = latitude - if longitude is not None: - self.longitude = longitude - if heading is not None: - self.heading = heading - - -class BikeAttribute(): - """ Stores the BikeAttribute values at runtime """ - def __init__(self, value, retrievalstatus, timestamp, distance_unit=None, display_value=None, unit=None): - self.value = value - self.retrievalstatus = retrievalstatus - self.timestamp = timestamp - self.distance_unit = distance_unit - self.display_value = display_value - self.unit = unit - - def as_dict(self) -> dict[str, Any]: - """Return dictionary version of this entry.""" - return { - "value": self.value, - "retrievalstatus": self.retrievalstatus, - "timestamp": self.timestamp, - "distance_unit": self.distance_unit, - "display_value": self.display_value, - "unit": self.unit, - } - diff --git a/custom_components/mysmartbike/config_flow.py b/custom_components/mysmartbike/config_flow.py new file mode 100644 index 0000000..35d7f23 --- /dev/null +++ b/custom_components/mysmartbike/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for NEW_NAME integration.""" +from __future__ import annotations + +from http import HTTPStatus +from typing import Any + +from aiohttp import ClientConnectionError, ClientResponseError +import voluptuous as vol # type: ignore + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER +from .exceptions import MySmartBikeAuthException +from .webapi import MySmartBikeWebApi + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.EMAIL, autocomplete="username" + ) + ), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.PASSWORD, autocomplete="current-password" + ), + ), + } +) + + +class Link2HomeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config or options flow for Link2Home.""" + + VERSION = 1 + + def __init__(self): + """Initialize component.""" + self._existing_entry = None + self.data = None + self.reauth_mode = False + + async def async_step_user(self, user_input: dict[str, Any] | None = None) -> FlowResult: + """Get configuration from the user.""" + + if user_input is None: + return self.async_show_form(step_id="user", data_schema=CONFIG_SCHEMA) + + errors = {} + + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + await self.async_set_unique_id(username) + if not self.reauth_mode: + self._abort_if_unique_id_configured() + + webapi: MySmartBikeWebApi = MySmartBikeWebApi( + async_get_clientsession(self.hass), username, password + ) + try: + if not await webapi.login(): + LOGGER.info("") + errors["base"] = "invalid_auth" + return self.async_show_form( + step_id="user", data_schema=CONFIG_SCHEMA, errors=errors + ) + else: + return self.async_create_entry( + title=username, + data={}, + options={CONF_USERNAME: username, CONF_PASSWORD: password}, + ) + except ClientConnectionError: + errors["base"] = "invalid_auth" + except ClientResponseError as error: + if error.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_auth" + else: + errors["base"] = "unknown" + except MySmartBikeAuthException: + errors["base"] = "invalid_auth" + except Exception: + errors["base"] = "unknown" + + return self.async_create_entry( + title=username, + data={}, + options={CONF_USERNAME: username, CONF_PASSWORD: password}, + ) + + async def async_step_reauth(self, user_input=None): + """Get new tokens for a config entry that can't authenticate.""" + + self.reauth_mode = True + self._existing_entry = user_input + + return self.async_show_form(step_id="user", data_schema=CONFIG_SCHEMA) + + +class InputValidationError(HomeAssistantError): + """Error to indicate we cannot proceed due to invalid input.""" + + def __init__(self, base: str) -> None: + """Initialize with error base.""" + super().__init__() + self.base = base diff --git a/custom_components/mysmartbike/const.py b/custom_components/mysmartbike/const.py new file mode 100644 index 0000000..875d321 --- /dev/null +++ b/custom_components/mysmartbike/const.py @@ -0,0 +1,29 @@ +"""Constants for the MySmartBike integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.const import Platform + +MYSMARTBIKE_PLATFORMS = [Platform.SENSOR, Platform.DEVICE_TRACKER] + +DOMAIN = "mysmartbike" +LOGGER = logging.getLogger(__package__) +UPDATE_INTERVAL = timedelta(seconds=300) + +USE_SIMULATOR = False +API_BASE_URI_CLOUD = "https://my-smartbike.com" +API_BASE_URI_SIMULATOR = "http://0.0.0.0:8001" +API_BASE_URI = API_BASE_URI_CLOUD if not USE_SIMULATOR else API_BASE_URI_SIMULATOR + +USE_PROXY = False +DISABLE_SSL_CERT_CHECK = USE_PROXY +SYSTEM_PROXY: str | None = None if not USE_PROXY else "http://0.0.0.0:8080" +PROXIES: dict | None = {} if not USE_PROXY else {"https": SYSTEM_PROXY} + +API_USER_AGENT = "ENDUSER/2.1.1 (com.mahle.sbs; build 9; IOS 17.3)" +API_X_APP = "ENDUSER" +API_X_PLATFORM = "IOS" +API_X_THEME = "DARK" +API_X_VERSION = "2.1.1" diff --git a/custom_components/mysmartbike/coordinator.py b/custom_components/mysmartbike/coordinator.py new file mode 100644 index 0000000..20e6323 --- /dev/null +++ b/custom_components/mysmartbike/coordinator.py @@ -0,0 +1,71 @@ +"""DataUpdateCoordinator class for the MySmartBike Integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from aiohttp import ClientSession +from aiohttp.client_exceptions import ClientConnectorError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL +from .device import MySmartBikeDevice +from .webapi import MySmartBikeWebApi + +LOGGER = logging.getLogger(__name__) + + +class MySmartBikeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """DataUpdateCoordinator class for the MySmartBike Integration.""" + + initialized: bool = False + + def __init__( + self, + hass: HomeAssistant, + session: ClientSession, + username: str, + password: str, + ) -> None: + """Initialize.""" + + self.hass: HomeAssistant = hass + self.webapi: MySmartBikeWebApi = MySmartBikeWebApi(session, username, password) + + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + + async def async_init(self) -> bool: + """Addition init async.""" + async with asyncio.timeout(10): + return await self.webapi.login() + + async def _async_update_data(self) -> dict[str, MySmartBikeDevice]: + """Update data via library.""" + LOGGER.debug("_async_update_data: started") + + try: + if not self.initialized: + devices = await self.webapi.get_device_list() + LOGGER.info( + "MySmartBike Cloud delivered %s device(s).", + len(devices), + ) + LOGGER.debug(devices) + + self.initialized = True + + else: + devices = await self.webapi.get_device_list() + LOGGER.info( + "MySmartBike Cloud delivered %s device(s).", + len(devices), + ) + LOGGER.debug(devices) + + return devices + + except (ClientConnectorError,) as error: + raise UpdateFailed(error) from error diff --git a/custom_components/mysmartbike/device.py b/custom_components/mysmartbike/device.py new file mode 100644 index 0000000..d31a00b --- /dev/null +++ b/custom_components/mysmartbike/device.py @@ -0,0 +1,22 @@ +"""Device Support for the MySmartBike integration.""" +from __future__ import annotations + +from datetime import datetime + +from attr import dataclass + + +@dataclass +class MySmartBikeDevice: + """Device class for the MySmartBike integration.""" + + serial: str + odometry: int + manufacturer_name: str + model_name: str + longitude: float + latitude: float + last_position_date: datetime + state_of_charge: int | None + remaining_capacity: int | None + # devices: dict[str, MySmartBikeDevice] diff --git a/custom_components/mysmartbike/device_tracker.py b/custom_components/mysmartbike/device_tracker.py index b1588e7..4cba168 100644 --- a/custom_components/mysmartbike/device_tracker.py +++ b/custom_components/mysmartbike/device_tracker.py @@ -1,75 +1,104 @@ -""" -Device Tracker support for Bikes with MySmartBike. +"""Device tracker for MySmartBike.""" +from __future__ import annotations -For more details about this component, please refer to the documentation at -https://github.com/ReneNulschDE/ha-mysmartbike/ -""" import logging -from typing import Optional -from homeassistant.components.device_tracker import SOURCE_TYPE_GPS -from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import MySmartBikeEntity - -from .const import ( - DEVICE_TRACKER, - DOMAIN, -) +from .const import DOMAIN +from .coordinator import MySmartBikeDataUpdateCoordinator +from .device import MySmartBikeDevice LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up the MySmartBike tracker by config_entry.""" - - data = hass.data[DOMAIN] - - if not data.client.bikes: - LOGGER.info("No Bikes found.") - return +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +): + """Set up the sensor platform.""" + coordinator: MySmartBikeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + entities = [] - sensor_list = [] + data: list[MySmartBikeDevice] = list(coordinator.data.values()) + for result in data: + entities.append(MySmartBikeTrackerEntity(result, coordinator)) - for car in data.client.cars: - for key, value in sorted(DEVICE_TRACKER.items()): -# if value[5] is None or getattr(car.features, value[5]) is True: - device = MySmartBikeDeviceTracker( - hass=hass, - data=data, - internal_name = key, - sensor_config = value, - vin = car.finorvin - ) - if device.device_retrieval_status() in ["VALID", "NOT_RECEIVED"] : - sensor_list.append(device) + LOGGER.debug("async_setup_entry: DeviceTracker count for creation - %s", len(entities)) + async_add_entities(entities) - async_add_entities(sensor_list, True) +class MySmartBikeTrackerEntity(TrackerEntity, RestoreEntity): + """Represent a tracked MySmartBike device.""" + def __init__( + self, + device: MySmartBikeDevice, + coordinator: MySmartBikeDataUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" -class MySmartBikeDeviceTracker(MySmartBikeEntity, TrackerEntity, RestoreEntity): - """Representation of a Sensor.""" + self.coordinator: MySmartBikeDataUpdateCoordinator = coordinator + self.device: MySmartBikeDevice = device @property - def latitude(self) -> Optional[float]: + def latitude(self) -> float | None: """Return latitude value of the device.""" - location = self._get_car_value("location", "positionLat", "value", 0) - return location if location else None + return ( + self.coordinator.data[self.device.serial].latitude if self.coordinator.data else None + ) @property - def longitude(self) -> Optional[float]: + def should_poll(self) -> bool: + """No polling for entities that have location pushed.""" + return True + + @property + def longitude(self) -> float | None: """Return longitude value of the device.""" - location = self._get_car_value("location", "positionLong", "value", 0) - return location if location else None + return ( + self.coordinator.data[self.device.serial].longitude if self.coordinator.data else None + ) @property - def source_type(self): - """Return the source type, eg gps or router, of the device.""" - return SOURCE_TYPE_GPS + def icon(self) -> str: + """Return the icon of the device.""" + return "mdi:bike" - @ property - def device_class(self): + @property + def source_type(self) -> SourceType: + """Return the source type of the device.""" + return SourceType.GPS + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.serial)}, + manufacturer=self.device.manufacturer_name, + model=self.device.model_name, + name=(f"{self.device.manufacturer_name} {self.device.model_name}"), + ) + + @property + def battery_level(self) -> int | None: + """Return the battery level of the device. Percentage from 0-100.""" + + return ( + self.coordinator.data[self.device.serial].state_of_charge + if self.coordinator.data + else None + ) return None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + self.async_write_ha_state() diff --git a/custom_components/mysmartbike/diagnostics.py b/custom_components/mysmartbike/diagnostics.py index 01549fd..71a1580 100644 --- a/custom_components/mysmartbike/diagnostics.py +++ b/custom_components/mysmartbike/diagnostics.py @@ -1,25 +1,28 @@ -"""Diagnostics support for HACS.""" +"""Diagnostics support for AccuWeather.""" from __future__ import annotations -import json + from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from .const import DOMAIN +from .coordinator import MySmartBikeDataUpdateCoordinator + +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, - entry: ConfigEntry, + hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - domain = hass.data[DOMAIN] + coordinator: MySmartBikeDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - data = { - "entry": entry.as_dict(), - "cars" : json.dumps(domain.client.bikes) + diagnostics_data = { + "config_entry_data": async_redact_data(dict(config_entry.options), TO_REDACT), + "coordinator_data": coordinator.data, } - return async_redact_data(data, ("password", "access_token", "refresh_token", "username", "unique_id")) + return diagnostics_data diff --git a/custom_components/mysmartbike/errors.py b/custom_components/mysmartbike/errors.py deleted file mode 100644 index 08abe99..0000000 --- a/custom_components/mysmartbike/errors.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Define package errors.""" - -class MySmartBikeApiError(Exception): - """Define a base error.""" diff --git a/custom_components/mysmartbike/exceptions.py b/custom_components/mysmartbike/exceptions.py new file mode 100644 index 0000000..92538df --- /dev/null +++ b/custom_components/mysmartbike/exceptions.py @@ -0,0 +1,16 @@ +"""Exceptions for the MySmartBike integration.""" +from __future__ import annotations + +from homeassistant.exceptions import IntegrationError + + +class MySmartBikeException(IntegrationError): + """Base class for MySmartBike related errors.""" + + +class MySmartBikeAuthException(MySmartBikeException): + """Auth related errors.""" + + +class MySmartBikeAPIException(MySmartBikeException): + """Api related errors.""" diff --git a/custom_components/mysmartbike/manifest.json b/custom_components/mysmartbike/manifest.json index 26f8eef..22606db 100644 --- a/custom_components/mysmartbike/manifest.json +++ b/custom_components/mysmartbike/manifest.json @@ -6,9 +6,7 @@ "issue_tracker": "https://github.com/ReneNulschDE/ha-mysmartbike/issues", "requirements": [], "dependencies": [], - "codeowners": [ - "@ReneNulschDE" - ], + "codeowners": ["@ReneNulschDE"], "version": "0.0.1", "iot_class": "cloud_pull" -} \ No newline at end of file +} diff --git a/custom_components/mysmartbike/sensor.py b/custom_components/mysmartbike/sensor.py index 4092e0a..0a68a82 100644 --- a/custom_components/mysmartbike/sensor.py +++ b/custom_components/mysmartbike/sensor.py @@ -1,64 +1,155 @@ -""" -Sensor support for bikes with MySmartBike app. +"""Sensor support for bikes with MySmartBike app. For more details about this component, please refer to the documentation at https://github.com/ReneNulschDE/ha-mysmartbike/ """ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime import logging +from typing import Any, cast + +from homeassistant import util +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfLength +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.helpers.restore_state import RestoreEntity +from .const import DOMAIN +from .coordinator import MySmartBikeDataUpdateCoordinator +from .device import MySmartBikeDevice + +LOGGER = logging.getLogger(__name__) -from . import MySmartBikeEntity -from .const import ( - DOMAIN, - SENSORS +@dataclass(frozen=True) +class MySmartBikeSensorDescriptionMixin: + """Mixin for MySmartBike sensor.""" + + value_fn: Callable[[dict[str, Any]], str | int | float | datetime | None] + + +@dataclass(frozen=True) +class MySmartBikeSensorDescription(SensorEntityDescription, MySmartBikeSensorDescriptionMixin): + """Class describing MySmartBike sensor entities.""" + + exists_fn: Callable[[MySmartBikeDevice], bool] = lambda _: True + attr_fn: Callable[[Any | None], dict[str, Any]] = lambda _: {} + + +SENSORS: tuple[MySmartBikeSensorDescription, ...] = ( + MySmartBikeSensorDescription( + key="odometry", + native_unit_of_measurement=UnitOfLength.METERS, + suggested_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="odometry", + value_fn=lambda data: cast(int, data), + exists_fn=lambda device: bool(device.odometry), + ), # type: ignore[call-arg] + MySmartBikeSensorDescription( + key="state_of_charge", + translation_key="state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(int, data), + exists_fn=lambda device: bool(device.state_of_charge), + ), # type: ignore[call-arg] ) -LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): - """Setup the sensor platform.""" +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +): + """Set up the sensor platform.""" + coordinator: MySmartBikeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + entities = [] + + data: list[MySmartBikeDevice] = list(coordinator.data.values()) - data = hass.data[DOMAIN] + for result in data: + entities.extend( + [ + MySmartBikeSensor(result, coordinator, description) + for description in SENSORS + if description.exists_fn(result) + ] + ) + LOGGER.debug("async_setup_entry: Sensor count for creation - %s", len(entities)) + async_add_entities(entities) - if not data.client.bikes: - LOGGER.info("No Bikes found.") - return - sensor_list = [] - for bike in data.client.bikes: +class MySmartBikeSensor(CoordinatorEntity[MySmartBikeDataUpdateCoordinator], SensorEntity): + """MySmartBike Sensor.""" - for key, value in sorted(SENSORS.items()): - device = MySmartBikeSensor( - hass=hass, - data=data, - internal_name = key, - sensor_config = value, - vin = bike.finorvin - ) - sensor_list.append(device) + _attr_has_entity_name = True + entity_description: MySmartBikeSensorDescription - async_add_entities(sensor_list, True) + def __init__( + self, + device: MySmartBikeDevice, + coordinator: MySmartBikeDataUpdateCoordinator, + description: MySmartBikeSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.device = device + self._attr_unique_id = util.slugify(f"{device.serial} {description.key}") + self._attr_name = description.key + self._attr_should_poll = False + self.entity_description = description -class MySmartBikeSensor(MySmartBikeEntity, RestoreEntity): - """Representation of a Sensor.""" + if coordinator.data: + self._sensor_data = getattr(coordinator.data.get(device.serial), description.key) @property - def state(self): - """Return the state of the sensor.""" + def native_value(self) -> str | int | float | datetime | None: + """Return the state.""" + return self.entity_description.value_fn(self._sensor_data) - return self._state + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + return ( + self.entity_description.attr_fn(self.coordinator.data.get(self.device.serial)) + if self.coordinator.data + else {} + ) - async def async_added_to_hass(self) -> None: - """Call when entity about to be added to Home Assistant.""" - await super().async_added_to_hass() - # __init__ will set self._state to self._initial, only override - # if needed. - state = await self.async_get_last_state() - if state is not None: - self._state = state.state + @property + def device_info(self) -> DeviceInfo: + """Device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.serial)}, + manufacturer=self.device.manufacturer_name, + model=self.device.model_name, + name=(f"{self.device.manufacturer_name} {self.device.model_name}"), + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + if self.coordinator.data: + self._sensor_data = getattr( + self.coordinator.data.get(self.device.serial), + self.entity_description.key, + ) + self.async_write_ha_state() diff --git a/custom_components/mysmartbike/services.yaml b/custom_components/mysmartbike/services.yaml index a976904..e69de29 100644 --- a/custom_components/mysmartbike/services.yaml +++ b/custom_components/mysmartbike/services.yaml @@ -1,2 +0,0 @@ -refresh_access_token: - description: Refresh the API access token diff --git a/custom_components/mysmartbike/strings.json b/custom_components/mysmartbike/strings.json deleted file mode 100644 index e19112a..0000000 --- a/custom_components/mysmartbike/strings.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Component is configured already.", - "reauth_successful": "Reauth successful! Component reload in progress." - }, - "error": { - "cannot_connect": "cannot_connect", - "invalid_auth": "invalid_auth", - "unknown": "unknown" - }, - "step": { - "user": { - "description": "Enter your MySmartBike account details", - "title": "Set up the MySmartBike connection", - "data": { - "username": "email address", - "password": "password" - } - } - } - }, - "options": { - "step": { - "init": { - "data": { - "country_code": "Country Code", - "locale": "Locale", - "excluded_bikes": "VINs excluded (comma-sep)" - }, - "description": "Configure your options. Some changes require a restart of Home Assistant. You need to restart HA after PIN change.", - "title": "MySmartBike Options" - } - }, - "abort": { - "already_configured": "Component is configured already.", - "reauth_successful": "Reauth successful! Component reload in progress." - } - }, - "system_health": { - "info": { - "api_endpoint_reachable": "MySmartBike API endpoint reachable" - } - } -} \ No newline at end of file diff --git a/custom_components/mysmartbike/system_health.py b/custom_components/mysmartbike/system_health.py index c53c98d..f4bb715 100644 --- a/custom_components/mysmartbike/system_health.py +++ b/custom_components/mysmartbike/system_health.py @@ -1,27 +1,21 @@ """Provide info to system health.""" -from homeassistant.components import system_health +from __future__ import annotations + +from homeassistant.components import system_health # type: ignore[attr-defined] from homeassistant.core import HomeAssistant, callback -from .const import ( - DOMAIN, - WEBSOCKET_API_BASE -) +from .const import API_BASE_URI @callback -def async_register( - hass: HomeAssistant, register: system_health.SystemHealthRegistration -) -> None: +def async_register(hass: HomeAssistant, register: system_health.SystemHealthRegistration) -> None: """Register system health callbacks.""" register.async_register_info(system_health_info) async def system_health_info(hass): """Get info for the info page.""" - client = hass.data[DOMAIN] return { - "api_endpoint_reachable": system_health.async_check_can_reach_url( - hass, WEBSOCKET_API_BASE - ) + "api_endpoint_reachable": system_health.async_check_can_reach_url(hass, API_BASE_URI), } diff --git a/custom_components/mysmartbike/translations/en.json b/custom_components/mysmartbike/translations/en.json index e19112a..4acfd51 100755 --- a/custom_components/mysmartbike/translations/en.json +++ b/custom_components/mysmartbike/translations/en.json @@ -1,45 +1,45 @@ { - "config": { - "abort": { - "already_configured": "Component is configured already.", - "reauth_successful": "Reauth successful! Component reload in progress." - }, - "error": { - "cannot_connect": "cannot_connect", - "invalid_auth": "invalid_auth", - "unknown": "unknown" - }, - "step": { - "user": { - "description": "Enter your MySmartBike account details", - "title": "Set up the MySmartBike connection", - "data": { - "username": "email address", - "password": "password" - } - } - } + "config": { + "abort": { + "already_configured": "Component is configured already.", + "reauth_successful": "Reauth successful! Component reload in progress." }, - "options": { - "step": { - "init": { - "data": { - "country_code": "Country Code", - "locale": "Locale", - "excluded_bikes": "VINs excluded (comma-sep)" - }, - "description": "Configure your options. Some changes require a restart of Home Assistant. You need to restart HA after PIN change.", - "title": "MySmartBike Options" - } - }, - "abort": { - "already_configured": "Component is configured already.", - "reauth_successful": "Reauth successful! Component reload in progress." - } + "error": { + "cannot_connect": "Cannot connect to MySmartBike API. Retry later or check the logs.", + "invalid_auth": "Failed to authenticate. Verify that email and password are correct.", + "unknown": "An unknown error occurred. See log for details." }, - "system_health": { - "info": { - "api_endpoint_reachable": "MySmartBike API endpoint reachable" + "step": { + "user": { + "description": "Enter your MySmartBike account details", + "title": "Set up the MySmartBike connection", + "data": { + "username": "email address", + "password": "password" } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "Country Code", + "locale": "Locale", + "excluded_bikes": "VINs excluded (comma-sep)" + }, + "description": "Configure your options. Some changes require a restart of Home Assistant. You need to restart HA after PIN change.", + "title": "MySmartBike Options" + } + }, + "abort": { + "already_configured": "Component is configured already.", + "reauth_successful": "Reauth successful! Component reload in progress." + } + }, + "system_health": { + "info": { + "api_endpoint_reachable": "MySmartBike API endpoint reachable" } -} \ No newline at end of file + } +} diff --git a/custom_components/mysmartbike/webapi.py b/custom_components/mysmartbike/webapi.py new file mode 100644 index 0000000..c149bfc --- /dev/null +++ b/custom_components/mysmartbike/webapi.py @@ -0,0 +1,172 @@ +"""The MySmartBike WebAPI.""" +from __future__ import annotations + +from datetime import datetime +import logging +import traceback +from typing import Any + +from aiohttp import ClientResponseError, ClientSession, ClientTimeout +from aiohttp.client_exceptions import ClientError + +from .const import ( + API_BASE_URI, + API_USER_AGENT, + API_X_APP, + API_X_PLATFORM, + API_X_THEME, + API_X_VERSION, + DISABLE_SSL_CERT_CHECK, + SYSTEM_PROXY, +) +from .device import MySmartBikeDevice +from .exceptions import MySmartBikeAuthException + +LOGGER = logging.getLogger(__name__) + + +class MySmartBikeWebApi: + """Define the WebAPI object.""" + + def __init__( + self, + session: ClientSession, + username: str, + password: str, + ) -> None: + """Initialize.""" + self._session: ClientSession = session + self._username: str = username + self._password: str = password + self.initialized: bool = False + self.token: str = "" + + async def login(self) -> bool: + """Get the login token from MySmartBike cloud.""" + LOGGER.debug("login: Start") + + data = f"password={self._password}&contents_id=&email={self._username}" + + headers = {"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"} + + login_response = await self._request( + "post", "/api/v1/users/login", data=data, headers=headers + ) + + if login_response and login_response.get("status") and login_response.get("status") == 200: + LOGGER.debug("login: Success - %s", login_response) + self.token = login_response.get("data").get("token") + return True + + if login_response and login_response.get("status"): + LOGGER.warning("login: auth error - %s", login_response) + raise MySmartBikeAuthException(login_response) + + return False + + async def get_device_list(self) -> dict[str, MySmartBikeDevice]: + """Pull bikes and generate device list.""" + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.token}", + } + + _response = await self._request("get", "/api/v1/objects/me?limit=5", headers=headers) + if _response and _response.get("status") and _response.get("status") == 200: + # LOGGER.debug("get_device_list: %s", _response) + + return await self._build_device_list(_response) + + LOGGER.debug("get_device_list: other error - %s") + return {} + + async def _request( + self, + method: str, + endpoint: str, + ignore_errors: bool = False, + **kwargs, + ) -> Any: + """Make a request against the API.""" + + url = f"{API_BASE_URI}{endpoint}" + + if "headers" not in kwargs: + kwargs.setdefault("headers", {}) + + kwargs.setdefault("proxy", SYSTEM_PROXY) + kwargs.setdefault("ssl", DISABLE_SSL_CERT_CHECK) + + kwargs["headers"].update( + { + "Accept": "application/json", + "User-Agent": API_USER_AGENT, + "Accept-Language": "de-DE", + "X-Theme": API_X_THEME, + "X-App": API_X_APP, + "X-Platform": API_X_PLATFORM, + "X-Version": API_X_VERSION, + } + ) + + use_running_session = self._session and not self._session.closed + + if use_running_session: + session = self._session + else: + session = ClientSession(timeout=ClientTimeout(total=30)) + + try: + # async with session.request(method, url, proxy=proxy, ssl=False, **kwargs) as resp: + if "url" in kwargs: + async with session.request(method, **kwargs) as resp: + # resp.raise_for_status() + return await resp.json(content_type=None) + else: + async with session.request(method, url, **kwargs) as resp: + resp.raise_for_status() + return await resp.json(content_type=None) + + except ClientResponseError as err: + LOGGER.debug(traceback.format_exc()) + if not ignore_errors: + raise MySmartBikeAuthException from err + return None + except ClientError as err: + LOGGER.debug(traceback.format_exc()) + if not ignore_errors: + raise ClientError from err + return None + except Exception: + LOGGER.debug(traceback.format_exc()) + finally: + if not use_running_session: + await session.close() + + async def _build_device_list(self, data) -> dict[str, MySmartBikeDevice]: + root_objects: dict[str, MySmartBikeDevice] = {} + for rbike in data["data"]: + state_of_charge: int | None = None + remaining_capacity: int | None = None + + for obj in rbike["object_tree"]: + if "state_of_charge" in obj: + state_of_charge = obj["state_of_charge"] + if "remaining_capacity" in obj: + remaining_capacity = obj["remaining_capacity"] + + root_object = MySmartBikeDevice( + rbike["serial"], + rbike["odometry"], + rbike["object_model"]["brand"]["alias"], + rbike.get("object_model").get("model_name"), + rbike.get("longitude"), + rbike.get("latitude"), + datetime.strptime(rbike.get("last_position_date"), "%Y-%m-%d %H:%M:%S"), + state_of_charge, + remaining_capacity, + ) + + root_objects[root_object.serial] = root_object + return root_objects diff --git a/hacs.json b/hacs.json index b41fbc4..617d961 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,7 @@ { - "name": "MySmartBike", - "domains": ["sensor", "device_tracker"], - "iot_class": "Cloud Pull" -} \ No newline at end of file + "name": "MySmartBike", + "domains": ["sensor", "device_tracker"], + "render_readme": true, + "zip_release": false, + "filename": "ha_mysmartbike.zip" +} diff --git a/info.md b/info.md deleted file mode 100644 index 9425f20..0000000 --- a/info.md +++ /dev/null @@ -1,83 +0,0 @@ - - -# MySmartBike -[![HassFest tests](https://github.com/renenulschde/ha-mysmartbike/workflows/Validate%20with%20hassfest/badge.svg)](https://developers.home-assistant.io/blog/2020/04/16/hassfest)![Validate with HACS](https://github.com/ReneNulschDE/ha-mysmartbike/workflows/Validate%20with%20HACS/badge.svg) - - -MySmartBike (Male powered e-bikes) platform as a Custom Component for Home Assistant. - -IMPORTANT: - -* Please login once in the MySmartBike IOS or Android app before you install this component. Make sure you connected your bike in the app - -* Tested Countries: DE - -### Installation -* First: This is not a Home Assistant Add-On. It's a custom component. -* There are two ways to install. First you can download the folder custom_component and copy it into your Home-Assistant config folder. Second option is to install HACS (Home Assistant Custom Component Store) and select "MercedesME 2020" from the Integrations catalog. -* [How to install a custom component?](https://www.google.com/search?q=how+to+install+custom+components+home+assistant) -* [How to install HACS?](https://hacs.xyz/docs/installation/prerequisites) -### Configuration - -Use the "Add Integration" in Home Assistant and select "MySmartBike". - -Use your MySmartBike-login email address and your Password. - -### Optional configuration values - -See Options dialog in the Integration under Home-Assistant/Configuration/Integration. - -``` -Excluded Bikes: comma-separated list of VINs. -``` - -## Available components -Depends on your own bike. - - -### Binary Sensors - -* None - -### Device Tracker - -* WIP - -### Locks - -* None - -### Sensors - -* None - -### Diagnostic Sensors -[Diagnostic sensors](https://www.home-assistant.io/blog/2021/11/03/release-202111/#entity-categorization) are hidden by default, check the devices page to see the current values - -* None - -### Services - -* None - -### Switches - -* None - -### Logging - -Set the logging to debug with the following settings in case of problems. - -``` -logger: - default: warn - logs: - custom_components.mysmartbike: debug -``` - -### Open Items -* List is too long as we are on version 0.0.1 - -### Useful links - -* [Forum post](WIP) diff --git a/mypi.ini b/mypi.ini new file mode 100644 index 0000000..5f868f9 --- /dev/null +++ b/mypi.ini @@ -0,0 +1,76 @@ +# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p mypy_config + +[mypy] +python_version = 3.11 +plugins = pydantic.mypy +show_error_codes = true +follow_imports = silent +local_partial_types = true +strict_equality = true +no_implicit_optional = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unused_ignores = true +enable_error_code = ignore-without-code, redundant-self, truthy-iterable +disable_error_code = annotation-unchecked, import-not-found, import-untyped +extra_checks = false +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + + +[mypy-custom_components.*] +check_untyped_defs = false +disallow_incomplete_defs = false +disallow_subclassing_any = false +disallow_untyped_calls = false +disallow_untyped_decorators = false +disallow_untyped_defs = false +warn_return_any = false +warn_unreachable = false +no_implicit_reexport = false +ignore_missing_imports = True +follow_imports = silent + +[mypy-simulator] +check_untyped_defs = false +disallow_incomplete_defs = false +disallow_subclassing_any = false +disallow_untyped_calls = false +disallow_untyped_decorators = false +disallow_untyped_defs = false +warn_return_any = false +warn_unreachable = false +no_implicit_reexport = false +ignore_missing_imports = True +follow_imports = silent + +[mypy-tests.*] +check_untyped_defs = false +disallow_incomplete_defs = false +disallow_subclassing_any = false +disallow_untyped_calls = false +disallow_untyped_decorators = false +disallow_untyped_defs = false +warn_return_any = false +warn_unreachable = false + + + +[mypy.voluptuous] +ignore_missing_imports = True +follow_imports = silent \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f4f54e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,456 @@ +[tool.pylint.MAIN] +py-version = "3.11" +ignore = [ + "tests", +] +extension-pkg-whitelist= ["orjson"] +# Use a conservative default here; 2 should speed up most setups and not hurt +# any too bad. Override on command line as appropriate. +jobs = 2 +init-hook = """\ + from pathlib import Path; \ + import sys; \ + + from pylint.config import find_default_config_files; \ + + sys.path.append( \ + str(Path(next(find_default_config_files())).parent.joinpath('pylint/plugins')) + ); \ + sys.path.append(".") \ + """ +load-plugins = [ + "pylint.extensions.code_style", + "pylint.extensions.typing", + "pylint_strict_informational", +] +persistent = false +extension-pkg-allow-list = [ + "av.audio.stream", + "av.stream", + "ciso8601", + "cv2", +] + +[tool.pylint.BASIC] +class-const-naming-style = "any" +good-names = [ + "_", + "ev", + "ex", + "fp", + "i", + "id", + "j", + "k", + "Run", + "ip", + "T", +] + +[tool.pylint."MESSAGES CONTROL"] +# Reasons disabled: +# format - handled by black +# locally-disabled - it spams too much +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# unused-argument - generic callbacks and setup methods create a lot of warnings +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# too-many-ancestors - it's too strict. +# wrong-import-order - isort guards this +# consider-using-f-string - str.format sometimes more readable +# --- +# Enable once current issues are fixed: +# consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) +# consider-using-assignment-expr (Pylint CodeStyle extension) +disable = [ + "format", + "abstract-method", + "broad-except", + "cyclic-import", + "duplicate-code", + "inconsistent-return-statements", + "locally-disabled", + "not-context-manager", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-boolean-expressions", + "wrong-import-order", + "consider-using-f-string", + "consider-using-namedtuple-or-dataclass", + "consider-using-assignment-expr", + + # Handled by ruff + # Ref: + "await-outside-async", # PLE1142 + "bad-str-strip-call", # PLE1310 + "bad-string-format-type", # PLE1307 + "bidirectional-unicode", # PLE2502 + "continue-in-finally", # PLE0116 + "duplicate-bases", # PLE0241 + "format-needs-mapping", # F502 + "function-redefined", # F811 + "invalid-all-format", # PLE0605 + "invalid-all-object", # PLE0604 + "invalid-character-backspace", # PLE2510 + "invalid-character-esc", # PLE2513 + "invalid-character-nul", # PLE2514 + "invalid-character-sub", # PLE2512 + "invalid-character-zero-width-space", # PLE2515 + "logging-too-few-args", # PLE1206 + "logging-too-many-args", # PLE1205 + "missing-format-string-key", # F524 + "mixed-format-string", # F506 + "no-method-argument", # N805 + "no-self-argument", # N805 + "nonexistent-operator", # B002 + "nonlocal-without-binding", # PLE0117 + "not-in-loop", # F701, F702 + "notimplemented-raised", # F901 + "return-in-init", # PLE0101 + "return-outside-function", # F706 + "syntax-error", # E999 + "too-few-format-args", # F524 + "too-many-format-args", # F522 + "too-many-star-expressions", # F622 + "truncated-format-string", # F501 + "undefined-all-variable", # F822 + "undefined-variable", # F821 + "used-prior-global-declaration", # PLE0118 + "yield-inside-async-function", # PLE1700 + "yield-outside-function", # F704 + "anomalous-backslash-in-string", # W605 + "assert-on-string-literal", # PLW0129 + "assert-on-tuple", # F631 + "bad-format-string", # W1302, F + "bad-format-string-key", # W1300, F + "bare-except", # E722 + "binary-op-exception", # PLW0711 + "cell-var-from-loop", # B023 + # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work + "duplicate-except", # B014 + "duplicate-key", # F601 + "duplicate-string-formatting-argument", # F + "duplicate-value", # F + "eval-used", # PGH001 + "exec-used", # S102 + # "expression-not-assigned", # B018, ruff catches new occurrences, needs more work + "f-string-without-interpolation", # F541 + "forgotten-debug-statement", # T100 + "format-string-without-interpolation", # F + # "global-statement", # PLW0603, ruff catches new occurrences, needs more work + "global-variable-not-assigned", # PLW0602 + "implicit-str-concat", # ISC001 + "import-self", # PLW0406 + "inconsistent-quotes", # Q000 + "invalid-envvar-default", # PLW1508 + "keyword-arg-before-vararg", # B026 + "logging-format-interpolation", # G + "logging-fstring-interpolation", # G + "logging-not-lazy", # G + "misplaced-future", # F404 + "named-expr-without-context", # PLW0131 + "nested-min-max", # PLW3301 + # "pointless-statement", # B018, ruff catches new occurrences, needs more work + "raise-missing-from", # TRY200 + # "redefined-builtin", # A001, ruff is way more stricter, needs work + "try-except-raise", # TRY302 + "unused-argument", # ARG001, we don't use it + "unused-format-string-argument", #F507 + "unused-format-string-key", # F504 + "unused-import", # F401 + "unused-variable", # F841 + "useless-else-on-loop", # PLW0120 + "wildcard-import", # F403 + "bad-classmethod-argument", # N804 + "consider-iterating-dictionary", # SIM118 + "empty-docstring", # D419 + "invalid-name", # N815 + "line-too-long", # E501, disabled globally + "missing-class-docstring", # D101 + "missing-final-newline", # W292 + "missing-function-docstring", # D103 + "missing-module-docstring", # D100 + "multiple-imports", #E401 + "singleton-comparison", # E711, E712 + "superfluous-parens", # UP034 + # "ungrouped-imports", # I001 + "unidiomatic-typecheck", # E721 + "unnecessary-direct-lambda-call", # PLC3002 + "unnecessary-lambda-assignment", # PLC3001 + "unneeded-not", # SIM208 + "useless-import-alias", # PLC0414 + "wrong-import-order", # I001 + "wrong-import-position", # E402 + "comparison-of-constants", # PLR0133 + "comparison-with-itself", # PLR0124 + "consider-alternative-union-syntax", # UP007 + "consider-merging-isinstance", # PLR1701 + "consider-using-alias", # UP006 + "consider-using-dict-comprehension", # C402 + "consider-using-generator", # C417 + "consider-using-get", # SIM401 + "consider-using-set-comprehension", # C401 + "consider-using-sys-exit", # PLR1722 + "consider-using-ternary", # SIM108 + "literal-comparison", # F632 + "property-with-parameters", # PLR0206 + "super-with-arguments", # UP008 + "too-many-branches", # PLR0912 + "too-many-return-statements", # PLR0911 + "too-many-statements", # PLR0915 + "trailing-comma-tuple", # COM818 + "unnecessary-comprehension", # C416 + "use-a-generator", # C417 + "use-dict-literal", # C406 + "use-list-literal", # C405 + "useless-object-inheritance", # UP004 + "useless-return", # PLR1711 + + # Handled by mypy + # Ref: + "abstract-class-instantiated", + "arguments-differ", + "assigning-non-slot", + "assignment-from-no-return", + "assignment-from-none", + "bad-exception-cause", + "bad-format-character", + "bad-reversed-sequence", + "bad-super-call", + "bad-thread-instantiation", + "catching-non-exception", + "comparison-with-callable", + "deprecated-class", + "dict-iter-missing-items", + "format-combined-specification", + "global-variable-undefined", + "import-error", + "inconsistent-mro", + "inherit-non-class", + "init-is-generator", + "invalid-class-object", + "invalid-enum-extension", + "invalid-envvar-value", + "invalid-format-returned", + "invalid-hash-returned", + "invalid-metaclass", + "invalid-overridden-method", + "invalid-repr-returned", + "invalid-sequence-index", + "invalid-slice-index", + "invalid-slots-object", + "invalid-slots", + "invalid-star-assignment-target", + "invalid-str-returned", + "invalid-unary-operand-type", + "invalid-unicode-codec", + "isinstance-second-argument-not-valid-type", + "method-hidden", + "misplaced-format-function", + "missing-format-argument-key", + "missing-format-attribute", + "missing-kwoa", + "no-member", + "no-value-for-parameter", + "non-iterator-returned", + "non-str-assignment-to-dunder-name", + "nonlocal-and-global", + "not-a-mapping", + "not-an-iterable", + "not-async-context-manager", + "not-callable", + "not-context-manager", + "overridden-final-method", + "raising-bad-type", + "raising-non-exception", + "redundant-keyword-arg", + "relative-beyond-top-level", + "self-cls-assignment", + "signature-differs", + "star-needs-assignment-target", + "subclassed-final-class", + "super-without-brackets", + "too-many-function-args", + "typevar-double-variance", + "typevar-name-mismatch", + "unbalanced-dict-unpacking", + "unbalanced-tuple-unpacking", + "unexpected-keyword-arg", + "unhashable-member", + "unpacking-non-sequence", + "unsubscriptable-object", + "unsupported-assignment-operation", + "unsupported-binary-operation", + "unsupported-delete-operation", + "unsupported-membership-test", + "used-before-assignment", + "using-final-decorator-in-unsupported-version", + "wrong-exception-operation", +] +enable = [ + #"useless-suppression", # temporarily every now and then to clean them up + "use-symbolic-message-instead", +] + +[tool.pylint.REPORTS] +score = false + +[tool.pylint.TYPECHECK] +ignored-classes = [ + "_CountingAttr", # for attrs +] +mixin-class-rgx = ".*[Mm]ix[Ii]n" + +[tool.pylint.FORMAT] +expected-line-ending-format = "LF" + +[tool.pylint.EXCEPTIONS] +overgeneral-exceptions = [ + "builtins.Exception", +] + +[tool.pylint.TYPING] +runtime-typing = false + +[tool.pylint.CODE_STYLE] +max-line-length-suggestions = 99 + +[tool.pytest.ini_options] +pythonpath = [ + ".", + "custom_components.ha_link2home", +] +testpaths = [ + "tests", +] +norecursedirs = [ + ".git", + "testing_config", +] +asyncio_mode = "auto" + +[tool.ruff] +target-version = "py311" +line-length = 99 + +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 + "B023", # Function definition does not bind loop variable {name} + "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "G", # flake8-logging-format + "I", # isort + "ICN001", # import concentions; {name} should be imported as {asname} + # "ISC001", # Implicitly concatenated string literals on one line + "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 + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "PLC", # pylint + "PLE", # pylint + "PLR", # pylint + "PLW", # pylint + "Q000", # Double quotes found but single quotes preferred + "RUF006", # Store a reference to the return value of asyncio.create_task + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S320", # suspicious-xmle-tree-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "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} + "SIM208", # Use {expr} instead of not (not {expr}) + "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 + "T100", # Trace found: {name} used + "T20", # flake8-print + "TID251", # Banned imports + "TRY004", # Prefer TypeError exception for invalid type + "TRY200", # Use raise from to specify exception cause + "TRY302", # Remove exception handler; error is immediately re-raised + "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 + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long + "E731", # do not assign a lambda expression, use a def + #"PLC1901", # Lots of false positives + # False positives https://github.com/astral-sh/ruff/issues/5386 + "PLC0208", # Use a sequence type instead of a `set` when iterating over values + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "UP006", # keep type annotation style as is + "UP007", # keep type annotation style as is + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + +] + +[tool.ruff.flake8-pytest-style] +fixture-parentheses = false + +[tool.ruff.flake8-import-conventions.extend-aliases] +voluptuous = "vol" + + +[tool.ruff.per-file-ignores] +"script/*" = ["T20"] + + +[tool.ruff.isort] +required-imports = ["from __future__ import annotations"] +force-sort-within-sections = true +known-first-party = [ + "homeassistant", +] +combine-as-imports = true +split-on-trailing-comma = false + + +[tool.ruff.mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..02d8a20 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,7 @@ +# This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component. +astroid==3.0.1 +mypy==1.8.0 +pre-commit==3.6.0 +pylint==3.0.3 +types-requests==2.31.0.3 +homeassistant-stubs==2024.1.3 diff --git a/requirements-tests.txt b/requirements-tests.txt new file mode 100644 index 0000000..57f7ca1 --- /dev/null +++ b/requirements-tests.txt @@ -0,0 +1,37 @@ +# This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component. +# linters such as pylint should be pinned, as new releases +# make new things fail. Manually update these pins when pulling in a +# new version + +# types-* that have versions roughly corresponding to the packages they +# contain hints for available should be kept in sync with them + +-r requirements_test_pre_commit.txt +coverage==7.3.4 +freezegun==1.3.1 +mock-open==1.4.0 +pydantic==1.10.12 +pylint-per-file-ignores==1.2.1 +pipdeptree==2.11.0 +pytest-asyncio==0.21.0 +pytest-aiohttp==1.0.5 +pytest-cov==4.1.0 +pytest-freezer==0.4.8 +pytest-socket==0.6.0 +pytest-test-groups==1.0.3 +pytest-sugar==0.9.7 +pytest-timeout==2.1.0 +pytest-unordered==0.5.2 +pytest-picked==0.5.0 +pytest-xdist==3.3.1 +pytest==7.4.3 +requests-mock==1.11.0 +respx==0.20.2 +syrupy==4.6.0 +tqdm==4.66.1 +homeassistant==2024.1.3 +SQLAlchemy==2.0.23 + +paho-mqtt==1.6.1 + +numpy==1.26.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt new file mode 100644 index 0000000..a02eed6 --- /dev/null +++ b/requirements_test_pre_commit.txt @@ -0,0 +1,5 @@ +# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit + +codespell==2.2.2 +ruff==0.1.8 +yamllint==1.32.0 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 595e911..0000000 --- a/setup.cfg +++ /dev/null @@ -1,46 +0,0 @@ - -[flake8] -exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build -doctests = True -# To work with Black -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 = homeassistant,tests -forced_separate = tests -combine_as_imports = true - -[mypy] -python_version = 3.9 -ignore_errors = true -follow_imports = silent -ignore_missing_imports = true -warn_incomplete_stub = true -warn_redundant_casts = true -warn_unused_configs = true diff --git a/simulator/ApiServer.py b/simulator/ApiServer.py new file mode 100644 index 0000000..bf9ae4e --- /dev/null +++ b/simulator/ApiServer.py @@ -0,0 +1,69 @@ +"""Simple HTTP Server to simulate the Link2Home API.""" +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, HTTPServer +import logging +import os +import sys +from urllib.parse import urlparse + +HTTP_SERVER_IP = "0.0.0.0" +HTTP_SERVER_PORT = 8001 +LOGGER = logging.getLogger(__package__) + + +class Link2HomeSimulatorServer(BaseHTTPRequestHandler): + """Simple HTTP Server to simulate the Link2Home API.""" + + def do_GET(self): + """Answer get requests.""" + self.send_response(200) + self.send_header("Content-type", "application/json;charset=UTF-8") + self.end_headers() + + parsed = urlparse(self.path) + if parsed.path == "/api/v1/users/login": + if os.path.isfile("./api/v1/users/.login-200-dev"): + with open("./api/v1/users/.login-200-dev", "rb") as file: + self.wfile.write(file.read()) + else: + with open("./api/v1/users/login-200", "rb") as file: + self.wfile.write(file.read()) + return + if parsed.path == "/api/v1/objects/me": + if os.path.isfile("./api/v1/objects/.me-200-dev"): + with open("./api/v1/objects/.me-200-dev", "rb") as file: + self.wfile.write(file.read()) # Read the file and send the contents + else: + with open("./api/v1/objects/me-200", "rb") as file: + self.wfile.write(file.read()) # Read the file and send the contents + return + LOGGER.debug("Unknown request path: %s", self.path) + + def do_POST(self): + """Answer post requests.""" + self.do_GET() + + +def set_logger(): + """Set Logger properties.""" + + fmt = "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s" + LOGGER.setLevel(logging.DEBUG) + + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter(fmt) + handler.setFormatter(formatter) + LOGGER.addHandler(handler) + + +if __name__ == "__main__": + webServer = HTTPServer((HTTP_SERVER_IP, HTTP_SERVER_PORT), Link2HomeSimulatorServer) + set_logger() + LOGGER.debug("Server started http://%s:%s", HTTP_SERVER_IP, HTTP_SERVER_PORT) + + webServer.serve_forever() + + webServer.server_close() + LOGGER.debug("Server stopped.") diff --git a/simulator/api/v1/objects/me-200 b/simulator/api/v1/objects/me-200 new file mode 100644 index 0000000..3b4e529 --- /dev/null +++ b/simulator/api/v1/objects/me-200 @@ -0,0 +1,305 @@ +{ + "status": 200, + "code": "OK", + "success": true, + "data": [ + { + "id": 1, + "serial": "1", + "object_status": "SOLD", + "diagnosis_status": "ACTIVE", + "manufacture_date": "2022-05-10 11:18:35", + "activation_date": "2022-06-11 06:55:12", + "connection_uuid": "1eb305c7-88d1-4347-a34f-811fcf997238", + "odometry": 496500, + "longitude": 12.348000000007125, + "latitude": 51.34950000004572, + "last_position_date": "2022-06-18 18:40:30", + "diagnosed": true, + "diagnosed_at": "2023-06-18 18:40:30", + "user_count": 1, + "error_count": 2, + "active_error_count": 0, + "object_model": { + "id": 520, + "model_name": "Arthur IX", + "model_year": 2021, + "segment": "URBAN", + "drive_system": "X35", + "wheel_length": 2183, + "presets": { + "sport": { + "eco": 60, + "boost": 100, + "power": 80 + }, + "urban": { + "eco": 40, + "boost": 90, + "power": 70 + }, + "efficient": { + "eco": 30, + "boost": 65, + "power": 42 + } + }, + "diagnosable": false, + "master": false, + "odometry": false, + "object_type": { + "id": 1, + "object_type_name": "Bicycle", + "alias": "bicycle", + "is_root": true, + "settings": { + "activity_minSpeed": 8, + "activity_minDistance": 20, + "activity_maxAcceleration": 1, + "activity_maxSpeedPercentage": 20 + }, + "resource": "https://my-smartbike.com/resources/object-types/object-type-1.png?date=20240105084438" + }, + "resource": "https://my-smartbike.com/resources/object-models/object-model-520.png?date=20211218153559", + "brand": { + "id": 9, + "parent_id": 1, + "alias": "Schindelhauer", + "prefix": "SB", + "brand_name": "Schindelhauer", + "email_technical": "service@schindelhauerbikes.com", + "email_commercial": "info@schindelhauerbikes.com", + "dns": "schindelhauer.my-smartbike.com", + "theme": { + "card_bg": "#323338", + "primary": "#323338", + "card_text": "#FFFFFF", + "primary_dark": "#E6E6FF", + "card_inverse_logo": true + }, + "resources": { + "logo": "https://my-smartbike.com/resources/brands/Schindelhauer_logo.png?date=20210128122837", + "logo_inverse": "https://my-smartbike.com/resources/brands/Schindelhauer_logo_inverse.png?date=20210128122840", + "logo_mini": "https://my-smartbike.com/resources/brands/Schindelhauer_logo_mini.png?date=20210812182256", + "login_bg_1": "https://my-smartbike.com/resources/brands/Schindelhauer_login_bg_1.jpg?date=20210812182304", + "login_bg_2": "https://my-smartbike.com/resources/brands/Schindelhauer_login_bg_2.jpg?date=20210812182306", + "login_bg_3": "https://my-smartbike.com/resources/brands/Schindelhauer_login_bg_3.jpg?date=20210812182308", + "login_screen": "https://my-smartbike.com/resources/brands/Schindelhauer_login_screen.jpg?date=20210812182259" + } + }, + "presets_v2": { + "modes": [ + { + "name": "efficient", + "levels": [ + { + "level": 1, + "peak_power": 30 + }, + { + "level": 2, + "peak_power": 42 + }, + { + "level": 3, + "peak_power": 65 + } + ] + }, + { + "name": "urban", + "levels": [ + { + "level": 1, + "peak_power": 40 + }, + { + "level": 2, + "peak_power": 70 + }, + { + "level": 3, + "peak_power": 90 + } + ] + }, + { + "name": "sport", + "levels": [ + { + "level": 1, + "peak_power": 60 + }, + { + "level": 2, + "peak_power": 80 + }, + { + "level": 3, + "peak_power": 100 + } + ] + } + ] + }, + "thumbnail": "https://my-smartbike.com/resources/object-models/object-model-520-thumbnail.png?date=20211218153559" + }, + "object_tree": [ + { + "id": 2, + "parent_id": 1, + "serial": "2", + "firmware": "48", + "object_status": "SOLD", + "diagnosis_status": "ACTIVE", + "manufacture_date": "2022-05-10 11:18:35", + "activation_date": "2022-06-11 06:55:12", + "diagnosed": false, + "remaining_capacity": 190.7, + "object_model": { + "id": 1038, + "object_type": { + "id": 8, + "object_type_name": "Battery", + "alias": "battery", + "is_root": false, + "resource": "https://my-smartbike.com/resources/object-types/object-type-8.png?date=20200723173545" + }, + "resource": "https://my-smartbike.com/resources/object-models/object-model-1038.png?date=20211218153638", + "brand": { + "id": 1, + "alias": "MAHLE", + "prefix": "EM", + "brand_name": "MAHLE", + "email_technical": "mahle.support@heloo.com", + "email_commercial": "mahle.support@heloo.com", + "company_name": "MAHLE Smartbike Systems SLU", + "theme": { + "card_bg": "#003478", + "primary": "#003478", + "card_text": "#FFFFFF", + "primary_dark": "#1d78c5", + "card_inverse_logo": true + }, + "resources": { + "logo": "https://my-smartbike.com/resources/brands/MAHLE_logo.png?date=20210127104459", + "logo_inverse": "https://my-smartbike.com/resources/brands/MAHLE_logo_inverse.png?date=20210127104459", + "logo_mini": "https://my-smartbike.com/resources/brands/MAHLE_logo_mini.png?date=20210127104459", + "login_bg_1": "https://my-smartbike.com/media/images/brands/login_bg_1.jpg", + "login_bg_2": "https://my-smartbike.com/media/images/brands/login_bg_2.jpg", + "login_bg_3": "https://my-smartbike.com/media/images/brands/login_bg_3.jpg", + "login_bg_4": "https://my-smartbike.com/media/images/brands/login_bg_4.jpg", + "login_bg_5": "https://my-smartbike.com/media/images/brands/login_bg_5.jpg" + } + }, + "thumbnail": "https://my-smartbike.com/resources/object-models/object-model-1038-thumbnail.png?date=20211218153638" + }, + "state_of_charge": 78 + }, + { + "id": 3, + "parent_id": 1, + "serial": "3", + "firmware": "iM2EM126", + "object_status": "SOLD", + "diagnosis_status": "ACTIVE", + "manufacture_date": "2022-05-10 11:18:35", + "activation_date": "2022-06-11 06:55:12", + "diagnosed": true, + "diagnosed_at": "2023-06-18 18:40:30", + "object_model": { + "id": 1175, + "object_type": { + "id": 6, + "object_type_name": "Joystick", + "alias": "joystick", + "is_root": false, + "resource": "https://my-smartbike.com/resources/object-types/object-type-6.png?date=20200723173630" + }, + "resource": "https://my-smartbike.com/resources/object-models/object-model-1175.png?date=20211218153719", + "brand": { + "id": 1, + "alias": "MAHLE", + "prefix": "EM", + "brand_name": "MAHLE", + "email_technical": "mahle.support@heloo.com", + "email_commercial": "mahle.support@heloo.com", + "company_name": "MAHLE Smartbike Systems SLU", + "theme": { + "card_bg": "#003478", + "primary": "#003478", + "card_text": "#FFFFFF", + "primary_dark": "#1d78c5", + "card_inverse_logo": true + }, + "resources": { + "logo": "https://my-smartbike.com/resources/brands/MAHLE_logo.png?date=20210127104459", + "logo_inverse": "https://my-smartbike.com/resources/brands/MAHLE_logo_inverse.png?date=20210127104459", + "logo_mini": "https://my-smartbike.com/resources/brands/MAHLE_logo_mini.png?date=20210127104459", + "login_bg_1": "https://my-smartbike.com/media/images/brands/login_bg_1.jpg", + "login_bg_2": "https://my-smartbike.com/media/images/brands/login_bg_2.jpg", + "login_bg_3": "https://my-smartbike.com/media/images/brands/login_bg_3.jpg", + "login_bg_4": "https://my-smartbike.com/media/images/brands/login_bg_4.jpg", + "login_bg_5": "https://my-smartbike.com/media/images/brands/login_bg_5.jpg" + } + }, + "thumbnail": "https://my-smartbike.com/resources/object-models/object-model-1175-thumbnail.png?date=20211218153719" + } + }, + { + "id": 4, + "parent_id": 1, + "serial": "4", + "firmware": "4107", + "object_status": "SOLD", + "diagnosis_status": "ACTIVE", + "manufacture_date": "2022-05-10 11:18:35", + "activation_date": "2022-06-11 06:55:12", + "diagnosed": true, + "diagnosed_at": "2023-06-18 18:40:30", + "object_model": { + "id": 1174, + "object_type": { + "id": 4, + "object_type_name": "Controller", + "alias": "controller", + "is_root": false, + "resource": "https://my-smartbike.com/resources/object-types/object-type-4.png?date=20200723173612" + }, + "resource": "https://my-smartbike.com/resources/object-models/object-model-1174.png?date=20211218153718", + "brand": { + "id": 1, + "alias": "MAHLE", + "prefix": "EM", + "brand_name": "MAHLE", + "email_technical": "mahle.support@heloo.com", + "email_commercial": "mahle.support@heloo.com", + "company_name": "MAHLE Smartbike Systems SLU", + "theme": { + "card_bg": "#003478", + "primary": "#003478", + "card_text": "#FFFFFF", + "primary_dark": "#1d78c5", + "card_inverse_logo": true + }, + "resources": { + "logo": "https://my-smartbike.com/resources/brands/MAHLE_logo.png?date=20210127104459", + "logo_inverse": "https://my-smartbike.com/resources/brands/MAHLE_logo_inverse.png?date=20210127104459", + "logo_mini": "https://my-smartbike.com/resources/brands/MAHLE_logo_mini.png?date=20210127104459", + "login_bg_1": "https://my-smartbike.com/media/images/brands/login_bg_1.jpg", + "login_bg_2": "https://my-smartbike.com/media/images/brands/login_bg_2.jpg", + "login_bg_3": "https://my-smartbike.com/media/images/brands/login_bg_3.jpg", + "login_bg_4": "https://my-smartbike.com/media/images/brands/login_bg_4.jpg", + "login_bg_5": "https://my-smartbike.com/media/images/brands/login_bg_5.jpg" + } + }, + "thumbnail": "https://my-smartbike.com/resources/object-models/object-model-1174-thumbnail.png?date=20211218153718" + } + } + ] + } + ], + "offset": 0, + "limit": 5, + "total": 1 +} \ No newline at end of file diff --git a/simulator/api/v1/users/login-200 b/simulator/api/v1/users/login-200 new file mode 100644 index 0000000..5a95d6c --- /dev/null +++ b/simulator/api/v1/users/login-200 @@ -0,0 +1,89 @@ +{ + "status": 200, + "code": "OK", + "success": true, + "data": { + "id": 1, + "public_id": "7435a108-cbc5-4996-b8f2-f5f116f0827d", + "parent_users_id": null, + "strava_id": null, + "firstname": null, + "surname": null, + "birthdate": null, + "height": null, + "weight": null, + "gender": null, + "email": "user@domain.local", + "languages_id": "de_DE", + "phone_prefix": null, + "phone_number": null, + "status": "ACTIVE", + "accept_mail": false, + "accept_share": false, + "last_activity": "2024-01-02 16:38:14", + "preferences": { + "theme": "SYSTEM", + "map_style": "AUTOMATIC", + "object_type": "COMPATIBLE", + "unit_height": "CM", + "unit_weight": "KG", + "unit_distance": "KM", + "behavior_every": 5, + "unit_elevation": "M", + "map_headingMode": "ROUTE", + "map_showCompass": false, + "biometry_enabled": false, + "unit_temperature": "C", + "health_sensitivity": "MEDIUM", + "language_direction": "LTR", + "activity_difficulty": "MEDIUM", + "activity_visibility": "PRIVATE", + "activity_terrainType": "HIGH_QUALITY_ROAD", + "map_simulateNavigation": false, + "health_enableAutoAssist": false, + "health_heartRateMonitor": false, + "health_maximumHeartRate": null, + "behavior_enableAutoPause": false, + "alert_nutrition_food_every": 30, + "map_voiceNavegationAdvices": false, + "activity_autoUploadToStrava": "AUTO", + "alert_nutrition_drink_every": 30, + "alert_nutrition_food_enabled": false, + "behavior_enableAutoRecording": false, + "alert_activity_distance_every": 10, + "alert_nutrition_drink_enabled": false, + "behavior_preferredOrientation": "PORTRAIT", + "health_overrideRecommendedMHR": false, + "alert_activity_distance_enabled": false, + "alert_activity_noReturn_enabled": false, + "alert_weather_weatherAlert_every": 30, + "alert_activity_motorPower_enabled": false, + "behavior_enableSummaryAudioAdvice": false, + "alert_weather_weatherAlert_enabled": false, + "alert_activity_motorPower_percentage": 80, + "alert_heartRate_maxHeartRate_enabled": false, + "alert_heartRate_maxHeartRate_maximum": null, + "behavior_enableContextualAudioAdvice": false + }, + "country_code": "DE", + "privacy_zones": [], + "accept_health": false, + "accept_mail_brands": false, + "avatar": "https://my-smartbike.com/media/images/avatar.png", + "object_count": 1, + "brands": [], + "profiles": [ + { + "id": 3, + "alias": "guest", + "description": "All authenticated users", + "profile_name": "Guest", + "roles": [] + } + ], + "roles": [], + "token": "token-value", + "country_name": "Deutschland", + "strava_linked": false + } +} \ No newline at end of file diff --git a/simulator/api/v1/users/login-401 b/simulator/api/v1/users/login-401 new file mode 100644 index 0000000..7587930 --- /dev/null +++ b/simulator/api/v1/users/login-401 @@ -0,0 +1 @@ +{"status":401,"code":"INVALID_CREDENTIALS","success":false,"data":[{"msg":"Username oder Passwort ungültig."}]} diff --git a/simulator/api/v1/users/login-409 b/simulator/api/v1/users/login-409 new file mode 100644 index 0000000..49f80b7 --- /dev/null +++ b/simulator/api/v1/users/login-409 @@ -0,0 +1 @@ +{"status":409,"code":"LOGIN_ATTEMPTS_EXCEEDED","success":false,"data":[{"msg":"Sie haben die Anzahl der fehlgeschlagenen Anmeldeversuche überschritten (5). Versuchen Sie es innerhalb von 10 Minuten erneut."}]}