diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..637ab31 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,190 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject +### VirtualEnv template +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# idea folder, uncomment if you don't need it +.idea + +# Test results +junit/ + +.github/ \ No newline at end of file diff --git a/.github/workflows/build_and_test_python.yml b/.github/workflows/build_and_test_python.yml index 4ae33f6..c4a7d37 100644 --- a/.github/workflows/build_and_test_python.yml +++ b/.github/workflows/build_and_test_python.yml @@ -15,28 +15,47 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.12" ] + python-version: [ "3.12", "3.13" ] steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-in-project: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }} + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - - name: Test with pytest + poetry install --no-interaction --no-root + + - name: Install library + run: poetry install --no-interaction + + - name: Run tests with coverage run: | - python -m pip install pytest pytest-cov - python -m pytest --doctest-modules --junitxml=junit/test-results-${{ matrix.python-version }}.xml + poetry run pytest tests --cov --junit-xml=junit/test-results-${{ matrix.python-version }}.xml + - name: Lint with Ruff run: | - python -m pip install ruff - ruff check --output-format=github . + poetry run ruff check . --output-format=github continue-on-error: true + - name: Surface failing tests uses: pmeier/pytest-results-action@main with: diff --git a/.github/workflows/build_python_mqtt_dev_images.yml b/.github/workflows/build_python_mqtt_dev_images.yml index e74e7e3..896e7f1 100644 --- a/.github/workflows/build_python_mqtt_dev_images.yml +++ b/.github/workflows/build_python_mqtt_dev_images.yml @@ -52,8 +52,10 @@ jobs: if: env.DOCKERHUB_ORGANIZATION == null with: context: . - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: linux/amd64,linux/arm64 push: true + cache-from: type=gha + cache-to: type=gha,mode=max tags: | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:dev ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:${{ env.RELEASE_VERSION }} @@ -63,8 +65,10 @@ jobs: if: env.DOCKERHUB_ORGANIZATION != null with: context: . - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: linux/amd64,linux/arm64 push: true + cache-from: type=gha + cache-to: type=gha,mode=max tags: | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:dev ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:${{ env.RELEASE_VERSION }} diff --git a/.github/workflows/build_python_mqtt_images.yml b/.github/workflows/build_python_mqtt_images.yml index e94f1c0..4c6e265 100644 --- a/.github/workflows/build_python_mqtt_images.yml +++ b/.github/workflows/build_python_mqtt_images.yml @@ -52,7 +52,7 @@ jobs: if: env.DOCKERHUB_ORGANIZATION == null with: context: . - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: linux/amd64,linux/arm64 push: true tags: | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:latest @@ -63,7 +63,7 @@ jobs: if: env.DOCKERHUB_ORGANIZATION != null with: context: . - platforms: linux/amd64,linux/arm64,linux/arm/v7 + platforms: linux/amd64,linux/arm64 push: true tags: | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:latest diff --git a/.gitignore b/.gitignore index 4982003..e2dc45d 100644 --- a/.gitignore +++ b/.gitignore @@ -185,4 +185,5 @@ fabric.properties .idea # Test results -junit/ \ No newline at end of file +junit/ +.run/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b5b6fd3..95ac541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Change Log +## 0.9.0 + +### What's Changed +* Compatibiltity with the latest mobile app release +* #292: Push HA discovery again when HA connects to broker by @krombel in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/294 +* add random delay before pushing discovery by @krombel in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/295 +* #296: Detect charging from BMS instead of car state by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/298 +* Mark 'Last Charge SoC kWh' in HA as a TOTAL_INCRESING sensor so that it can be used in Energy Dashboard by @nanomad +* Expose journey ID to Home Assistant by @nanomad +* Internally mark the car as "shutdown" only after the car state changes. +This avoids looking for a "charging started" event as soon as the charging is completed by @nanomad +* Keep retrying initial login by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/319 +* Publish window status only if mileage is reported properly by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/320 + +### New Contributors +* @krombel made their first contribution in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/294 + +**Full Changelog**: https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/compare/0.7.1...0.9.0 + ## 0.7.1 ## What's changed diff --git a/Dockerfile b/Dockerfile index 76390c7..f3af458 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,50 @@ -FROM python:3.12-slim +ARG POETRY_VERSION=2.1.2 +ARG PYTHON_VERSION=3.12 + +FROM weastur/poetry:${POETRY_VERSION}-python-${PYTHON_VERSION} AS builder + +ENV POETRY_HOME=/opt/poetry +ENV POETRY_NO_INTERACTION=1 +ENV POETRY_VIRTUALENVS_IN_PROJECT=1 +ENV POETRY_VIRTUALENVS_CREATE=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +# Tell Poetry where to place its cache and virtual environment +ENV POETRY_CACHE_DIR=/opt/.cache + +# Install build-time deps for poetry and FFI +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + g++ \ + gcc \ + libffi-dev \ + libssl-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/app + +# --- Reproduce the environment --- +# You can comment the following two lines if you prefer to manually install +# the dependencies from inside the container. +COPY pyproject.toml poetry.lock /usr/src/app/ + +# Install the dependencies and clear the cache afterwards. +# This may save some MBs. +RUN --mount=type=tmpfs,target=/root/.cargo poetry install --no-root && rm -rf $POETRY_CACHE_DIR + +# Now let's build the runtime image from the builder. +# We'll just copy the env and the PATH reference. +FROM python:${PYTHON_VERSION}-slim AS runtime WORKDIR /usr/src/app -COPY requirements.txt ./ -RUN apt-get update \ - && apt-get install -y --no-install-recommends gcc python3-dev \ - && rm -rf /var/lib/apt/lists/* \ - && pip install --no-cache-dir -r requirements.txt \ - && apt-get purge -y --auto-remove gcc python3-dev -# the --no-install-recommends helps limit some of the install so that you can be more explicit about what gets installed +ENV VIRTUAL_ENV=/usr/src/app/.venv +ENV PATH="/usr/src/app/.venv/bin:$PATH" -COPY . . +COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} +COPY src/ . +COPY examples/ . -CMD [ "python", "./mqtt_gateway.py"] +CMD [ "python", "./main.py"] \ No newline at end of file diff --git a/README.md b/README.md index 3947fa3..5d52f1d 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ An [openWB](https://openwb.de) charging station is capable of providing informat need to provide the configuration in the file charging-stations.json. A sample configuration for two cars connected to an openWB charging station would be the following. -Check-out the [sample file](charging-stations.json.sample) +Check-out the [sample file](examples\charging-stations.json.sample) The key-value pairs in the JSON express the following: diff --git a/configuration/argparse_extensions.py b/configuration/argparse_extensions.py deleted file mode 100644 index ee25870..0000000 --- a/configuration/argparse_extensions.py +++ /dev/null @@ -1,50 +0,0 @@ -import argparse -import os -from typing import Callable - - -class EnvDefault(argparse.Action): - def __init__(self, envvar, required=True, default=None, **kwargs): - if ( - envvar in os.environ - and os.environ[envvar] - ): - default = os.environ[envvar] - if required and default: - required = False - super(EnvDefault, self).__init__(default=default, required=required, **kwargs) - - def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, self.dest, values) - - -def cfg_value_to_dict(cfg_value: str, result_map: dict, value_type: Callable[[str], any] = str): - if ',' in cfg_value: - map_entries = cfg_value.split(',') - else: - map_entries = [cfg_value] - - for entry in map_entries: - if '=' in entry: - key_value_pair = entry.split('=') - key = key_value_pair[0] - value = key_value_pair[1] - result_map[key] = value_type(value) - - -def check_positive(value): - ivalue = int(value) - if ivalue <= 0: - raise argparse.ArgumentTypeError(f'{ivalue} is an invalid positive int value') - return ivalue - - -def check_positive_float(value): - fvalue = float(value) - if fvalue <= 0: - raise argparse.ArgumentTypeError(f'{fvalue} is an invalid positive float value') - return fvalue - - -def check_bool(value): - return str(value).lower() in ['true', '1', 'yes', 'y'] diff --git a/configuration/parser.py b/configuration/parser.py deleted file mode 100644 index 3491708..0000000 --- a/configuration/parser.py +++ /dev/null @@ -1,257 +0,0 @@ -import argparse -import json -import logging -import urllib.parse - -from configuration import Configuration, TransportProtocol -from configuration.argparse_extensions import EnvDefault, check_positive, check_bool, check_positive_float, \ - cfg_value_to_dict -from integrations.openwb.charging_station import ChargingStation - -LOG = logging.getLogger(__name__) -CHARGING_STATIONS_FILE = 'charging-stations.json' - - -def __process_charging_stations_file(config: Configuration, json_file: str): - try: - with open(json_file, 'r') as f: - data = json.load(f) - - for item in data: - charge_state_topic = item['chargeStateTopic'] - charging_value = item['chargingValue'] - vin = item['vin'] - if 'socTopic' in item: - charging_station = ChargingStation(vin, charge_state_topic, charging_value, item['socTopic']) - else: - charging_station = ChargingStation(vin, charge_state_topic, charging_value) - if 'rangeTopic' in item: - charging_station.range_topic = item['rangeTopic'] - if 'chargerConnectedTopic' in item: - charging_station.connected_topic = item['chargerConnectedTopic'] - if 'chargerConnectedValue' in item: - charging_station.connected_value = item['chargerConnectedValue'] - config.charging_stations_by_vin[vin] = charging_station - except FileNotFoundError: - LOG.warning(f'File {json_file} does not exist') - except json.JSONDecodeError as e: - LOG.exception(f'Reading {json_file} failed', exc_info=e) - - -def process_arguments() -> Configuration: - config = Configuration() - parser = argparse.ArgumentParser(prog='MQTT Gateway') - try: - parser.add_argument('-m', '--mqtt-uri', - help='The URI to the MQTT Server. Environment Variable: MQTT_URI,' - + 'TCP: tcp://mqtt.eclipseprojects.io:1883 ' - + 'WebSocket: ws://mqtt.eclipseprojects.io:9001' - + 'TLS: tls://mqtt.eclipseprojects.io:8883', - dest='mqtt_uri', required=False, action=EnvDefault, envvar='MQTT_URI') - parser.add_argument('--mqtt-server-cert', - help='Path to the server certificate authority file in PEM format for TLS.', - dest='tls_server_cert_path', required=False, action=EnvDefault, envvar='MQTT_SERVER_CERT') - parser.add_argument('--mqtt-user', help='The MQTT user name. Environment Variable: MQTT_USER', - dest='mqtt_user', required=False, action=EnvDefault, envvar='MQTT_USER') - parser.add_argument('--mqtt-password', - help='The MQTT password. Environment Variable: MQTT_PASSWORD', dest='mqtt_password', - required=False, action=EnvDefault, envvar='MQTT_PASSWORD') - parser.add_argument('--mqtt-client-id', help='The MQTT Client Identifier. Environment Variable: ' - + 'MQTT_CLIENT_ID ' - + 'Default is saic-python-mqtt-gateway', - default='saic-python-mqtt-gateway', dest='mqtt_client_id', required=False, - action=EnvDefault, envvar='MQTT_CLIENT_ID') - parser.add_argument('--mqtt-topic-prefix', - help='MQTT topic prefix. Environment Variable: MQTT_TOPIC Default is saic', default='saic', - dest='mqtt_topic', required=False, action=EnvDefault, envvar='MQTT_TOPIC') - parser.add_argument('-s', '--saic-rest-uri', - help='The SAIC uri. Environment Variable: SAIC_REST_URI Default is the European ' - 'Production Endpoint: https://tap-eu.soimt.com', - default='https://gateway-mg-eu.soimt.com/api.app/v1/', dest='saic_rest_uri', required=False, - action=EnvDefault, - envvar='SAIC_REST_URI') - parser.add_argument('-u', '--saic-user', - help='The SAIC user name. Environment Variable: SAIC_USER', dest='saic_user', required=True, - action=EnvDefault, envvar='SAIC_USER') - parser.add_argument('-p', '--saic-password', - help='The SAIC password. Environment Variable: SAIC_PASSWORD', dest='saic_password', - required=True, action=EnvDefault, envvar='SAIC_PASSWORD') - parser.add_argument('--saic-phone-country-code', - help='The SAIC phone country code. Environment Variable: SAIC_PHONE_COUNTRY_CODE', - dest='saic_phone_country_code', required=False, action=EnvDefault, - envvar='SAIC_PHONE_COUNTRY_CODE') - parser.add_argument('--saic-region', '--saic-region', - help='The SAIC API region. Environment Variable: SAIC_REGION', default='eu', - dest='saic_region', required=False, action=EnvDefault, envvar='SAIC_REGION') - parser.add_argument('--saic-tenant-id', - help='The SAIC API tenant id. Environment Variable: SAIC_TENANT_ID', default='459771', - dest='saic_tenant_id', required=False, action=EnvDefault, - envvar='SAIC_TENANT_ID') - parser.add_argument('--battery-capacity-mapping', help='The mapping of VIN to full batteryc' - + ' apacity. Multiple mappings can be provided separated' - + ' by , Example: LSJXXXX=54.0,LSJYYYY=64.0,' - + ' Environment Variable: BATTERY_CAPACITY_MAPPING', - dest='battery_capacity_mapping', required=False, action=EnvDefault, - envvar='BATTERY_CAPACITY_MAPPING') - parser.add_argument('--charging-stations-json', - help='Custom charging stations configuration file name', dest='charging_stations_file', - required=False, action=EnvDefault, envvar='CHARGING_STATIONS_JSON') - parser.add_argument('--saic-relogin-delay', - help='How long to wait before attempting another login to the SAIC API. Environment ' - 'Variable: SAIC_RELOGIN_DELAY', dest='saic_relogin_delay', required=False, - action=EnvDefault, envvar='SAIC_RELOGIN_DELAY', type=check_positive) - parser.add_argument('--ha-discovery', - help='Enable Home Assistant Discovery. Environment Variable: HA_DISCOVERY_ENABLED', - dest='ha_discovery_enabled', required=False, - action=EnvDefault, - envvar='HA_DISCOVERY_ENABLED', default=True, type=check_bool) - parser.add_argument('--ha-discovery-prefix', - help='Home Assistant Discovery Prefix. Environment Variable: HA_DISCOVERY_PREFIX', - dest='ha_discovery_prefix', required=False, action=EnvDefault, envvar='HA_DISCOVERY_PREFIX', - default='homeassistant') - parser.add_argument('--ha-show-unavailable', - help='Show entities as Unavailable in Home Assistant when car polling fails. ' - 'Environment Variable: HA_SHOW_UNAVAILABLE', dest='ha_show_unavailable', - required=False, action=EnvDefault, envvar='HA_SHOW_UNAVAILABLE', default=True, - type=check_bool) - parser.add_argument('--messages-request-interval', - help='The interval for retrieving messages in seconds. Environment Variable: ' - 'MESSAGES_REQUEST_INTERVAL', dest='messages_request_interval', - required=False, action=EnvDefault, - envvar='MESSAGES_REQUEST_INTERVAL', default=60) - parser.add_argument('--charge-min-percentage', - help='How many % points we should try to refresh the charge state. Environment Variable: ' - 'CHARGE_MIN_PERCENTAGE', dest='charge_dynamic_polling_min_percentage', required=False, - action=EnvDefault, envvar='CHARGE_MIN_PERCENTAGE', default='1.0', type=check_positive_float) - parser.add_argument('--publish-raw-api-data', - help='Publish raw SAIC API request/response to MQTT. Environment Variable: ' - 'PUBLISH_RAW_API_DATA_ENABLED', - dest='publish_raw_api_data', required=False, - action=EnvDefault, - envvar='PUBLISH_RAW_API_DATA_ENABLED', default=False, type=check_bool) - - # ABRP Integration - parser.add_argument('--abrp-api-key', - help='The API key for the A Better Route Planer telemetry API.' - + ' Default is the open source telemetry' - + ' API key 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d.' - + ' Environment Variable: ABRP_API_KEY', - default='8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d', dest='abrp_api_key', required=False, - action=EnvDefault, envvar='ABRP_API_KEY') - parser.add_argument('--abrp-user-token', help='The mapping of VIN to ABRP User Token.' - + ' Multiple mappings can be provided seperated by ,' - + ' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl,' - + ' Environment Variable: ABRP_USER_TOKEN', - dest='abrp_user_token', required=False, action=EnvDefault, envvar='ABRP_USER_TOKEN') - parser.add_argument('--publish-raw-abrp-data', - help='Publish raw ABRP API request/response to MQTT. Environment Variable: ' - 'PUBLISH_RAW_ABRP_DATA_ENABLED', - dest='publish_raw_abrp_data', required=False, action=EnvDefault, - envvar='PUBLISH_RAW_ABRP_DATA_ENABLED', default=False, type=check_bool) - # OsmAnd Integration - parser.add_argument('--osmand-server-uri', - help='The URL of your OsmAnd Server.' - + ' Default unset' - + ' Environment Variable: OSMAND_SERVER_URI', - default=None, dest='osmand_server_uri', required=False, - action=EnvDefault, envvar='OSMAND_SERVER_URI') - parser.add_argument('--osmand-device-id', help='The mapping of VIN to OsmAnd Device ID.' - + ' Multiple mappings can be provided seperated by ,' - + ' Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl,' - ' Default is to use the car VIN as Device ID, ' - + ' Environment Variable: OSMAND_DEVICE_ID', - dest='osmand_device_id', required=False, action=EnvDefault, envvar='OSMAND_DEVICE_ID') - parser.add_argument('--publish-raw-osmand-data', - help='Publish raw ABRP OsmAnd request/response to MQTT. Environment Variable: ' - 'PUBLISH_RAW_OSMAND_DATA_ENABLED', - dest='publish_raw_osmand_data', required=False, action=EnvDefault, - envvar='PUBLISH_RAW_OSMAND_DATA_ENABLED', default=False, type=check_bool) - - args = parser.parse_args() - config.mqtt_user = args.mqtt_user - config.mqtt_password = args.mqtt_password - config.mqtt_client_id = args.mqtt_client_id - config.charge_dynamic_polling_min_percentage = args.charge_dynamic_polling_min_percentage - if args.saic_relogin_delay: - config.saic_relogin_delay = args.saic_relogin_delay - config.mqtt_topic = args.mqtt_topic - config.saic_rest_uri = args.saic_rest_uri - config.saic_region = args.saic_region - config.saic_tenant_id = str(args.saic_tenant_id) - config.saic_user = args.saic_user - config.saic_password = args.saic_password - config.saic_phone_country_code = args.saic_phone_country_code - if args.battery_capacity_mapping: - cfg_value_to_dict( - args.battery_capacity_mapping, - config.battery_capacity_map, - value_type=check_positive_float - ) - if args.charging_stations_file: - __process_charging_stations_file(config, args.charging_stations_file) - else: - __process_charging_stations_file(config, f'./{CHARGING_STATIONS_FILE}') - - config.saic_password = args.saic_password - - if args.ha_discovery_enabled is not None: - config.ha_discovery_enabled = args.ha_discovery_enabled - - if args.publish_raw_api_data is not None: - config.publish_raw_api_data = args.publish_raw_api_data - - if args.ha_show_unavailable is not None: - config.ha_show_unavailable = args.ha_show_unavailable - - if args.ha_discovery_prefix: - config.ha_discovery_prefix = args.ha_discovery_prefix - - try: - config.messages_request_interval = int(args.messages_request_interval) - except ValueError: - raise SystemExit(f'No valid integer value for messages_request_interval: {args.messages_request_interval}') - - if args.mqtt_uri is not None and len(args.mqtt_uri) > 0: - print(f'MQTT URI: {args.mqtt_uri}') - parse_result = urllib.parse.urlparse(args.mqtt_uri) - if parse_result.scheme == 'tcp': - config.mqtt_transport_protocol = TransportProtocol.TCP - elif parse_result.scheme == 'ws': - config.mqtt_transport_protocol = TransportProtocol.WS - elif parse_result.scheme == 'tls': - config.mqtt_transport_protocol = TransportProtocol.TLS - if args.tls_server_cert_path: - config.tls_server_cert_path = args.tls_server_cert_path - else: - raise SystemExit(f'No server certificate authority file provided for TLS MQTT URI {args.mqtt_uri}') - else: - raise SystemExit(f'Invalid MQTT URI scheme: {parse_result.scheme}, use tcp or ws') - - if not parse_result.port: - if config.mqtt_transport_protocol == 'tcp': - config.mqtt_port = 1883 - else: - config.mqtt_port = 9001 - else: - config.mqtt_port = parse_result.port - - config.mqtt_host = str(parse_result.hostname) - - # ABRP Integration - config.abrp_api_key = args.abrp_api_key - if args.abrp_user_token: - cfg_value_to_dict(args.abrp_user_token, config.abrp_token_map) - if args.publish_raw_abrp_data is not None: - config.publish_raw_abrp_data = args.publish_raw_abrp_data - - # OsmAnd Integration - config.osmand_server_uri = args.osmand_server_uri - if args.osmand_device_id: - cfg_value_to_dict(args.osmand_device_id, config.osmand_device_id_map) - if args.publish_raw_osmand_data is not None: - config.publish_raw_osmand_data = args.publish_raw_osmand_data - - return config - except argparse.ArgumentError as err: - parser.print_help() - SystemExit(err) diff --git a/charging-stations.json b/examples/charging-stations.json similarity index 100% rename from charging-stations.json rename to examples/charging-stations.json diff --git a/charging-stations.json.sample b/examples/charging-stations.json.sample similarity index 100% rename from charging-stations.json.sample rename to examples/charging-stations.json.sample diff --git a/charging-stations.json.sample_openWB_2.0 b/examples/charging-stations.json.sample_openWB_2.0 similarity index 100% rename from charging-stations.json.sample_openWB_2.0 rename to examples/charging-stations.json.sample_openWB_2.0 diff --git a/exceptions.py b/exceptions.py deleted file mode 100644 index 94e4a29..0000000 --- a/exceptions.py +++ /dev/null @@ -1,6 +0,0 @@ -class MqttGatewayException(Exception): - def __init__(self, msg: str): - self.message = msg - - def __str__(self): - return self.message diff --git a/handlers/message.py b/handlers/message.py deleted file mode 100644 index ec44ea1..0000000 --- a/handlers/message.py +++ /dev/null @@ -1,150 +0,0 @@ -import datetime -import logging -from typing import Union - -from saic_ismart_client_ng import SaicApi -from saic_ismart_client_ng.api.message.schema import MessageEntity -from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException - -from handlers.relogin import ReloginHandler -from handlers.vehicle import VehicleHandlerLocator -from vehicle import RefreshMode - -LOG = logging.getLogger(__name__) - - -class MessageHandler: - def __init__( - self, - gateway: VehicleHandlerLocator, - relogin_handler: ReloginHandler, - saicapi: SaicApi - ): - self.gateway = gateway - self.saicapi = saicapi - self.relogin_handler = relogin_handler - self.last_message_ts = datetime.datetime.min - self.last_message_id = None - - async def check_for_new_messages(self) -> None: - if self.__should_poll(): - try: - LOG.debug("Checking for new messages") - await self.__polling() - except Exception as e: - LOG.exception('MessageHandler poll loop failed', exc_info=e) - - async def __polling(self): - try: - all_messages = await self.__get_all_alarm_messages() - LOG.info(f'{len(all_messages)} messages received') - - new_messages = [m for m in all_messages if m.read_status != 'read'] - for message in new_messages: - LOG.info(message.details) - await self.__read_message(message) - - latest_message = self.__get_latest_message(all_messages) - if ( - latest_message is not None - and latest_message.messageId != self.last_message_id - and latest_message.message_time > self.last_message_ts - ): - self.last_message_id = latest_message.messageId - self.last_message_ts = latest_message.message_time - LOG.info( - f'{latest_message.title} detected at {latest_message.message_time}' - ) - vehicle_handler = self.gateway.get_vehicle_handler(latest_message.vin) - if vehicle_handler: - vehicle_handler.vehicle_state.notify_message(latest_message) - - # Delete vehicle start messages unless they are the latest - vehicle_start_messages = [ - m for m in all_messages - if m.messageType == '323' and m.messageId != self.last_message_id - ] - for vehicle_start_message in vehicle_start_messages: - await self.__delete_message(vehicle_start_message) - except SaicLogoutException as e: - LOG.error("API Client was logged out, waiting for a new login", exc_info=e) - self.relogin_handler.relogin() - except SaicApiException as e: - LOG.exception('MessageHandler poll loop failed during SAIC API Call', exc_info=e) - except Exception as e: - LOG.exception('MessageHandler poll loop failed unexpectedly', exc_info=e) - - async def __get_all_alarm_messages(self) -> list[MessageEntity]: - idx = 1 - all_messages = [] - while True: - try: - message_list = await self.saicapi.get_alarm_list(page_num=idx, page_size=1) - if message_list is not None and message_list.messages and len(message_list.messages) > 0: - all_messages.extend(message_list.messages) - else: - return all_messages - oldest_message = self.__get_oldest_message(all_messages) - if oldest_message is not None and oldest_message.message_time < self.last_message_ts: - return all_messages - except SaicLogoutException as e: - raise e - except Exception as e: - LOG.exception( - 'Error while fetching a message from the SAIC API, please open the app and clear them, ' - 'then report this as a bug.', - exc_info=e - ) - finally: - idx = idx + 1 - - async def __delete_message(self, latest_vehicle_start_message: MessageEntity): - try: - message_id = latest_vehicle_start_message.messageId - await self.saicapi.delete_message(message_id=message_id) - LOG.info(f'{latest_vehicle_start_message.title} message with ID {message_id} deleted') - except Exception as e: - LOG.exception('Could not delete message from server', exc_info=e) - - async def __read_message(self, message: MessageEntity): - try: - message_id = message.messageId - await self.saicapi.read_message(message_id=message_id) - LOG.info(f'{message.title} message with ID {message_id} marked as read') - except Exception as e: - LOG.exception('Could not mark message as read from server', exc_info=e) - - def __should_poll(self): - vehicle_handlers = self.gateway.vehicle_handlers or dict() - refresh_modes = [ - vh.vehicle_state.refresh_mode - for vh in vehicle_handlers.values() - if vh.vehicle_state is not None - ] - # We do not poll if we have no cars or all cars have RefreshMode.OFF - if len(refresh_modes) == 0 or all(mode == RefreshMode.OFF for mode in refresh_modes): - logging.debug("Not checking for new messages as all cars have RefreshMode.OFF") - return False - elif self.relogin_handler.relogin_in_progress: - logging.warning("Not checking for new messages as we are waiting to log back in") - return False - else: - return True - - @staticmethod - def __get_latest_message(vehicle_start_messages: list[MessageEntity]) -> Union[MessageEntity, None]: - return next(iter(reversed( - sorted( - vehicle_start_messages, - key=lambda m: m.message_time - ) - )), None) - - @staticmethod - def __get_oldest_message(vehicle_start_messages: list[MessageEntity]) -> Union[MessageEntity, None]: - return next(iter( - sorted( - vehicle_start_messages, - key=lambda m: m.message_time - ) - ), None) diff --git a/handlers/vehicle.py b/handlers/vehicle.py deleted file mode 100644 index 3bef247..0000000 --- a/handlers/vehicle.py +++ /dev/null @@ -1,475 +0,0 @@ -import asyncio -import datetime -import json -import logging -from abc import ABC -from typing import Optional - -from saic_ismart_client_ng import SaicApi -from saic_ismart_client_ng.api.vehicle.schema import VinInfo, VehicleStatusResp -from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp, ScheduledBatteryHeatingResp, \ - ChargeCurrentLimitCode, TargetBatteryCode, ScheduledChargingMode -from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException - -import mqtt_topics -from configuration import Configuration -from exceptions import MqttGatewayException -from handlers.relogin import ReloginHandler -from integrations import IntegrationException -from integrations.abrp.api import AbrpApi -from integrations.home_assistant.discovery import HomeAssistantDiscovery -from integrations.osmand.api import OsmAndApi -from mqtt_topics import SET_SUFFIX, RESULT_SUFFIX -from publisher.core import Publisher -from saic_api_listener import MqttGatewayAbrpListener, MqttGatewayOsmAndListener -from vehicle import VehicleState, RefreshMode - -LOG = logging.getLogger(__name__) - - -class VehicleHandler: - def __init__( - self, - config: Configuration, - relogin_handler: ReloginHandler, - saicapi: SaicApi, - publisher: Publisher, - vin_info: VinInfo, - vehicle_state: VehicleState - ): - self.configuration = config - self.relogin_handler = relogin_handler - self.saic_api = saicapi - self.publisher = publisher - self.vin_info = vin_info - self.vehicle_prefix = self.publisher.get_topic( - f'{self.configuration.saic_user}/vehicles/{self.vin_info.vin}', - True - ) - self.vehicle_state = vehicle_state - self.ha_discovery = HomeAssistantDiscovery(vehicle_state, vin_info, config) - - self.__setup_abrp(config, vin_info) - self.__setup_osmand(config, vin_info) - - def __setup_abrp(self, config, vin_info): - if vin_info.vin in self.configuration.abrp_token_map: - abrp_user_token = self.configuration.abrp_token_map[vin_info.vin] - else: - abrp_user_token = None - if config.publish_raw_abrp_data: - abrp_api_listener = MqttGatewayAbrpListener(self.publisher) - else: - abrp_api_listener = None - self.abrp_api = AbrpApi( - self.configuration.abrp_api_key, - abrp_user_token, - listener=abrp_api_listener - ) - - def __setup_osmand(self, config, vin_info): - if vin_info.vin in self.configuration.osmand_device_id_map: - osmand_device_id = self.configuration.osmand_device_id_map[vin_info.vin] - else: - osmand_device_id = vin_info.vin - if config.publish_raw_osmand_data: - api_listener = MqttGatewayOsmAndListener(self.publisher) - else: - api_listener = None - self.osmand_api = OsmAndApi( - server_uri=self.configuration.osmand_server_uri, - device_id=osmand_device_id, - listener=api_listener - ) - - async def handle_vehicle(self) -> None: - start_time = datetime.datetime.now() - self.vehicle_state.publish_vehicle_info() - self.vehicle_state.notify_car_activity() - - while True: - if self.__should_complete_configuration(start_time): - self.vehicle_state.configure_missing() - - if self.__should_poll(): - try: - LOG.debug('Polling vehicle status') - await self.__polling() - except SaicLogoutException as e: - self.vehicle_state.mark_failed_refresh() - LOG.error("API Client was logged out, waiting for a new login", exc_info=e) - self.relogin_handler.relogin() - except SaicApiException as e: - self.vehicle_state.mark_failed_refresh() - LOG.exception( - 'handle_vehicle loop failed during SAIC API call', - exc_info=e - ) - except IntegrationException as ae: - LOG.exception('handle_vehicle loop failed during integration processing', exc_info=ae) - except Exception as e: - self.vehicle_state.mark_failed_refresh() - LOG.exception( - 'handle_vehicle loop failed with an unexpected exception', - exc_info=e - ) - finally: - if self.configuration.ha_discovery_enabled: - self.ha_discovery.publish_ha_discovery_messages() - else: - # car not active, wait a second - await asyncio.sleep(1.0) - - async def __polling(self): - vehicle_status = await self.update_vehicle_status() - charge_status = None - - if self.vehicle_state.is_ev: - try: - charge_status = await self.update_charge_status() - except Exception as e: - LOG.exception('Error updating charge status', exc_info=e) - charge_status = None - - try: - await self.update_scheduled_battery_heating_status() - except Exception as e: - LOG.exception('Error updating scheduled battery heating status', exc_info=e) - else: - LOG.debug("Skipping EV-related updates as the vehicle is not an EV") - charge_status = None - - self.vehicle_state.update_data_conflicting_in_vehicle_and_bms(vehicle_status, charge_status) - - self.vehicle_state.mark_successful_refresh() - LOG.info('Refreshing vehicle status succeeded...') - - await self.__refresh_abrp(charge_status, vehicle_status) - await self.__refresh_osmand(charge_status, vehicle_status) - - def __should_poll(self) -> bool: - return ( - not self.relogin_handler.relogin_in_progress - and self.vehicle_state.is_complete() - and self.vehicle_state.should_refresh() - ) - - def __should_complete_configuration(self, start_time) -> bool: - return ( - not self.vehicle_state.is_complete() - and datetime.datetime.now() > start_time + datetime.timedelta(seconds=10) - ) - - async def __refresh_osmand(self, charge_status, vehicle_status): - refreshed, response = await self.osmand_api.update_osmand(vehicle_status, charge_status) - self.publisher.publish_str(f'{self.vehicle_prefix}/{mqtt_topics.INTERNAL_OSMAND}', response) - if refreshed: - LOG.info('Refreshing OsmAnd status succeeded...') - else: - LOG.info(f'OsmAnd not refreshed, reason {response}') - - async def __refresh_abrp(self, charge_status, vehicle_status): - abrp_refreshed, abrp_response = await self.abrp_api.update_abrp(vehicle_status, charge_status) - self.publisher.publish_str(f'{self.vehicle_prefix}/{mqtt_topics.INTERNAL_ABRP}', abrp_response) - if abrp_refreshed: - LOG.info('Refreshing ABRP status succeeded...') - else: - LOG.info(f'ABRP not refreshed, reason {abrp_response}') - - async def update_vehicle_status(self) -> VehicleStatusResp: - LOG.info('Updating vehicle status') - vehicle_status_response = await self.saic_api.get_vehicle_status(self.vin_info.vin) - self.vehicle_state.handle_vehicle_status(vehicle_status_response) - return vehicle_status_response - - async def update_charge_status(self) -> ChrgMgmtDataResp: - LOG.info('Updating charging status') - charge_mgmt_data = await self.saic_api.get_vehicle_charging_management_data(self.vin_info.vin) - self.vehicle_state.handle_charge_status(charge_mgmt_data) - return charge_mgmt_data - - async def update_scheduled_battery_heating_status(self) -> ScheduledBatteryHeatingResp: - LOG.info('Updating scheduled battery heating status') - scheduled_battery_heating_status = await self.saic_api.get_vehicle_battery_heating_schedule(self.vin_info.vin) - self.vehicle_state.handle_scheduled_battery_heating_status(scheduled_battery_heating_status) - return scheduled_battery_heating_status - - async def handle_mqtt_command(self, *, topic: str, payload: str): - topic, result_topic = self.__get_command_topics(topic) - try: - should_force_refresh = True - match topic: - case mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE_SET: - match payload.strip().lower(): - case 'true': - LOG.info("HV battery is now active") - self.vehicle_state.set_hv_battery_active(True) - case 'false': - LOG.info("HV battery is now inactive") - self.vehicle_state.set_hv_battery_active(False) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DRIVETRAIN_CHARGING_SET: - match payload.strip().lower(): - case 'true': - LOG.info("Charging will be started") - await self.saic_api.control_charging(self.vin_info.vin, stop_charging=False) - case 'false': - LOG.info("Charging will be stopped") - await self.saic_api.control_charging(self.vin_info.vin, stop_charging=True) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SET: - match payload.strip().lower(): - case 'true': - LOG.info("Battery heater wil be will be switched on") - response = await self.saic_api.control_battery_heating(self.vin_info.vin, enable=True) - case 'false': - LOG.info("Battery heater wil be will be switched off") - response = await self.saic_api.control_battery_heating(self.vin_info.vin, enable=False) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - if response is not None and response.ptcHeatResp is not None: - decoded = response.heating_stop_reason - self.publisher.publish_str( - self.vehicle_state.get_topic(mqtt_topics.DRIVETRAIN_BATTERY_HEATING_STOP_REASON), - f'UNKNOWN ({response.ptcHeatResp})' if decoded is None else decoded.name - ) - - case mqtt_topics.CLIMATE_REMOTE_TEMPERATURE_SET: - payload = payload.strip() - try: - LOG.info("Setting remote climate target temperature to %s", payload) - temp = int(payload) - changed = self.vehicle_state.set_ac_temperature(temp) - if changed and self.vehicle_state.is_remote_ac_running: - await self.saic_api.start_ac( - self.vin_info.vin, - temperature_idx=self.vehicle_state.get_ac_temperature_idx() - ) - - except ValueError as e: - raise MqttGatewayException(f'Error setting temperature target: {e}') - case mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE_SET: - match payload.strip().lower(): - case 'off': - LOG.info('A/C will be switched off') - await self.saic_api.stop_ac(self.vin_info.vin) - case 'blowingonly': - LOG.info('A/C will be set to blowing only') - await self.saic_api.start_ac_blowing(self.vin_info.vin) - case 'on': - LOG.info('A/C will be switched on') - await self.saic_api.start_ac( - self.vin_info.vin, - temperature_idx=self.vehicle_state.get_ac_temperature_idx() - ) - case 'front': - LOG.info("A/C will be set to front seats only") - await self.saic_api.start_front_defrost(self.vin_info.vin) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL_SET: - try: - LOG.info("Setting heated seats front left level to %s", payload) - level = int(payload.strip().lower()) - changed = self.vehicle_state.update_heated_seats_front_left_level(level) - if changed: - await self.saic_api.control_heated_seats( - self.vin_info.vin, - left_side_level=self.vehicle_state.remote_heated_seats_front_left_level, - right_side_level=self.vehicle_state.remote_heated_seats_front_right_level - ) - else: - LOG.info("Heated seats front left level not changed") - except Exception as e: - raise MqttGatewayException(f'Error setting heated seats: {e}') - - case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL_SET: - try: - LOG.info("Setting heated seats front right level to %s", payload) - level = int(payload.strip().lower()) - changed = self.vehicle_state.update_heated_seats_front_right_level(level) - if changed: - await self.saic_api.control_heated_seats( - self.vin_info.vin, - left_side_level=self.vehicle_state.remote_heated_seats_front_left_level, - right_side_level=self.vehicle_state.remote_heated_seats_front_right_level - ) - else: - LOG.info("Heated seats front right level not changed") - except Exception as e: - raise MqttGatewayException(f'Error setting heated seats: {e}') - - case mqtt_topics.DOORS_BOOT_SET: - match payload.strip().lower(): - case 'true': - LOG.info(f'We cannot lock vehicle {self.vin_info.vin} boot remotely') - case 'false': - LOG.info(f'Vehicle {self.vin_info.vin} boot will be unlocked') - await self.saic_api.open_tailgate(self.vin_info.vin) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DOORS_LOCKED_SET: - match payload.strip().lower(): - case 'true': - LOG.info(f'Vehicle {self.vin_info.vin} will be locked') - await self.saic_api.lock_vehicle(self.vin_info.vin) - case 'false': - LOG.info(f'Vehicle {self.vin_info.vin} will be unlocked') - await self.saic_api.unlock_vehicle(self.vin_info.vin) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.CLIMATE_BACK_WINDOW_HEAT_SET: - match payload.strip().lower(): - case 'off': - LOG.info('Rear window heating will be switched off') - await self.saic_api.control_rear_window_heat(self.vin_info.vin, enable=False) - case 'on': - LOG.info('Rear window heating will be switched on') - await self.saic_api.control_rear_window_heat(self.vin_info.vin, enable=True) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.CLIMATE_FRONT_WINDOW_HEAT_SET: - match payload.strip().lower(): - case 'off': - LOG.info('Front window heating will be switched off') - await self.saic_api.stop_ac(self.vin_info.vin) - case 'on': - LOG.info('Front window heating will be switched on') - await self.saic_api.start_front_defrost(self.vin_info.vin) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT_SET: - payload = payload.strip().upper() - if self.vehicle_state.target_soc is not None: - try: - LOG.info("Setting charging current limit to %s", payload) - raw_charge_current_limit = str(payload) - charge_current_limit = ChargeCurrentLimitCode.to_code(raw_charge_current_limit) - await self.saic_api.set_target_battery_soc( - self.vin_info.vin, - target_soc=self.vehicle_state.target_soc, - charge_current_limit=charge_current_limit - ) - self.vehicle_state.update_charge_current_limit(charge_current_limit) - except ValueError: - raise MqttGatewayException(f'Error setting value for payload {payload}') - else: - logging.info( - 'Unknown Target SOC: waiting for state update before changing charge current limit' - ) - raise MqttGatewayException( - f'Error setting charge current limit - SOC {self.vehicle_state.target_soc}') - case mqtt_topics.DRIVETRAIN_SOC_TARGET_SET: - payload = payload.strip() - try: - LOG.info("Setting SoC target to %s", payload) - target_battery_code = TargetBatteryCode.from_percentage(int(payload)) - await self.saic_api.set_target_battery_soc(self.vin_info.vin, target_soc=target_battery_code) - self.vehicle_state.update_target_soc(target_battery_code) - except ValueError as e: - raise MqttGatewayException(f'Error setting SoC target: {e}') - case mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE_SET: - payload = payload.strip() - try: - LOG.info("Setting charging schedule to %s", payload) - payload_json = json.loads(payload) - start_time = datetime.time.fromisoformat(payload_json['startTime']) - end_time = datetime.time.fromisoformat(payload_json['endTime']) - mode = ScheduledChargingMode[payload_json['mode'].upper()] - await self.saic_api.set_schedule_charging( - self.vin_info.vin, - start_time=start_time, - end_time=end_time, - mode=mode - ) - self.vehicle_state.update_scheduled_charging(start_time, mode) - except Exception as e: - raise MqttGatewayException(f'Error setting charging schedule: {e}') - case mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE_SET: - payload = payload.strip() - try: - LOG.info("Setting battery heating schedule to %s", payload) - payload_json = json.loads(payload) - start_time = datetime.time.fromisoformat(payload_json['startTime']) - mode = payload_json['mode'].upper() - should_enable = mode == 'ON' - changed = self.vehicle_state.update_scheduled_battery_heating(start_time, should_enable) - if changed: - if should_enable: - LOG.info(f'Setting battery heating schedule to {start_time}') - await self.saic_api.enable_schedule_battery_heating( - self.vin_info.vin, - start_time=start_time - ) - else: - LOG.info('Disabling battery heating schedule') - await self.saic_api.disable_schedule_battery_heating(self.vin_info.vin) - else: - LOG.info('Battery heating schedule not changed') - except Exception as e: - raise MqttGatewayException(f'Error setting battery heating schedule: {e}') - case mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK_SET: - match payload.strip().lower(): - case 'false': - LOG.info(f'Vehicle {self.vin_info.vin} charging cable will be unlocked') - await self.saic_api.control_charging_port_lock(self.vin_info.vin, unlock=True) - case 'true': - LOG.info(f'Vehicle {self.vin_info.vin} charging cable will be locked') - await self.saic_api.control_charging_port_lock(self.vin_info.vin, unlock=False) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.LOCATION_FIND_MY_CAR_SET: - vin = self.vin_info.vin - match payload.strip().lower(): - case 'activate': - LOG.info(f'Activating \'find my car\' with horn and lights for vehicle {vin}') - await self.saic_api.control_find_my_car(vin) - case 'lights_only': - LOG.info(f'Activating \'find my car\' with lights only for vehicle {vin}') - await self.saic_api.control_find_my_car(vin, with_horn=False, with_lights=True) - case 'horn_only': - LOG.info(f'Activating \'find my car\' with horn only for vehicle {vin}') - await self.saic_api.control_find_my_car(vin, with_horn=True, with_lights=False) - case 'stop': - LOG.info(f'Stopping \'find my car\' for vehicle {vin}') - await self.saic_api.control_find_my_car(vin, should_stop=True) - case _: - raise MqttGatewayException(f'Unsupported payload {payload}') - case _: - # set mode, period (in)-active,... - should_force_refresh = False - await self.vehicle_state.configure_by_message(topic=topic, payload=payload) - self.publisher.publish_str(result_topic, 'Success') - if should_force_refresh: - self.vehicle_state.set_refresh_mode(RefreshMode.FORCE, f'after command execution on topic {topic}') - except MqttGatewayException as e: - self.publisher.publish_str(result_topic, f'Failed: {e.message}') - LOG.exception(e.message, exc_info=e) - except SaicLogoutException as se: - self.publisher.publish_str(result_topic, f'Failed: {se.message}') - LOG.error("API Client was logged out, waiting for a new login", exc_info=se) - self.relogin_handler.relogin() - except SaicApiException as se: - self.publisher.publish_str(result_topic, f'Failed: {se.message}') - LOG.exception(se.message, exc_info=se) - except Exception as se: - self.publisher.publish_str(result_topic, 'Failed unexpectedly') - LOG.exception("handle_mqtt_command failed with an unexpected exception", exc_info=se) - - def __get_command_topics(self, topic: str) -> tuple[str, str]: - global_topic_removed = topic.removeprefix(self.configuration.mqtt_topic).removeprefix('/') - set_topic = global_topic_removed.removeprefix(self.vehicle_prefix).removeprefix('/') - result_topic = global_topic_removed.removesuffix(SET_SUFFIX).removesuffix('/') + '/' + RESULT_SUFFIX - return set_topic, result_topic - - -class VehicleHandlerLocator(ABC): - - def get_vehicle_handler(self, vin: str) -> Optional[VehicleHandler]: - raise NotImplementedError() - - @property - def vehicle_handlers(self) -> dict[str, VehicleHandler]: - raise NotImplementedError() diff --git a/integrations/__init__.py b/integrations/__init__.py deleted file mode 100644 index 93b53f7..0000000 --- a/integrations/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -class IntegrationException(Exception): - def __init__(self, integration: str, msg: str): - self.message = f'{integration}: {msg}' - - def __str__(self): - return self.message diff --git a/integrations/abrp/api.py b/integrations/abrp/api.py deleted file mode 100644 index 967a7a7..0000000 --- a/integrations/abrp/api.py +++ /dev/null @@ -1,237 +0,0 @@ -import json -import logging -from abc import ABC -from typing import Any, Tuple, Optional - -import httpx -from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus -from saic_ismart_client_ng.api.vehicle import VehicleStatusResp -from saic_ismart_client_ng.api.vehicle.schema import BasicVehicleStatus -from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp -from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus - -from integrations import IntegrationException -from utils import value_in_range, get_update_timestamp - -LOG = logging.getLogger(__name__) - - -class AbrpApiException(IntegrationException): - def __init__(self, msg: str): - super().__init__(__name__, msg) - - -class AbrpApiListener(ABC): - async def on_request(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): - pass - - async def on_response(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): - pass - - -class AbrpApi: - def __init__(self, abrp_api_key: str, abrp_user_token: str, listener: Optional[AbrpApiListener] = None) -> None: - self.abrp_api_key = abrp_api_key - self.abrp_user_token = abrp_user_token - self.__listener = listener - self.__base_uri = 'https://api.iternio.com/1/' - self.client = httpx.AsyncClient( - event_hooks={ - "request": [self.invoke_request_listener], - "response": [self.invoke_response_listener] - } - ) - - async def update_abrp(self, vehicle_status: VehicleStatusResp, charge_info: ChrgMgmtDataResp) \ - -> Tuple[bool, Any | None]: - - charge_status = None if charge_info is None else charge_info.chrgMgmtData - - if ( - self.abrp_api_key is not None - and self.abrp_user_token is not None - and vehicle_status is not None - and charge_status is not None - ): - # Request - tlm_send_url = f'{self.__base_uri}tlm/send' - data = { - # Guess the timestamp from either the API, GPS info or current machine time - 'utc': int(get_update_timestamp(vehicle_status).timestamp()), - 'soc': (charge_status.bmsPackSOCDsp / 10.0), - 'is_charging': vehicle_status.is_charging, - 'is_parked': vehicle_status.is_parked, - } - - if vehicle_status.is_parked: - data.update({ - # We assume the vehicle is stationary, we will update it later from GPS if available - 'speed': 0.0, - }) - - # Skip invalid current values reported by the API - is_valid_current = ( - charge_status.bmsPackCrntV != 1 - and value_in_range(charge_status.bmsPackCrnt, 0, 65535) - ) - if is_valid_current: - data.update({ - 'power': charge_status.decoded_power, - 'voltage': charge_status.decoded_voltage, - 'current': charge_status.decoded_current - }) - - basic_vehicle_status = vehicle_status.basicVehicleStatus - if basic_vehicle_status is not None: - data.update(self.__extract_basic_vehicle_status(basic_vehicle_status)) - - data.update(self.__extract_electric_range(basic_vehicle_status, charge_info.rvsChargeStatus)) - - gps_position = vehicle_status.gpsPosition - if gps_position is not None: - data.update(self.__extract_gps_position(gps_position)) - - headers = { - 'Authorization': f'APIKEY {self.abrp_api_key}' - } - - try: - response = await self.client.post(url=tlm_send_url, headers=headers, params={ - 'token': self.abrp_user_token, - 'tlm': json.dumps(data) - }) - await response.aread() - return True, response.text - except httpx.ConnectError as ece: - raise AbrpApiException(f'Connection error: {ece}') - except httpx.TimeoutException as et: - raise AbrpApiException(f'Timeout error {et}') - except httpx.RequestError as e: - raise AbrpApiException(f'{e}') - except httpx.HTTPError as ehttp: - raise AbrpApiException(f'HTTP error {ehttp}') - else: - return False, 'ABRP request skipped because of missing configuration' - - @staticmethod - def __extract_basic_vehicle_status(basic_vehicle_status: BasicVehicleStatus) -> dict: - data = {} - - exterior_temperature = basic_vehicle_status.exteriorTemperature - if exterior_temperature is not None and value_in_range(exterior_temperature, -127, 127): - data['ext_temp'] = exterior_temperature - mileage = basic_vehicle_status.mileage - # Skip invalid range readings - if mileage is not None and value_in_range(mileage, 1, 2147483647): - data['odometer'] = mileage / 10.0 - - return data - - @staticmethod - def __extract_gps_position(gps_position: GpsPosition) -> dict: - data = {} - - # Do not use GPS data if it is not available - if gps_position.gps_status_decoded not in [GpsStatus.FIX_2D, GpsStatus.FIX_3d]: - return data - - way_point = gps_position.wayPoint - if way_point is None: - return data - - speed = way_point.speed - if value_in_range(speed, -999, 4500): - data['speed'] = speed / 10 - - heading = way_point.heading - if value_in_range(heading, 0, 360): - data['heading'] = heading - - position = way_point.position - if position is None: - return data - - altitude = position.altitude - if value_in_range(altitude, -500, 8900): - data['elevation'] = altitude - - lat_degrees = position.latitude / 1000000.0 - lon_degrees = position.longitude / 1000000.0 - - if ( - abs(lat_degrees) <= 90 - and abs(lon_degrees) <= 180 - ): - data.update({ - 'lat': lat_degrees, - 'lon': lon_degrees, - }) - - return data - - def __extract_electric_range( - self, - basic_vehicle_status: BasicVehicleStatus | None, - charge_status: RvsChargeStatus | None - ) -> dict: - - data = {} - - range_elec_vehicle = 0.0 - if basic_vehicle_status is not None: - range_elec_vehicle = self.__parse_electric_range(raw_value=basic_vehicle_status.fuelRangeElec) - - range_elec_bms = 0.0 - if charge_status is not None: - range_elec_bms = self.__parse_electric_range(raw_value=charge_status.fuelRangeElec) - - range_elec = max(range_elec_vehicle, range_elec_bms) - if range_elec > 0: - data['est_battery_range'] = range_elec - - return data - - @staticmethod - def __parse_electric_range(raw_value) -> float: - if value_in_range(raw_value, 1, 20460): - return float(raw_value) / 10.0 - return 0.0 - - async def invoke_request_listener(self, request: httpx.Request): - if not self.__listener: - return - try: - body = None - if request.content: - try: - - body = request.content.decode("utf-8") - except Exception as e: - LOG.warning(f"Error decoding request content: {e}") - - await self.__listener.on_request( - path=str(request.url).replace(self.__base_uri, "/"), - body=body, - headers=dict(request.headers), - ) - except Exception as e: - LOG.warning(f"Error invoking request listener: {e}", exc_info=e) - - async def invoke_response_listener(self, response: httpx.Response): - if not self.__listener: - return - try: - body = await response.aread() - if body: - try: - body = body.decode("utf-8") - except Exception as e: - LOG.warning(f"Error decoding request content: {e}") - - await self.__listener.on_response( - path=str(response.url).replace(self.__base_uri, "/"), - body=body, - headers=dict(response.headers), - ) - except Exception as e: - LOG.warning(f"Error invoking request listener: {e}", exc_info=e) diff --git a/integrations/home_assistant/discovery.py b/integrations/home_assistant/discovery.py deleted file mode 100644 index 17be8cb..0000000 --- a/integrations/home_assistant/discovery.py +++ /dev/null @@ -1,908 +0,0 @@ -import json -import logging -import os -from typing import List - -import inflection as inflection -from saic_ismart_client_ng.api.vehicle.schema import VinInfo -from saic_ismart_client_ng.api.vehicle_charging import ChargeCurrentLimitCode, ScheduledChargingMode - -import mqtt_topics -from configuration import Configuration -from publisher.mqtt_publisher import MqttPublisher -from vehicle import VehicleState, RefreshMode - -LOG = logging.getLogger(__name__) -LOG.setLevel(level=os.getenv('LOG_LEVEL', 'INFO').upper()) - - -class HaCustomAvailabilityEntry: - def __init__( - self, *, - topic: str, - template: str | None = None, - payload_available: str = 'online', - payload_not_available: str = 'offline', - ): - self.__topic = topic - self.__template = template - self.__payload_available = payload_available - self.__payload_not_available = payload_not_available - - def to_dict(self): - result = { - 'topic': self.__topic, - 'payload_available': self.__payload_available, - 'payload_not_available': self.__payload_not_available - } - if self.__template: - result.update({ - 'value_template': self.__template - }) - return result - - def __key(self): - return self.__topic, self.__template, self.__payload_available, self.__payload_not_available - - def __hash__(self): - return hash(self.__key()) - - def __eq__(self, other): - if isinstance(other, HaCustomAvailabilityEntry): - return self.__key() == other.__key() - return NotImplemented - - -class HaCustomAvailabilityConfig: - def __init__( - self, *, - rules: List[HaCustomAvailabilityEntry], - mode: str = 'all', - ): - self.__rules = rules - self.__mode = mode - - def to_dict(self): - return { - 'availability': [r.to_dict() for r in set(self.__rules)], - 'availability_mode': self.__mode - } - - -class HomeAssistantDiscovery: - def __init__(self, vehicle_state: VehicleState, vin_info: VinInfo, configuration: Configuration): - self.__vehicle_state = vehicle_state - self.__vin_info = vin_info - self.__discovery_prefix = configuration.ha_discovery_prefix - self.__system_availability = HaCustomAvailabilityEntry( - topic=self.__get_system_topic(mqtt_topics.INTERNAL_LWT) - ) - if configuration.ha_show_unavailable: - self.__vehicle_availability = HaCustomAvailabilityEntry( - topic=self.__get_vehicle_topic(mqtt_topics.AVAILABLE) - ) - else: - self.__vehicle_availability = self.__system_availability - self.__system_availability_config = HaCustomAvailabilityConfig( - rules=[ - self.__system_availability - ] - ) - self.__standard_availability_config = HaCustomAvailabilityConfig( - rules=[ - self.__system_availability, - self.__vehicle_availability - ] - ) - self.published = False - - def publish_ha_discovery_messages(self): - if self.published: - LOG.debug("Skipping Home Assistant discovery messages as it was already published") - return - - if not self.__vehicle_state.is_complete(): - LOG.debug("Skipping Home Assistant discovery messages as vehicle state is not yet complete") - return - - self.__publish_ha_discovery_messages_real() - self.published = True - - def __publish_ha_discovery_messages_real(self): - - LOG.debug("Publishing Home Assistant discovery messages") - - # Gateway Control - self.__publish_select(mqtt_topics.REFRESH_MODE, 'Gateway refresh mode', [m.value for m in RefreshMode], - entity_category='config', - icon='mdi:refresh', custom_availability=self.__system_availability_config) - self.__publish_number(mqtt_topics.REFRESH_PERIOD_ACTIVE, 'Gateway active refresh period', - entity_category='config', - unit_of_measurement='s', icon='mdi:timer', min_value=30, max_value=60 * 60, step=1, - custom_availability=self.__system_availability_config) - self.__publish_number(mqtt_topics.REFRESH_PERIOD_INACTIVE, 'Gateway inactive refresh period', - entity_category='config', - unit_of_measurement='s', icon='mdi:timer', min_value=1 * 60 * 60, - max_value=5 * 24 * 60 * 60, step=1, - custom_availability=self.__system_availability_config) - self.__publish_number(mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN, 'Gateway refresh period after car shutdown', - entity_category='config', - unit_of_measurement='s', icon='mdi:timer', min_value=30, max_value=12 * 60 * 60, step=1, - custom_availability=self.__system_availability_config) - self.__publish_number(mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE, 'Gateway grace period after car shutdown', - entity_category='config', - unit_of_measurement='s', icon='mdi:timer', min_value=30, max_value=12 * 60 * 60, step=1, - custom_availability=self.__system_availability_config) - self.__publish_sensor(mqtt_topics.REFRESH_PERIOD_CHARGING, 'Gateway charging refresh period', - entity_category='diagnostic', - unit_of_measurement='s', icon='mdi:timer', - custom_availability=self.__system_availability_config) - self.__publish_sensor(mqtt_topics.REFRESH_PERIOD_ERROR, 'Gateway error refresh period', - entity_category='diagnostic', - unit_of_measurement='s', icon='mdi:timer', - custom_availability=self.__system_availability_config) - self.__publish_sensor(mqtt_topics.REFRESH_LAST_ACTIVITY, 'Last car activity', device_class='timestamp', - entity_category='diagnostic', - custom_availability=self.__system_availability_config) - self.__publish_sensor(mqtt_topics.REFRESH_LAST_CHARGE_STATE, 'Last charge state', device_class='timestamp', - entity_category='diagnostic', - custom_availability=self.__system_availability_config) - self.__publish_sensor(mqtt_topics.REFRESH_LAST_VEHICLE_STATE, 'Last vehicle state', device_class='timestamp', - entity_category='diagnostic', - custom_availability=self.__system_availability_config) - self.__publish_sensor(mqtt_topics.REFRESH_LAST_ERROR, 'Last poll error', device_class='timestamp', - entity_category='diagnostic', - custom_availability=self.__system_availability_config) - - self.__publish_sensor( - mqtt_topics.INFO_LAST_MESSAGE_CONTENT, 'Last car message', - entity_category='diagnostic', enabled=False, - custom_availability=self.__system_availability_config - ) - - # Complex sensors - self.__publish_remote_ac() - self.__publish_heated_seats() - self.__publish_vehicle_tracker() - self.__publish_scheduled_charging() - self.__publish_scheduled_battery_heating() - - # Switches - self.__publish_switch(mqtt_topics.DRIVETRAIN_CHARGING, 'Charging') - self.__publish_switch(mqtt_topics.DRIVETRAIN_BATTERY_HEATING, 'Battery heating', icon='mdi:heat-wave') - self.__publish_switch(mqtt_topics.WINDOWS_DRIVER, 'Window driver') - self.__publish_switch(mqtt_topics.WINDOWS_PASSENGER, 'Window passenger') - self.__publish_switch(mqtt_topics.WINDOWS_REAR_LEFT, 'Window rear left') - self.__publish_switch(mqtt_topics.WINDOWS_REAR_RIGHT, 'Window rear right') - - if self.__vehicle_state.has_sunroof: - self.__publish_switch(mqtt_topics.WINDOWS_SUN_ROOF, 'Sun roof') - self.__publish_binary_sensor(mqtt_topics.WINDOWS_SUN_ROOF, 'Sun roof') - else: - self.__unpublish_ha_discovery_message('switch', 'Sun roof') - self.__unpublish_ha_discovery_message('binary_sensor', 'Sun roof') - - self.__publish_switch(mqtt_topics.CLIMATE_BACK_WINDOW_HEAT, 'Rear window defroster heating', - icon='mdi:car-defrost-rear', payload_on='on', payload_off='off') - self.__publish_switch(mqtt_topics.LOCATION_FIND_MY_CAR, 'Find my car', - icon='mdi:car-search', payload_on='activate', payload_off='stop') - - # Locks - self.__publish_lock(mqtt_topics.DOORS_LOCKED, 'Doors Lock', icon='mdi:car-door-lock') - self.__publish_lock(mqtt_topics.DOORS_BOOT, 'Boot Lock', icon='mdi:car-door-lock', state_locked='False', - state_unlocked='True') - self.__publish_lock(mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK, 'Charging Cable Lock', icon='mdi:lock') - - # Target SoC - self.__publish_number( - mqtt_topics.DRIVETRAIN_SOC_TARGET, - 'Target SoC', - device_class='battery', - unit_of_measurement='%', - min_value=40, - max_value=100, - step=10, - mode='slider', - icon='mdi:battery-charging-70', - enabled=self.__vehicle_state.supports_target_soc, - ) - self.__publish_select(mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT, 'Charge current limit', - [m.limit for m in ChargeCurrentLimitCode if m != ChargeCurrentLimitCode.C_IGNORE], - icon='mdi:current-ac') - - # Standard sensors - self.__publish_sensor(mqtt_topics.DRIVETRAIN_SOC, 'SoC', device_class='battery', state_class='measurement', - unit_of_measurement='%') - self.__publish_sensor(mqtt_topics.DRIVETRAIN_SOC_KWH, - 'SoC_kWh', - device_class='ENERGY_STORAGE', - state_class='measurement', - icon='mdi:battery-charging-70', - unit_of_measurement='kWh') - self.__publish_sensor(mqtt_topics.DRIVETRAIN_LAST_CHARGE_ENDING_POWER, - 'Last Charge SoC kWh', - device_class='ENERGY_STORAGE', - state_class='measurement', - icon='mdi:battery-charging-70', - unit_of_measurement='kWh' - ) - self.__publish_sensor(mqtt_topics.DRIVETRAIN_POWER_USAGE_SINCE_LAST_CHARGE, - 'Energy Usage Since Last Charge', - device_class='ENERGY_STORAGE', - state_class='measurement', - icon='mdi:battery-charging-70', - unit_of_measurement='kWh', - enabled=False - ) - self.__publish_sensor(mqtt_topics.DRIVETRAIN_POWER_USAGE_OF_DAY, - 'Energy Usage of the Day', - device_class='ENERGY_STORAGE', - state_class='measurement', - icon='mdi:battery-charging-70', - unit_of_measurement='kWh', - enabled=False - ) - - self.__publish_sensor(mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME, 'Remaining charging time', - device_class='duration', state_class='measurement', unit_of_measurement='s') - self.__publish_sensor(mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME, 'Charging finished', - device_class='timestamp', - value_template='{{ (now() + timedelta(seconds = value | int)).isoformat() }}', - custom_availability=HaCustomAvailabilityConfig(rules=[ - self.__system_availability, - self.__vehicle_availability, - HaCustomAvailabilityEntry( - topic=self.__get_vehicle_topic(mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME), - template="{{ 'online' if (value | int) > 0 else 'offline' }}" - ) - ])) - self.__publish_sensor(mqtt_topics.DRIVETRAIN_CHARGING_LAST_START, 'Last Charge Start Time', - device_class='timestamp', - value_template="{{ value | int | timestamp_utc }}", - icon='mdi:clock-start', - custom_availability=HaCustomAvailabilityConfig(rules=[ - self.__system_availability, - self.__vehicle_availability, - HaCustomAvailabilityEntry( - topic=self.__get_vehicle_topic(mqtt_topics.DRIVETRAIN_CHARGING_LAST_START), - template="{{ 'online' if (value | int) > 0 else 'offline' }}" - ) - ])) - self.__publish_sensor(mqtt_topics.DRIVETRAIN_CHARGING_LAST_END, 'Last Charge End Time', - device_class='timestamp', - value_template="{{ value | int | timestamp_utc }}", - icon='mdi:clock-end', - custom_availability=HaCustomAvailabilityConfig(rules=[ - self.__system_availability, - self.__vehicle_availability, - HaCustomAvailabilityEntry( - topic=self.__get_vehicle_topic(mqtt_topics.DRIVETRAIN_CHARGING_LAST_END), - template="{{ 'online' if (value | int) > 0 else 'offline' }}" - ) - ])) - self.__publish_sensor(mqtt_topics.DRIVETRAIN_CHARGING_TYPE, 'Charging Mode', - entity_category='diagnostic', - enabled=False) - self.__publish_sensor(mqtt_topics.BMS_CHARGE_STATUS, 'BMS Charge Status', - entity_category='diagnostic', - enabled=False) - self.__publish_sensor(mqtt_topics.DRIVETRAIN_MILEAGE, 'Mileage', device_class='distance', - state_class='total_increasing', unit_of_measurement='km') - self.__publish_sensor(mqtt_topics.DRIVETRAIN_MILEAGE_OF_DAY, 'Mileage of the day', device_class='distance', - state_class='total_increasing', unit_of_measurement='km', - enabled=False) - self.__publish_sensor(mqtt_topics.DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE, 'Mileage since last charge', - device_class='distance', state_class='total_increasing', unit_of_measurement='km', - enabled=False) - self.__publish_sensor(mqtt_topics.DRIVETRAIN_CURRENT_JOURNEY, 'Mileage of journey', - device_class='distance', state_class='total_increasing', unit_of_measurement='km', - value_template='{{ value_json["distance"] | int(0) }}', enabled=False) - self.__publish_sensor(mqtt_topics.DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE, 'Auxiliary battery voltage', - device_class='voltage', state_class='measurement', unit_of_measurement='V', - icon='mdi:car-battery') - self.__publish_sensor(mqtt_topics.DRIVETRAIN_RANGE, 'Range', device_class='distance', unit_of_measurement='km') - self.__publish_sensor( - mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_RANGE, 'Fossil fuel range', - device_class='distance', - unit_of_measurement='km', - enabled=self.__vehicle_state.has_fossil_fuel - ) - self.__publish_sensor( - mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE, 'Fossil fuel percentage', - state_class='measurement', - unit_of_measurement='%', - icon='mdi:fuel', - enabled=self.__vehicle_state.has_fossil_fuel - ) - self.__publish_sensor(mqtt_topics.DRIVETRAIN_CURRENT, 'Current', device_class='current', - state_class='measurement', unit_of_measurement='A') - self.__publish_sensor(mqtt_topics.DRIVETRAIN_VOLTAGE, 'Voltage', device_class='voltage', - state_class='measurement', unit_of_measurement='V') - self.__publish_sensor(mqtt_topics.DRIVETRAIN_POWER, 'Power', device_class='power', state_class='measurement', - unit_of_measurement='kW') - - self.__publish_sensor(mqtt_topics.OBC_CURRENT, 'OBC Current', device_class='current', - state_class='measurement', unit_of_measurement='A', entity_category='diagnostic', - enabled=False) - self.__publish_sensor(mqtt_topics.OBC_VOLTAGE, 'OBC Voltage', device_class='voltage', - state_class='measurement', unit_of_measurement='V', entity_category='diagnostic', - enabled=False) - - self.__publish_sensor(mqtt_topics.OBC_POWER_SINGLE_PHASE, 'OBC Power Single Phase', device_class='power', - state_class='measurement', unit_of_measurement='W', entity_category='diagnostic', - enabled=False) - - self.__publish_sensor(mqtt_topics.OBC_POWER_THREE_PHASE, 'OBC Power Three Phase', device_class='power', - state_class='measurement', unit_of_measurement='W', entity_category='diagnostic', - enabled=False) - - self.__publish_sensor(mqtt_topics.CCU_ONBOARD_PLUG_STATUS, 'CCU Onboard Plug Status', state_class='measurement', - entity_category='diagnostic', enabled=False) - - self.__publish_sensor(mqtt_topics.CCU_OFFBOARD_PLUG_STATUS, 'CCU Offboard Plug Status', - state_class='measurement', - entity_category='diagnostic', enabled=False) - - self.__publish_sensor(mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE, 'Interior temperature', - device_class='temperature', state_class='measurement', unit_of_measurement='°C') - self.__publish_sensor(mqtt_topics.CLIMATE_EXTERIOR_TEMPERATURE, 'Exterior temperature', - device_class='temperature', state_class='measurement', unit_of_measurement='°C') - self.__publish_sensor(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE, 'Remote climate state', - icon='mdi:car-connected') - self.__publish_sensor(mqtt_topics.CLIMATE_BACK_WINDOW_HEAT, 'Rear window defroster heating', - icon='mdi:car-defrost-rear') - self.__publish_sensor(mqtt_topics.LOCATION_HEADING, 'Heading', icon='mdi:compass', unit_of_measurement='°') - self.__publish_sensor(mqtt_topics.LOCATION_SPEED, 'Vehicle speed', device_class='speed', - unit_of_measurement='km/h') - self.__publish_sensor(mqtt_topics.TYRES_FRONT_LEFT_PRESSURE, 'Tyres front left pressure', - device_class='pressure', unit_of_measurement='bar', icon='mdi:tire') - self.__publish_sensor(mqtt_topics.TYRES_FRONT_RIGHT_PRESSURE, 'Tyres front right pressure', - device_class='pressure', unit_of_measurement='bar', icon='mdi:tire') - self.__publish_sensor(mqtt_topics.TYRES_REAR_LEFT_PRESSURE, 'Tyres rear left pressure', device_class='pressure', - unit_of_measurement='bar', icon='mdi:tire') - self.__publish_sensor(mqtt_topics.TYRES_REAR_RIGHT_PRESSURE, 'Tyres rear right pressure', - device_class='pressure', unit_of_measurement='bar', icon='mdi:tire') - # Binary sensors - self.__publish_binary_sensor(mqtt_topics.DRIVETRAIN_CHARGER_CONNECTED, 'Charger connected', - device_class='plug', icon='mdi:power-plug-battery') - self.__publish_binary_sensor(mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE, 'HV Battery Active', - device_class='power', icon='mdi:battery-check') - self.__publish_binary_sensor(mqtt_topics.DRIVETRAIN_CHARGING, 'Battery Charging', - device_class='battery_charging', icon='mdi:battery-charging') - self.__publish_sensor( - mqtt_topics.DRIVETRAIN_CHARGING_STOP_REASON, 'Battery charging stop reason', - icon='mdi:battery-charging', - enabled=False - ) - self.__publish_binary_sensor(mqtt_topics.DRIVETRAIN_BATTERY_HEATING, 'Battery heating', icon='mdi:heat-wave') - self.__publish_sensor( - mqtt_topics.DRIVETRAIN_BATTERY_HEATING_STOP_REASON, 'Battery heating stop reason', - icon='mdi:heat-wave', - enabled=False - ) - self.__publish_binary_sensor(mqtt_topics.DRIVETRAIN_RUNNING, 'Vehicle Running', device_class='running', - icon='mdi:car-side') - self.__publish_binary_sensor(mqtt_topics.DOORS_DRIVER, 'Door driver', device_class='door', icon='mdi:car-door') - self.__publish_binary_sensor(mqtt_topics.DOORS_PASSENGER, 'Door passenger', device_class='door', - icon='mdi:car-door') - self.__publish_binary_sensor(mqtt_topics.DOORS_REAR_LEFT, 'Door rear left', device_class='door', - icon='mdi:car-door') - self.__publish_binary_sensor(mqtt_topics.DOORS_REAR_RIGHT, 'Door rear right', device_class='door', - icon='mdi:car-door') - self.__publish_binary_sensor(mqtt_topics.DOORS_BONNET, 'Bonnet', device_class='door', icon='mdi:car-door') - self.__publish_binary_sensor(mqtt_topics.DOORS_BOOT, 'Boot', device_class='door', icon='mdi:car-door') - self.__publish_binary_sensor(mqtt_topics.LIGHTS_MAIN_BEAM, 'Lights Main Beam', device_class='light', - icon='mdi:car-light-high') - self.__publish_binary_sensor(mqtt_topics.LIGHTS_DIPPED_BEAM, 'Lights Dipped Beam', device_class='light', - icon='mdi:car-light-dimmed') - self.__publish_binary_sensor(mqtt_topics.LIGHTS_SIDE, 'Lights Side', device_class='light', - icon='mdi:car-light-dimmed') - - # Remove deprecated sensors - self.__unpublish_ha_discovery_message('sensor', 'Front window defroster heating') - LOG.debug("Completed publishing Home Assistant discovery messages") - - def __publish_vehicle_tracker(self): - self.__publish_ha_discovery_message('device_tracker', 'Vehicle position', { - 'json_attributes_topic': self.__get_vehicle_topic(mqtt_topics.LOCATION_POSITION) - }) - - def __publish_remote_ac(self): - # This has been converted into 2 switches and a climate entity for ease of operation - - self.__publish_switch( - mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE, - 'Front window defroster heating', - icon='mdi:car-defrost-front', - value_template='{% if value == "front" %}front{% else %}off{% endif %}', - payload_on='front', - payload_off='off' - ) - - self.__publish_switch( - mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE, - 'Vehicle climate fan only', - icon='mdi:fan', - value_template='{% if value == "blowingonly" %}blowingonly{% else %}off{% endif %}', - payload_on='blowingonly', - payload_off='off' - ) - - self.__publish_ha_discovery_message('climate', 'Vehicle climate', { - 'precision': 1.0, - 'temperature_unit': 'C', - 'mode_state_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE), - 'mode_command_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE_SET), - 'mode_state_template': '{% if value == "on" %}auto{% else %}off{% endif %}', - 'mode_command_template': '{% if value == "auto" %}on{% else %}off{% endif %}', - 'modes': ['off', 'auto'], - 'current_temperature_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE), - 'current_temperature_template': '{{ value }}', - 'temperature_command_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_TEMPERATURE_SET), - 'temperature_command_template': '{{ value | int }}', - 'temperature_state_topic': self.__get_vehicle_topic(mqtt_topics.CLIMATE_REMOTE_TEMPERATURE), - 'temperature_state_template': '{{ value | int }}', - 'min_temp': self.__vehicle_state.get_min_ac_temperature(), - 'max_temp': self.__vehicle_state.get_max_ac_temperature(), - }) - - def __publish_switch( - self, - topic: str, - name: str, - *, - enabled=True, - icon: str | None = None, - value_template: str = '{{ value }}', - payload_on='True', - payload_off='False', - custom_availability: HaCustomAvailabilityConfig | None = None - ) -> str: - payload = { - 'state_topic': self.__get_vehicle_topic(topic), - 'command_topic': self.__get_vehicle_set_topic(topic), - 'value_template': value_template, - 'payload_on': payload_on, - 'payload_off': payload_off, - 'optimistic': False, - 'qos': 0, - 'enabled_by_default': enabled, - } - if icon is not None: - payload['icon'] = icon - return self.__publish_ha_discovery_message('switch', name, payload, custom_availability) - - def __publish_lock( - self, - topic: str, - name: str, - enabled=True, - icon: str | None = None, - payload_lock: str = 'True', - payload_unlock: str = 'False', - state_locked: str = 'True', - state_unlocked: str = 'False', - custom_availability: HaCustomAvailabilityConfig | None = None - ) -> str: - payload = { - 'state_topic': self.__get_vehicle_topic(topic), - 'command_topic': self.__get_vehicle_set_topic(topic), - 'payload_lock': payload_lock, - 'payload_unlock': payload_unlock, - 'state_locked': state_locked, - 'state_unlocked': state_unlocked, - 'optimistic': False, - 'qos': 0, - 'enabled_by_default': enabled, - } - if icon is not None: - payload['icon'] = icon - return self.__publish_ha_discovery_message('lock', name, payload, custom_availability) - - def __publish_sensor( - self, - topic: str, - name: str, - enabled=True, - entity_category: str | None = None, - device_class: str | None = None, - state_class: str | None = None, - unit_of_measurement: str | None = None, - icon: str | None = None, - value_template: str = '{{ value }}', - custom_availability: HaCustomAvailabilityConfig | None = None - ) -> str: - payload = { - 'state_topic': self.__get_vehicle_topic(topic), - 'value_template': value_template, - 'enabled_by_default': enabled, - } - if entity_category is not None: - payload['entity_category'] = entity_category - if device_class is not None: - payload['device_class'] = device_class - if state_class is not None: - payload['state_class'] = state_class - if unit_of_measurement is not None: - payload['unit_of_measurement'] = unit_of_measurement - if icon is not None: - payload['icon'] = icon - - return self.__publish_ha_discovery_message('sensor', name, payload, custom_availability) - - def __publish_number( - self, - topic: str, - name: str, - enabled=True, - entity_category: str | None = None, - device_class: str | None = None, - state_class: str | None = None, - unit_of_measurement: str | None = None, - icon: str | None = None, - value_template: str = '{{ value }}', - retain: bool = False, - mode: str = 'auto', - min_value: float = 1.0, - max_value: float = 100.0, - step: float = 1.0, - custom_availability: HaCustomAvailabilityConfig | None = None - ) -> str: - payload = { - 'state_topic': self.__get_vehicle_topic(topic), - 'command_topic': self.__get_vehicle_set_topic(topic), - 'value_template': value_template, - 'retain': str(retain).lower(), - 'mode': mode, - 'min': min_value, - 'max': max_value, - 'step': step, - 'enabled_by_default': enabled, - } - if entity_category is not None: - payload['entity_category'] = entity_category - if device_class is not None: - payload['device_class'] = device_class - if state_class is not None: - payload['state_class'] = state_class - if unit_of_measurement is not None: - payload['unit_of_measurement'] = unit_of_measurement - if icon is not None: - payload['icon'] = icon - - return self.__publish_ha_discovery_message('number', name, payload, custom_availability) - - def __publish_text( - self, - topic: str, - name: str, - enabled=True, - icon: str | None = None, - value_template: str = '{{ value }}', - command_template: str = '{{ value }}', - retain: bool = False, - min_value: int | None = None, - max_value: int | None = None, - pattern: str | None = None, - custom_availability: HaCustomAvailabilityConfig | None = None - ) -> str: - payload = { - 'state_topic': self.__get_vehicle_topic(topic), - 'command_topic': self.__get_vehicle_set_topic(topic), - 'value_template': value_template, - 'command_template': command_template, - 'retain': str(retain).lower(), - 'enabled_by_default': enabled, - } - if min_value is not None: - payload['min'] = min_value - if max_value is not None: - payload['max'] = max_value - if pattern is not None: - payload['pattern'] = pattern - if icon is not None: - payload['icon'] = icon - - return self.__publish_ha_discovery_message('text', name, payload, custom_availability) - - def __publish_binary_sensor( - self, - topic: str, - name: str, - enabled=True, - device_class: str | None = None, - value_template: str = '{{ value }}', - payload_on: str = 'True', - payload_off: str = 'False', - icon: str | None = None, - custom_availability: HaCustomAvailabilityConfig | None = None - ) -> str: - payload = { - 'state_topic': self.__get_vehicle_topic(topic), - 'value_template': value_template, - 'payload_on': payload_on, - 'payload_off': payload_off, - 'enabled_by_default': enabled, - } - if device_class is not None: - payload['device_class'] = device_class - if icon is not None: - payload['icon'] = icon - - return self.__publish_ha_discovery_message('binary_sensor', name, payload, custom_availability) - - def __publish_select( - self, - topic: str, - name: str, - options: list[str], - *, - entity_category: str | None = None, - enabled=True, - value_template: str = '{{ value }}', - command_template: str = '{{ value }}', - icon: str | None = None, - custom_availability: HaCustomAvailabilityConfig | None = None - ) -> str: - payload = { - 'state_topic': self.__get_vehicle_topic(topic), - 'command_topic': self.__get_vehicle_set_topic(topic), - 'value_template': value_template, - 'command_template': command_template, - 'options': options, - 'enabled_by_default': enabled, - } - if entity_category is not None: - payload['entity_category'] = entity_category - if icon is not None: - payload['icon'] = icon - - return self.__publish_ha_discovery_message('select', name, payload, custom_availability) - - def __get_common_attributes( - self, - unique_id: str, name: str, - custom_availability: HaCustomAvailabilityConfig | None = None - ): - common_attributes = { - 'name': name, - 'device': self.__get_device_node(), - 'unique_id': unique_id, - 'object_id': unique_id - } - - if custom_availability is not None: - common_attributes.update(custom_availability.to_dict()) - else: - common_attributes.update(self.__standard_availability_config.to_dict()) - - return common_attributes - - def __get_device_node(self): - vin = self.__get_vin() - brand_name = decode_as_utf8(self.__vin_info.brandName) - model_name = decode_as_utf8(self.__vin_info.modelName) - model_year = decode_as_utf8(self.__vin_info.modelYear) - color_name = decode_as_utf8(self.__vin_info.colorName) - series = str(self.__vin_info.series) - # Create a long model name concatenating model_name, model_year and color_name without multiple spaces - final_model_name = ' '.join([model_name, model_year, color_name]).strip().replace(' ', ' ') - return { - 'name': f'{brand_name} {model_name} {vin}', - 'manufacturer': brand_name, - 'model': final_model_name, - 'hw_version': series, - 'identifiers': [vin], - } - - def __get_vin(self): - vin = self.__vehicle_state.vin - return vin - - def __get_system_topic(self, topic: str) -> str: - publisher = self.__vehicle_state.publisher - if isinstance(publisher, MqttPublisher): - return publisher.get_topic(topic, no_prefix=False) - return topic - - def __get_vehicle_topic(self, topic: str) -> str: - vehicle_topic = self.__vehicle_state.get_topic(topic) - publisher = self.__vehicle_state.publisher - if isinstance(publisher, MqttPublisher): - return publisher.get_topic(vehicle_topic, no_prefix=False) - return vehicle_topic - - def __get_vehicle_set_topic(self, topic: str) -> str: - return self.__get_vehicle_topic(topic) + '/' + mqtt_topics.SET_SUFFIX - - def __publish_ha_discovery_message( - self, - sensor_type: str, - sensor_name: str, - payload: dict, - custom_availability: dict[str, str] | None = None - ) -> str: - vin = self.__get_vin() - unique_id = f'{vin}_{snake_case(sensor_name)}' - final_payload = self.__get_common_attributes(unique_id, sensor_name, custom_availability) | payload - ha_topic = f'{self.__discovery_prefix}/{sensor_type}/{vin}_mg/{unique_id}/config' - self.__vehicle_state.publisher.publish_json(ha_topic, final_payload, no_prefix=True) - return f"{sensor_type}.{unique_id}" - - # This de-registers an entity from Home Assistant - def __unpublish_ha_discovery_message(self, sensor_type: str, sensor_name: str) -> None: - vin = self.__get_vin() - unique_id = f'{vin}_{snake_case(sensor_name)}' - ha_topic = f'{self.__discovery_prefix}/{sensor_type}/{vin}_mg/{unique_id}/config' - self.__vehicle_state.publisher.publish_str(ha_topic, '', no_prefix=True) - - def __publish_scheduled_charging(self): - start_time_id = self.__publish_sensor( - mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, - 'Scheduled Charging Start', - value_template='{{ value_json["startTime"] }}', icon='mdi:clock-start' - ) - end_time_id = self.__publish_sensor( - mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, - 'Scheduled Charging End', - value_template='{{ value_json["endTime"] }}', icon='mdi:clock-end' - ) - scheduled_charging_mode_id = self.__publish_sensor( - mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, - 'Scheduled Charging Mode', - value_template='{{ value_json["mode"] }}', icon='mdi:clock-outline', - ) - - change_mode_cmd_template = json.dumps({ - "startTime": f"{{{{ states('{start_time_id}') }}}}", - "endTime": f"{{{{ states('{end_time_id}') }}}}", - "mode": "{{ value }}" - }) - self.__publish_select( - mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, - 'Scheduled Charging Mode', - options=[m.name for m in ScheduledChargingMode], - value_template='{{ value_json["mode"] }}', - command_template=change_mode_cmd_template, - icon='mdi:clock-outline', - ) - - change_start_cmd_template = json.dumps({ - "startTime": "{{ value }}", - "endTime": f"{{{{ states('{end_time_id}') }}}}", - "mode": f"{{{{ states('{scheduled_charging_mode_id}') }}}}" - }) - self.__publish_text( - mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, - 'Scheduled Charging Start', - value_template='{{ value_json["startTime"] }}', - command_template=change_start_cmd_template, - min_value=4, max_value=5, pattern='^([01][0-9]|2[0-3]):[0-5][0-9]$', - icon='mdi:clock-start' - ) - - change_end_cmd_template = json.dumps({ - "startTime": f"{{{{ states('{start_time_id}') }}}}", - "endTime": "{{ value }}", - "mode": f"{{{{ states('{scheduled_charging_mode_id}') }}}}" - }) - self.__publish_text( - mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, - 'Scheduled Charging End', - value_template='{{ value_json["endTime"] }}', - command_template=change_end_cmd_template, - min_value=4, max_value=5, pattern='^([01][0-9]|2[0-3]):[0-5][0-9]$', - icon='mdi:clock-end' - ) - - def __publish_scheduled_battery_heating(self): - start_time_id = self.__publish_sensor( - mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE, - 'Scheduled Battery Heating Start', - value_template='{{ value_json["startTime"] }}', icon='mdi:clock-start' - ) - mode_id = self.__publish_binary_sensor( - mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE, - 'Scheduled Battery Heating', - value_template='{{ value_json["mode"] }}', icon='mdi:clock-outline', - payload_on='on', payload_off='off' - ) - change_mode_cmd_template = json.dumps({ - "startTime": f"{{{{ states('{start_time_id}') }}}}", - "mode": "{{ value }}" - }) - self.__publish_select( - mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE, - 'Scheduled Battery Heating', - options=["on", "off"], - value_template='{{ value_json["mode"] }}', - command_template=change_mode_cmd_template, - icon='mdi:clock-outline', - ) - - change_start_cmd_template = json.dumps({ - "startTime": "{{ value }}", - "mode": f"{{{{ states('{mode_id}') }}}}" - }) - self.__publish_text( - mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE, - 'Scheduled Battery Heating Start', - value_template='{{ value_json["startTime"] }}', - command_template=change_start_cmd_template, - min_value=4, max_value=5, pattern='^([01][0-9]|2[0-3]):[0-5][0-9]$', - icon='mdi:clock-start' - ) - - def __publish_heated_seats(self): - if self.__vehicle_state.has_level_heated_seats: - self.__unpublish_heated_seat_switch('Front Left') - self.__unpublish_heated_seat_switch('Front Right') - self.__publish_heated_seat_level('Front Left', mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL) - self.__publish_heated_seat_level('Front Right', mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL) - elif self.__vehicle_state.has_on_off_heated_seats: - self.__unpublish_heated_seat_level('Front Left') - self.__unpublish_heated_seat_level('Front Right') - self.__publish_heated_seat_switch('Front Left', mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL) - self.__publish_heated_seat_switch('Front Right', mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL) - else: - self.__unpublish_heated_seat_level('Front Left') - self.__unpublish_heated_seat_level('Front Right') - self.__unpublish_heated_seat_switch('Front Left') - self.__unpublish_heated_seat_switch('Front Right') - - def __publish_heated_seat_level(self, seat: str, topic: str): - self.__publish_select( - topic, - f'Heated Seat {seat} Level', - options=['OFF', 'LOW', 'MEDIUM', 'HIGH'], - value_template='{% set v = value | int %}' - '{% if v == 0 %}OFF' - '{% elif v == 1 %}LOW' - '{% elif v == 2 %}MEDIUM' - '{% else %}HIGH' - '{% endif %}', - command_template='{% if value == "OFF" %}0' - '{% elif value == "LOW" %}1' - '{% elif value == "MEDIUM" %}2' - '{% else %}3' - '{% endif %}', - icon='mdi:car-seat-heater', - ) - - def __unpublish_heated_seat_level(self, seat: str): - self.__unpublish_ha_discovery_message('select', f'Heated Seat {seat} Level') - - def __publish_heated_seat_switch(self, seat: str, topic: str): - self.__publish_switch( - topic, - f'Heated Seat {seat}', - payload_off='0', - payload_on='1', - icon='mdi:car-seat-heater', - ) - - def __unpublish_heated_seat_switch(self, seat: str): - self.__unpublish_ha_discovery_message('switch', f'Heated Seat {seat}') - - -def snake_case(s): - return inflection.underscore(s.lower()).replace(' ', '_') - - -def decode_as_utf8(byte_string, default=''): - if byte_string is None: - return default - elif isinstance(byte_string, str): - return byte_string - elif isinstance(byte_string, bytes) or isinstance(byte_string, bytearray): - try: - return str(byte_string, encoding='utf8', errors='ignore') - except Exception: - LOG.exception(f'Failed to decode {byte_string} as utf8') - return default - else: - try: - return str(byte_string) - except Exception: - LOG.exception(f'Failed to decode {byte_string}') - return default diff --git a/integrations/osmand/api.py b/integrations/osmand/api.py deleted file mode 100644 index ed09e2f..0000000 --- a/integrations/osmand/api.py +++ /dev/null @@ -1,234 +0,0 @@ -import logging -from abc import ABC -from typing import Any, Tuple, Optional - -import httpx -from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus -from saic_ismart_client_ng.api.vehicle import VehicleStatusResp -from saic_ismart_client_ng.api.vehicle.schema import BasicVehicleStatus -from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp -from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus - -from integrations import IntegrationException -from utils import value_in_range, get_update_timestamp - -LOG = logging.getLogger(__name__) - - -class OsmAndApiException(IntegrationException): - def __init__(self, msg: str): - super().__init__(__name__, msg) - - -class OsmAndApiListener(ABC): - async def on_request(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): - pass - - async def on_response(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): - pass - - -class OsmAndApi: - def __init__(self, *, server_uri: str, device_id: str, listener: Optional[OsmAndApiListener] = None) -> None: - self.__device_id = device_id - self.__listener = listener - self.__server_uri = server_uri - self.client = httpx.AsyncClient( - event_hooks={ - "request": [self.invoke_request_listener], - "response": [self.invoke_response_listener] - } - ) - - async def update_osmand(self, vehicle_status: VehicleStatusResp, charge_info: ChrgMgmtDataResp | None) \ - -> Tuple[bool, Any | None]: - - charge_mgmt_data = None if charge_info is None else charge_info.chrgMgmtData - charge_status = None if charge_info is None else charge_info.rvsChargeStatus - - if ( - self.__device_id is not None - and self.__server_uri is not None - and vehicle_status is not None - ): - # Request - data = { - 'id': self.__device_id, - # Guess the timestamp from either the API, GPS info or current machine time - 'timestamp': int(get_update_timestamp(vehicle_status).timestamp()), - 'is_charging': vehicle_status.is_charging, - 'is_parked': vehicle_status.is_parked, - } - - if vehicle_status.is_parked: - data.update({ - # We assume the vehicle is stationary, we will update it later from GPS if available - 'speed': 0.0, - }) - - basic_vehicle_status = vehicle_status.basicVehicleStatus - if basic_vehicle_status is not None: - data.update(self.__extract_basic_vehicle_status(basic_vehicle_status)) - - gps_position = vehicle_status.gpsPosition - if gps_position is not None: - data.update(self.__extract_gps_position(gps_position)) - - if charge_mgmt_data is not None: - data.update({ - 'soc': (charge_mgmt_data.bmsPackSOCDsp / 10.0) - }) - - # Skip invalid current values reported by the API - is_valid_current = ( - charge_mgmt_data.bmsPackCrntV != 1 - and value_in_range(charge_mgmt_data.bmsPackCrnt, 0, 65535) - ) - if is_valid_current: - data.update({ - 'power': charge_mgmt_data.decoded_power, - 'voltage': charge_mgmt_data.decoded_voltage, - 'current': charge_mgmt_data.decoded_current - }) - - # Extract electric range if available - data.update(self.__extract_electric_range(basic_vehicle_status, charge_status)) - - try: - response = await self.client.post(url=self.__server_uri, params=data) - await response.aread() - return True, response.text - except httpx.ConnectError as ece: - raise OsmAndApiException(f'Connection error: {ece}') - except httpx.TimeoutException as et: - raise OsmAndApiException(f'Timeout error {et}') - except httpx.RequestError as e: - raise OsmAndApiException(f'{e}') - except httpx.HTTPError as ehttp: - raise OsmAndApiException(f'HTTP error {ehttp}') - else: - return False, 'OsmAnd request skipped because of missing configuration' - - @staticmethod - def __extract_basic_vehicle_status(basic_vehicle_status: BasicVehicleStatus) -> dict: - data = {} - - exterior_temperature = basic_vehicle_status.exteriorTemperature - if exterior_temperature is not None and value_in_range(exterior_temperature, -127, 127): - data['ext_temp'] = exterior_temperature - mileage = basic_vehicle_status.mileage - # Skip invalid range readings - if mileage is not None and value_in_range(mileage, 1, 2147483647): - data['odometer'] = 100 * mileage - - return data - - @staticmethod - def __extract_gps_position(gps_position: GpsPosition) -> dict: - data = {} - - # Do not use GPS data if it is not available - if gps_position.gps_status_decoded not in [GpsStatus.FIX_2D, GpsStatus.FIX_3d]: - return data - - way_point = gps_position.wayPoint - if way_point is None: - return data - - speed = way_point.speed - if value_in_range(speed, -999, 4500): - data['speed'] = speed / 10 - - heading = way_point.heading - if value_in_range(heading, 0, 360): - data['heading'] = heading - - position = way_point.position - if position is None: - return data - - altitude = position.altitude - if value_in_range(altitude, -500, 8900): - data['altitude'] = altitude - - lat_degrees = position.latitude / 1000000.0 - lon_degrees = position.longitude / 1000000.0 - - if ( - abs(lat_degrees) <= 90 - and abs(lon_degrees) <= 180 - ): - data.update({ - 'hdop': way_point.hdop, - 'lat': lat_degrees, - 'lon': lon_degrees, - }) - - return data - - def __extract_electric_range( - self, - basic_vehicle_status: BasicVehicleStatus | None, - charge_status: RvsChargeStatus | None - ) -> dict: - - data = {} - - range_elec_vehicle = 0.0 - if basic_vehicle_status is not None: - range_elec_vehicle = self.__parse_electric_range(raw_value=basic_vehicle_status.fuelRangeElec) - - range_elec_bms = 0.0 - if charge_status is not None: - range_elec_bms = self.__parse_electric_range(raw_value=charge_status.fuelRangeElec) - - range_elec = max(range_elec_vehicle, range_elec_bms) - if range_elec > 0: - data['est_battery_range'] = range_elec - - return data - - @staticmethod - def __parse_electric_range(raw_value) -> float: - if value_in_range(raw_value, 1, 20460): - return float(raw_value) / 10.0 - return 0.0 - - async def invoke_request_listener(self, request: httpx.Request): - if not self.__listener: - return - try: - body = None - if request.content: - try: - - body = request.content.decode("utf-8") - except Exception as e: - LOG.warning(f"Error decoding request content: {e}") - - await self.__listener.on_request( - path=str(request.url).replace(self.__server_uri, "/"), - body=body, - headers=dict(request.headers), - ) - except Exception as e: - LOG.warning(f"Error invoking request listener: {e}", exc_info=e) - - async def invoke_response_listener(self, response: httpx.Response): - if not self.__listener: - return - try: - body = await response.aread() - if body: - try: - body = body.decode("utf-8") - except Exception as e: - LOG.warning(f"Error decoding request content: {e}") - - await self.__listener.on_response( - path=str(response.url).replace(self.__server_uri, "/"), - body=body, - headers=dict(response.headers), - ) - except Exception as e: - LOG.warning(f"Error invoking request listener: {e}", exc_info=e) diff --git a/mqtt_gateway.py b/mqtt_gateway.py deleted file mode 100644 index 365123b..0000000 --- a/mqtt_gateway.py +++ /dev/null @@ -1,232 +0,0 @@ -import asyncio -import faulthandler -import logging -import os -import signal -import sys -from typing import override, Optional - -import apscheduler.schedulers.asyncio -from saic_ismart_client_ng import SaicApi -from saic_ismart_client_ng.api.vehicle.alarm import AlarmType -from saic_ismart_client_ng.model import SaicApiConfiguration - -import mqtt_topics -from configuration import Configuration -from configuration.parser import process_arguments -from handlers.message import MessageHandler -from handlers.relogin import ReloginHandler -from handlers.vehicle import VehicleHandler, VehicleHandlerLocator -from integrations.openwb.charging_station import ChargingStation -from publisher.core import Publisher, MqttCommandListener -from publisher.log_publisher import ConsolePublisher -from publisher.mqtt_publisher import MqttPublisher -from saic_api_listener import MqttGatewaySaicApiListener -from vehicle import VehicleState - -MSG_CMD_SUCCESSFUL = 'Success' - -logging.root.handlers = [] -logging.basicConfig(format='{asctime:s} [{levelname:^8s}] {message:s} - {name:s}', style='{') -LOG = logging.getLogger(__name__) -LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() -LOG.setLevel(level=LOG_LEVEL) -logging.getLogger('apscheduler').setLevel(level=LOG_LEVEL) - - -def debug_log_enabled(): - return LOG_LEVEL == 'DEBUG' - - -class MqttGateway(MqttCommandListener, VehicleHandlerLocator): - def __init__(self, config: Configuration): - self.configuration = config - self.__vehicle_handlers: dict[str, VehicleHandler] = dict() - if config.is_mqtt_enabled: - self.publisher: Publisher = MqttPublisher(self.configuration) - else: - LOG.warning("MQTT support disabled") - self.publisher: Publisher = ConsolePublisher(self.configuration) - self.publisher.command_listener = self - if config.publish_raw_api_data: - listener = MqttGatewaySaicApiListener(self.publisher) - else: - listener = None - self.saic_api = SaicApi( - configuration=SaicApiConfiguration( - username=self.configuration.saic_user, - password=self.configuration.saic_password, - username_is_email=config.username_is_email, - phone_country_code=None if config.username_is_email else self.configuration.saic_phone_country_code, - base_uri=self.configuration.saic_rest_uri, - region=self.configuration.saic_region, - tenant_id=self.configuration.saic_tenant_id - ), - listener=listener - ) - self.__scheduler = apscheduler.schedulers.asyncio.AsyncIOScheduler() - self.__relogin_handler = ReloginHandler( - relogin_relay=self.configuration.saic_relogin_delay, - api=self.saic_api, - scheduler=self.__scheduler - ) - - async def run(self): - try: - await self.__relogin_handler.login() - except Exception as e: - LOG.exception('MqttGateway crashed due to an Exception during startup', exc_info=e) - raise SystemExit(e) - - LOG.info("Fetching vehicle list") - vin_list = await self.saic_api.vehicle_list() - - alarm_switches = [x for x in AlarmType] - - for vin_info in vin_list.vinList: - try: - LOG.info(f'Registering for {[x.name for x in alarm_switches]} messages. vin={vin_info.vin}') - await self.saic_api.set_alarm_switches(alarm_switches=alarm_switches, vin=vin_info.vin) - LOG.info(f'Registered for {[x.name for x in alarm_switches]} messages. vin={vin_info.vin}') - except Exception as e: - LOG.exception( - f'Failed to register for {[x.name for x in alarm_switches]} messages. vin={vin_info.vin}', - exc_info=e - ) - raise SystemExit(e) - - account_prefix = f'{self.configuration.saic_user}/{mqtt_topics.VEHICLES}/{vin_info.vin}' - charging_station = self.get_charging_station(vin_info.vin) - if ( - charging_station - and charging_station.soc_topic - ): - LOG.debug('SoC of %s for charging station will be published over MQTT topic: %s', vin_info.vin, - charging_station.soc_topic) - if ( - charging_station - and charging_station.range_topic - ): - LOG.debug('Range of %s for charging station will be published over MQTT topic: %s', vin_info.vin, - charging_station.range_topic) - total_battery_capacity = configuration.battery_capacity_map.get(vin_info.vin, None) - vehicle_state = VehicleState( - self.publisher, - self.__scheduler, - account_prefix, - vin_info, - charging_station, - charge_polling_min_percent=self.configuration.charge_dynamic_polling_min_percentage, - total_battery_capacity=total_battery_capacity, - ) - - vehicle_handler = VehicleHandler( - self.configuration, - self.__relogin_handler, - self.saic_api, - self.publisher, # Gateway pointer - vin_info, - vehicle_state - ) - self.vehicle_handlers[vin_info.vin] = vehicle_handler - message_handler = MessageHandler( - gateway=self, - relogin_handler=self.__relogin_handler, - saicapi=self.saic_api - ) - self.__scheduler.add_job( - func=message_handler.check_for_new_messages, - trigger='interval', - seconds=self.configuration.messages_request_interval, - id='message_handler', - name='Check for new messages', - max_instances=1 - ) - await self.publisher.connect() - self.__scheduler.start() - await self.__main_loop() - - @override - def get_vehicle_handler(self, vin: str) -> Optional[VehicleHandler]: - if vin in self.vehicle_handlers: - return self.vehicle_handlers[vin] - else: - LOG.error(f'No vehicle handler found for VIN {vin}') - return None - - @property - @override - def vehicle_handlers(self) -> dict[str, VehicleHandler]: - return self.__vehicle_handlers - - @override - async def on_mqtt_command_received(self, *, vin: str, topic: str, payload: str) -> None: - vehicle_handler = self.get_vehicle_handler(vin) - if vehicle_handler: - await vehicle_handler.handle_mqtt_command(topic=topic, payload=payload) - else: - LOG.debug(f'Command for unknown vin {vin} received') - - @override - async def on_charging_detected(self, vin: str) -> None: - vehicle_handler = self.get_vehicle_handler(vin) - if vehicle_handler: - # just make sure that we don't set the is_charging flag too early - # and that it is immediately overwritten by a running vehicle state request - await asyncio.sleep(delay=3.0) - vehicle_handler.vehicle_state.set_is_charging(True) - else: - LOG.debug(f'Charging detected for unknown vin {vin}') - - def __on_publish_raw_value(self, key: str, raw: str): - self.publisher.publish_str(key, raw) - - def __on_publish_json_value(self, key: str, json_data: dict): - self.publisher.publish_json(key, json_data) - - def get_charging_station(self, vin) -> ChargingStation | None: - if vin in self.configuration.charging_stations_by_vin: - return self.configuration.charging_stations_by_vin[vin] - else: - return None - - async def __main_loop(self): - tasks = [] - for (key, vh) in self.vehicle_handlers.items(): - LOG.debug(f'Starting process for car {key}') - task = asyncio.create_task(vh.handle_vehicle(), name=f'handle_vehicle_{key}') - tasks.append(task) - - await self.__shutdown_handler(tasks) - - @staticmethod - async def __shutdown_handler(tasks): - while True: - done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - for task in done: - task_name = task.get_name() - if task.cancelled(): - LOG.debug(f'{task_name !r} task was cancelled, this is only supposed if the application is ' - + 'shutting down') - else: - exception = task.exception() - if exception is not None: - LOG.exception(f'{task_name !r} task crashed with an exception', exc_info=exception) - raise SystemExit(-1) - else: - LOG.warning(f'{task_name !r} task terminated cleanly with result={task.result()}') - if len(pending) == 0: - break - else: - LOG.warning(f'There are still {len(pending)} tasks... waiting for them to complete') - - -if __name__ == '__main__': - # Enable fault handler to get a thread dump on SIGQUIT - faulthandler.enable(file=sys.stderr, all_threads=True) - if hasattr(faulthandler, 'register'): - faulthandler.register(signal.SIGQUIT, chain=False) - configuration = process_arguments() - - mqtt_gateway = MqttGateway(configuration) - asyncio.run(mqtt_gateway.run(), debug=debug_log_enabled()) diff --git a/mqtt_topics.py b/mqtt_topics.py deleted file mode 100644 index 994e0de..0000000 --- a/mqtt_topics.py +++ /dev/null @@ -1,162 +0,0 @@ -SET_SUFFIX = 'set' -RESULT_SUFFIX = 'result' - -AVAILABLE = 'available' - -CLIMATE = 'climate' -CLIMATE_BACK_WINDOW_HEAT = CLIMATE + '/rearWindowDefrosterHeating' -CLIMATE_BACK_WINDOW_HEAT_SET = CLIMATE_BACK_WINDOW_HEAT + '/' + SET_SUFFIX -CLIMATE_FRONT_WINDOW_HEAT = CLIMATE + '/frontWindowDefrosterHeating' -CLIMATE_FRONT_WINDOW_HEAT_SET = CLIMATE_FRONT_WINDOW_HEAT + '/' + SET_SUFFIX -CLIMATE_EXTERIOR_TEMPERATURE = CLIMATE + '/exteriorTemperature' -CLIMATE_INTERIOR_TEMPERATURE = CLIMATE + '/interiorTemperature' -CLIMATE_REMOTE_CLIMATE_STATE = CLIMATE + '/remoteClimateState' -CLIMATE_REMOTE_CLIMATE_STATE_SET = CLIMATE_REMOTE_CLIMATE_STATE + '/' + SET_SUFFIX -CLIMATE_REMOTE_TEMPERATURE = CLIMATE + '/remoteTemperature' -CLIMATE_REMOTE_TEMPERATURE_SET = CLIMATE_REMOTE_TEMPERATURE + '/' + SET_SUFFIX -CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL = CLIMATE + '/heatedSeatsFrontLeftLevel' -CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL_SET = CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL + '/' + SET_SUFFIX -CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL = CLIMATE + '/heatedSeatsFrontRightLevel' -CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL_SET = CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL + '/' + SET_SUFFIX - -WINDOWS = 'windows' -WINDOWS_DRIVER = WINDOWS + '/driver' -WINDOWS_PASSENGER = WINDOWS + '/passenger' -WINDOWS_REAR_LEFT = WINDOWS + '/rearLeft' -WINDOWS_REAR_RIGHT = WINDOWS + '/rearRight' -WINDOWS_SUN_ROOF = WINDOWS + '/sunRoof' - -DOORS = 'doors' -DOORS_BONNET = DOORS + '/bonnet' -DOORS_BOOT = DOORS + '/boot' -DOORS_BOOT_SET = DOORS_BOOT + '/' + SET_SUFFIX -DOORS_DRIVER = DOORS + '/driver' -DOORS_LOCKED = DOORS + '/locked' -DOORS_LOCKED_SET = DOORS_LOCKED + '/' + SET_SUFFIX -DOORS_PASSENGER = DOORS + '/passenger' -DOORS_REAR_LEFT = DOORS + '/rearLeft' -DOORS_REAR_RIGHT = DOORS + '/rearRight' - -LIGHTS = 'lights' -LIGHTS_MAIN_BEAM = LIGHTS + '/mainBeam' -LIGHTS_DIPPED_BEAM = LIGHTS + '/dippedBeam' -LIGHTS_SIDE = LIGHTS + '/side' - -DRIVETRAIN = 'drivetrain' -DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE = DRIVETRAIN + '/auxiliaryBatteryVoltage' -DRIVETRAIN_CHARGER_CONNECTED = DRIVETRAIN + '/chargerConnected' -DRIVETRAIN_CHARGING = DRIVETRAIN + '/charging' -DRIVETRAIN_CHARGING_SET = DRIVETRAIN_CHARGING + '/' + SET_SUFFIX -DRIVETRAIN_CHARGING_STOP_REASON = DRIVETRAIN + '/chargingStopReason' -DRIVETRAIN_CHARGING_LAST_START = DRIVETRAIN_CHARGING + '/lastStart' -DRIVETRAIN_CHARGING_LAST_END = DRIVETRAIN_CHARGING + '/lastEnd' -DRIVETRAIN_BATTERY_HEATING = DRIVETRAIN + '/batteryHeating' -DRIVETRAIN_BATTERY_HEATING_SET = DRIVETRAIN_BATTERY_HEATING + '/' + SET_SUFFIX -DRIVETRAIN_BATTERY_HEATING_STOP_REASON = DRIVETRAIN + '/batteryHeatingStopReason' -DRIVETRAIN_CHARGING_SCHEDULE = DRIVETRAIN + '/chargingSchedule' -DRIVETRAIN_CHARGING_SCHEDULE_SET = DRIVETRAIN_CHARGING_SCHEDULE + '/' + SET_SUFFIX -DRIVETRAIN_BATTERY_HEATING_SCHEDULE = DRIVETRAIN + '/batteryHeatingSchedule' -DRIVETRAIN_BATTERY_HEATING_SCHEDULE_SET = DRIVETRAIN_BATTERY_HEATING_SCHEDULE + '/' + SET_SUFFIX -DRIVETRAIN_CHARGING_TYPE = DRIVETRAIN + '/chargingType' -DRIVETRAIN_CURRENT = DRIVETRAIN + '/current' -DRIVETRAIN_HV_BATTERY_ACTIVE = DRIVETRAIN + '/hvBatteryActive' -DRIVETRAIN_HV_BATTERY_ACTIVE_SET = DRIVETRAIN_HV_BATTERY_ACTIVE + '/' + SET_SUFFIX -DRIVETRAIN_MILEAGE = DRIVETRAIN + '/mileage' -DRIVETRAIN_MILEAGE_OF_DAY = DRIVETRAIN + '/mileageOfTheDay' -DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE = DRIVETRAIN + '/mileageSinceLastCharge' -DRIVETRAIN_POWER = DRIVETRAIN + '/power' -DRIVETRAIN_POWER_USAGE_OF_DAY = DRIVETRAIN + '/powerUsageOfDay' -DRIVETRAIN_POWER_USAGE_SINCE_LAST_CHARGE = DRIVETRAIN + '/powerUsageSinceLastCharge' -DRIVETRAIN_RANGE = DRIVETRAIN + '/range' -DRIVETRAIN_RUNNING = DRIVETRAIN + '/running' -DRIVETRAIN_REMAINING_CHARGING_TIME = DRIVETRAIN + '/remainingChargingTime' -DRIVETRAIN_HYBRID_ELECTRICAL_RANGE = DRIVETRAIN + '/hybrid_electrical_range' -DRIVETRAIN_SOC = DRIVETRAIN + '/soc' -DRIVETRAIN_SOC_TARGET = DRIVETRAIN + '/socTarget' -DRIVETRAIN_SOC_TARGET_SET = DRIVETRAIN_SOC_TARGET + '/' + SET_SUFFIX -DRIVETRAIN_CHARGECURRENT_LIMIT = DRIVETRAIN + '/chargeCurrentLimit' -DRIVETRAIN_CHARGECURRENT_LIMIT_SET = DRIVETRAIN_CHARGECURRENT_LIMIT + '/' + SET_SUFFIX -DRIVETRAIN_SOC_KWH = DRIVETRAIN + '/soc_kwh' -DRIVETRAIN_LAST_CHARGE_ENDING_POWER = DRIVETRAIN + '/lastChargeEndingPower' -DRIVETRAIN_TOTAL_BATTERY_CAPACITY = DRIVETRAIN + '/totalBatteryCapacity' -DRIVETRAIN_VOLTAGE = DRIVETRAIN + '/voltage' -DRIVETRAIN_CHARGING_CABLE_LOCK = DRIVETRAIN + '/chargingCableLock' -DRIVETRAIN_CHARGING_CABLE_LOCK_SET = DRIVETRAIN_CHARGING_CABLE_LOCK + '/' + SET_SUFFIX -DRIVETRAIN_CURRENT_JOURNEY = DRIVETRAIN + '/currentJourney' -DRIVETRAIN_FOSSIL_FUEL = DRIVETRAIN + '/fossilFuel' -DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE = DRIVETRAIN_FOSSIL_FUEL + '/percentage' -DRIVETRAIN_FOSSIL_FUEL_RANGE = DRIVETRAIN_FOSSIL_FUEL + '/range' - -OBC = 'obc' -OBC_CURRENT = OBC + '/current' -OBC_VOLTAGE = OBC + '/voltage' -OBC_POWER_SINGLE_PHASE = OBC + '/powerSinglePhase' -OBC_POWER_THREE_PHASE = OBC + '/powerThreePhase' - -CCU = 'ccu' -CCU_ONBOARD_PLUG_STATUS = CCU + '/onboardChargerPlugStatus' -CCU_OFFBOARD_PLUG_STATUS = CCU + '/offboardChargerPlugStatus' - -BMS = 'bms' -BMS_CHARGE_STATUS = BMS + '/chargeStatus' - -INFO = 'info' -INFO_BRAND = INFO + '/brand' -INFO_MODEL = INFO + '/model' -INFO_YEAR = INFO + '/year' -INFO_SERIES = INFO + '/series' -INFO_COLOR = INFO + '/color' -INFO_CONFIGURATION = INFO + '/configuration' -INFO_LAST_MESSAGE = INFO + '/lastMessage' -INFO_LAST_MESSAGE_ID = INFO_LAST_MESSAGE + '/messageId' -INFO_LAST_MESSAGE_TYPE = INFO_LAST_MESSAGE + '/messageType' -INFO_LAST_MESSAGE_TITLE = INFO_LAST_MESSAGE + '/title' -INFO_LAST_MESSAGE_TIME = INFO_LAST_MESSAGE + '/messageTime' -INFO_LAST_MESSAGE_SENDER = INFO_LAST_MESSAGE + '/sender' -INFO_LAST_MESSAGE_CONTENT = INFO_LAST_MESSAGE + '/content' -INFO_LAST_MESSAGE_STATUS = INFO_LAST_MESSAGE + '/status' -INFO_LAST_MESSAGE_VIN = INFO_LAST_MESSAGE + '/vin' - -INTERNAL = '_internal' -INTERNAL_API = INTERNAL + '/api' -INTERNAL_LWT = INTERNAL + '/lwt' -INTERNAL_ABRP = INTERNAL + '/abrp' -INTERNAL_OSMAND = INTERNAL + '/osmand' -INTERNAL_CONFIGURATION_RAW = INTERNAL + '/configuration/raw' - -LOCATION = 'location' -LOCATION_POSITION = LOCATION + '/position' -LOCATION_HEADING = LOCATION + '/heading' -LOCATION_LATITUDE = LOCATION + '/latitude' -LOCATION_LONGITUDE = LOCATION + '/longitude' -LOCATION_SPEED = LOCATION + '/speed' -LOCATION_ELEVATION = LOCATION + '/elevation' -LOCATION_FIND_MY_CAR = LOCATION + '/findMyCar' -LOCATION_FIND_MY_CAR_SET = LOCATION_FIND_MY_CAR + '/' + SET_SUFFIX - -REFRESH = 'refresh' -REFRESH_LAST_ACTIVITY = REFRESH + '/lastActivity' -REFRESH_LAST_CHARGE_STATE = REFRESH + '/lastChargeState' -REFRESH_LAST_VEHICLE_STATE = REFRESH + '/lastVehicleState' -REFRESH_LAST_ERROR = REFRESH + '/lastError' -REFRESH_MODE = REFRESH + '/mode' -REFRESH_MODE_SET = REFRESH_MODE + '/' + SET_SUFFIX -REFRESH_PERIOD = REFRESH + '/period' -REFRESH_PERIOD_ACTIVE = REFRESH_PERIOD + '/active' -REFRESH_PERIOD_ACTIVE_SET = REFRESH_PERIOD_ACTIVE + '/' + SET_SUFFIX -REFRESH_PERIOD_CHARGING = REFRESH_PERIOD + '/charging' -REFRESH_PERIOD_INACTIVE = REFRESH_PERIOD + '/inActive' -REFRESH_PERIOD_INACTIVE_SET = REFRESH_PERIOD_INACTIVE + '/' + SET_SUFFIX -REFRESH_PERIOD_AFTER_SHUTDOWN = REFRESH_PERIOD + '/afterShutdown' -REFRESH_PERIOD_AFTER_SHUTDOWN_SET = REFRESH_PERIOD_AFTER_SHUTDOWN + '/' + SET_SUFFIX -REFRESH_PERIOD_INACTIVE_GRACE = REFRESH_PERIOD + '/inActiveGrace' -REFRESH_PERIOD_INACTIVE_GRACE_SET = REFRESH_PERIOD_INACTIVE_GRACE + '/' + SET_SUFFIX -REFRESH_PERIOD_ERROR = REFRESH_PERIOD + '/error' - -TYRES = 'tyres' -TYRES_FRONT_LEFT_PRESSURE = TYRES + '/frontLeftPressure' -TYRES_FRONT_RIGHT_PRESSURE = TYRES + '/frontRightPressure' -TYRES_REAR_LEFT_PRESSURE = TYRES + '/rearLeftPressure' -TYRES_REAR_RIGHT_PRESSURE = TYRES + '/rearRightPressure' - -VEHICLES = 'vehicles' diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..9e90c9f --- /dev/null +++ b/poetry.lock @@ -0,0 +1,768 @@ +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. + +[[package]] +name = "anyio" +version = "4.9.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, + {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "apscheduler" +version = "3.11.0" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"}, + {file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + +[[package]] +name = "astroid" +version = "3.3.10" +description = "An abstract syntax tree for Python with inference support." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "astroid-3.3.10-py3-none-any.whl", hash = "sha256:104fb9cb9b27ea95e847a94c003be03a9e039334a8ebca5ee27dafaf5c5711eb"}, + {file = "astroid-3.3.10.tar.gz", hash = "sha256:c332157953060c6deb9caa57303ae0d20b0fbdb2e59b4a4f2a6ba49d0a7961ce"}, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, + {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.8.2" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "coverage-7.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bd8ec21e1443fd7a447881332f7ce9d35b8fbd2849e761bb290b584535636b0a"}, + {file = "coverage-7.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4c26c2396674816deaeae7ded0e2b42c26537280f8fe313335858ffff35019be"}, + {file = "coverage-7.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1aec326ed237e5880bfe69ad41616d333712c7937bcefc1343145e972938f9b3"}, + {file = "coverage-7.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e818796f71702d7a13e50c70de2a1924f729228580bcba1607cccf32eea46e6"}, + {file = "coverage-7.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:546e537d9e24efc765c9c891328f30f826e3e4808e31f5d0f87c4ba12bbd1622"}, + {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ab9b09a2349f58e73f8ebc06fac546dd623e23b063e5398343c5270072e3201c"}, + {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd51355ab8a372d89fb0e6a31719e825cf8df8b6724bee942fb5b92c3f016ba3"}, + {file = "coverage-7.8.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0774df1e093acb6c9e4d58bce7f86656aeed6c132a16e2337692c12786b32404"}, + {file = "coverage-7.8.2-cp310-cp310-win32.whl", hash = "sha256:00f2e2f2e37f47e5f54423aeefd6c32a7dbcedc033fcd3928a4f4948e8b96af7"}, + {file = "coverage-7.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:145b07bea229821d51811bf15eeab346c236d523838eda395ea969d120d13347"}, + {file = "coverage-7.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b99058eef42e6a8dcd135afb068b3d53aff3921ce699e127602efff9956457a9"}, + {file = "coverage-7.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5feb7f2c3e6ea94d3b877def0270dff0947b8d8c04cfa34a17be0a4dc1836879"}, + {file = "coverage-7.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:670a13249b957bb9050fab12d86acef7bf8f6a879b9d1a883799276e0d4c674a"}, + {file = "coverage-7.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bdc8bf760459a4a4187b452213e04d039990211f98644c7292adf1e471162b5"}, + {file = "coverage-7.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07a989c867986c2a75f158f03fdb413128aad29aca9d4dbce5fc755672d96f11"}, + {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2db10dedeb619a771ef0e2949ccba7b75e33905de959c2643a4607bef2f3fb3a"}, + {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e6ea7dba4e92926b7b5f0990634b78ea02f208d04af520c73a7c876d5a8d36cb"}, + {file = "coverage-7.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ef2f22795a7aca99fc3c84393a55a53dd18ab8c93fb431004e4d8f0774150f54"}, + {file = "coverage-7.8.2-cp311-cp311-win32.whl", hash = "sha256:641988828bc18a6368fe72355df5f1703e44411adbe49bba5644b941ce6f2e3a"}, + {file = "coverage-7.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:8ab4a51cb39dc1933ba627e0875046d150e88478dbe22ce145a68393e9652975"}, + {file = "coverage-7.8.2-cp311-cp311-win_arm64.whl", hash = "sha256:8966a821e2083c74d88cca5b7dcccc0a3a888a596a04c0b9668a891de3a0cc53"}, + {file = "coverage-7.8.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2f6fe3654468d061942591aef56686131335b7a8325684eda85dacdf311356c"}, + {file = "coverage-7.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76090fab50610798cc05241bf83b603477c40ee87acd358b66196ab0ca44ffa1"}, + {file = "coverage-7.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bd0a0a5054be160777a7920b731a0570284db5142abaaf81bcbb282b8d99279"}, + {file = "coverage-7.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da23ce9a3d356d0affe9c7036030b5c8f14556bd970c9b224f9c8205505e3b99"}, + {file = "coverage-7.8.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9392773cffeb8d7e042a7b15b82a414011e9d2b5fdbbd3f7e6a6b17d5e21b20"}, + {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:876cbfd0b09ce09d81585d266c07a32657beb3eaec896f39484b631555be0fe2"}, + {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3da9b771c98977a13fbc3830f6caa85cae6c9c83911d24cb2d218e9394259c57"}, + {file = "coverage-7.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a990f6510b3292686713bfef26d0049cd63b9c7bb17e0864f133cbfd2e6167f"}, + {file = "coverage-7.8.2-cp312-cp312-win32.whl", hash = "sha256:bf8111cddd0f2b54d34e96613e7fbdd59a673f0cf5574b61134ae75b6f5a33b8"}, + {file = "coverage-7.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:86a323a275e9e44cdf228af9b71c5030861d4d2610886ab920d9945672a81223"}, + {file = "coverage-7.8.2-cp312-cp312-win_arm64.whl", hash = "sha256:820157de3a589e992689ffcda8639fbabb313b323d26388d02e154164c57b07f"}, + {file = "coverage-7.8.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ea561010914ec1c26ab4188aef8b1567272ef6de096312716f90e5baa79ef8ca"}, + {file = "coverage-7.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cb86337a4fcdd0e598ff2caeb513ac604d2f3da6d53df2c8e368e07ee38e277d"}, + {file = "coverage-7.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a4636ddb666971345541b59899e969f3b301143dd86b0ddbb570bd591f1e85"}, + {file = "coverage-7.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5040536cf9b13fb033f76bcb5e1e5cb3b57c4807fef37db9e0ed129c6a094257"}, + {file = "coverage-7.8.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc67994df9bcd7e0150a47ef41278b9e0a0ea187caba72414b71dc590b99a108"}, + {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e6c86888fd076d9e0fe848af0a2142bf606044dc5ceee0aa9eddb56e26895a0"}, + {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:684ca9f58119b8e26bef860db33524ae0365601492e86ba0b71d513f525e7050"}, + {file = "coverage-7.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8165584ddedb49204c4e18da083913bdf6a982bfb558632a79bdaadcdafd0d48"}, + {file = "coverage-7.8.2-cp313-cp313-win32.whl", hash = "sha256:34759ee2c65362163699cc917bdb2a54114dd06d19bab860725f94ef45a3d9b7"}, + {file = "coverage-7.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:2f9bc608fbafaee40eb60a9a53dbfb90f53cc66d3d32c2849dc27cf5638a21e3"}, + {file = "coverage-7.8.2-cp313-cp313-win_arm64.whl", hash = "sha256:9fe449ee461a3b0c7105690419d0b0aba1232f4ff6d120a9e241e58a556733f7"}, + {file = "coverage-7.8.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8369a7c8ef66bded2b6484053749ff220dbf83cba84f3398c84c51a6f748a008"}, + {file = "coverage-7.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:159b81df53a5fcbc7d45dae3adad554fdbde9829a994e15227b3f9d816d00b36"}, + {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6fcbbd35a96192d042c691c9e0c49ef54bd7ed865846a3c9d624c30bb67ce46"}, + {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05364b9cc82f138cc86128dc4e2e1251c2981a2218bfcd556fe6b0fbaa3501be"}, + {file = "coverage-7.8.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46d532db4e5ff3979ce47d18e2fe8ecad283eeb7367726da0e5ef88e4fe64740"}, + {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4000a31c34932e7e4fa0381a3d6deb43dc0c8f458e3e7ea6502e6238e10be625"}, + {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:43ff5033d657cd51f83015c3b7a443287250dc14e69910577c3e03bd2e06f27b"}, + {file = "coverage-7.8.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94316e13f0981cbbba132c1f9f365cac1d26716aaac130866ca812006f662199"}, + {file = "coverage-7.8.2-cp313-cp313t-win32.whl", hash = "sha256:3f5673888d3676d0a745c3d0e16da338c5eea300cb1f4ada9c872981265e76d8"}, + {file = "coverage-7.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2c08b05ee8d7861e45dc5a2cc4195c8c66dca5ac613144eb6ebeaff2d502e73d"}, + {file = "coverage-7.8.2-cp313-cp313t-win_arm64.whl", hash = "sha256:1e1448bb72b387755e1ff3ef1268a06617afd94188164960dba8d0245a46004b"}, + {file = "coverage-7.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:496948261eaac5ac9cf43f5d0a9f6eb7a6d4cb3bedb2c5d294138142f5c18f2a"}, + {file = "coverage-7.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eacd2de0d30871eff893bab0b67840a96445edcb3c8fd915e6b11ac4b2f3fa6d"}, + {file = "coverage-7.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b039ffddc99ad65d5078ef300e0c7eed08c270dc26570440e3ef18beb816c1ca"}, + {file = "coverage-7.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e49824808d4375ede9dd84e9961a59c47f9113039f1a525e6be170aa4f5c34d"}, + {file = "coverage-7.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b069938961dfad881dc2f8d02b47645cd2f455d3809ba92a8a687bf513839787"}, + {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:de77c3ba8bb686d1c411e78ee1b97e6e0b963fb98b1637658dd9ad2c875cf9d7"}, + {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1676628065a498943bd3f64f099bb573e08cf1bc6088bbe33cf4424e0876f4b3"}, + {file = "coverage-7.8.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8e1a26e7e50076e35f7afafde570ca2b4d7900a491174ca357d29dece5aacee7"}, + {file = "coverage-7.8.2-cp39-cp39-win32.whl", hash = "sha256:6782a12bf76fa61ad9350d5a6ef5f3f020b57f5e6305cbc663803f2ebd0f270a"}, + {file = "coverage-7.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1efa4166ba75ccefd647f2d78b64f53f14fb82622bc94c5a5cb0a622f50f1c9e"}, + {file = "coverage-7.8.2-pp39.pp310.pp311-none-any.whl", hash = "sha256:ec455eedf3ba0bbdf8f5a570012617eb305c63cb9f03428d39bf544cb2b94837"}, + {file = "coverage-7.8.2-py3-none-any.whl", hash = "sha256:726f32ee3713f7359696331a18daf0c3b3a70bb0ae71141b9d3c52be7c595e32"}, + {file = "coverage-7.8.2.tar.gz", hash = "sha256:a886d531373a1f6ff9fad2a2ba4a045b68467b779ae729ee0b3b10ac20033b27"}, +] + +[package.extras] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "dacite" +version = "1.9.2" +description = "Simple creation of data classes from dictionaries." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0"}, + {file = "dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09"}, +] + +[package.extras] +dev = ["black", "coveralls", "mypy", "pre-commit", "pylint", "pytest (>=5)", "pytest-benchmark", "pytest-cov"] + +[[package]] +name = "dill" +version = "0.4.0" +description = "serialize all of Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049"}, + {file = "dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0"}, +] + +[package.extras] +graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] + +[[package]] +name = "gmqtt" +version = "0.7.0" +description = "Client for MQTT protocol" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "gmqtt-0.7.0-py3-none-any.whl", hash = "sha256:3e5571a20e9c115d83d600caa228b06f716087653e241035e29cec73277b52cc"}, + {file = "gmqtt-0.7.0.tar.gz", hash = "sha256:bedfec7bac26b6b4ce1f0c4c32cff3d663526a54c882d323d41560fc3b9b44a2"}, +] + +[package.extras] +test = ["atomicwrites (>=1.3.0)", "attrs (>=19.1.0)", "codecov (>=2.0.15)", "coverage (>=4.5.3)", "more-itertools (>=7.0.0)", "pluggy (>=0.11.0)", "py (>=1.8.0)", "pytest (>=5.4.0)", "pytest-asyncio (>=0.12.0)", "pytest-cov (>=2.7.1)", "six (>=1.12.0)", "uvloop (>=0.14.0)"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, + {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "isort" +version = "6.0.1" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615"}, + {file = "isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450"}, +] + +[package.extras] +colors = ["colorama"] +plugins = ["setuptools"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mock" +version = "5.2.0" +description = "Rolling backport of unittest.mock for all Pythons" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, + {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, +] + +[package.extras] +build = ["blurb", "twine", "wheel"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "mypy" +version = "1.16.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "mypy-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7909541fef256527e5ee9c0a7e2aeed78b6cda72ba44298d1334fe7881b05c5c"}, + {file = "mypy-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e71d6f0090c2256c713ed3d52711d01859c82608b5d68d4fa01a3fe30df95571"}, + {file = "mypy-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:936ccfdd749af4766be824268bfe22d1db9eb2f34a3ea1d00ffbe5b5265f5491"}, + {file = "mypy-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4086883a73166631307fdd330c4a9080ce24913d4f4c5ec596c601b3a4bdd777"}, + {file = "mypy-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:feec38097f71797da0231997e0de3a58108c51845399669ebc532c815f93866b"}, + {file = "mypy-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:09a8da6a0ee9a9770b8ff61b39c0bb07971cda90e7297f4213741b48a0cc8d93"}, + {file = "mypy-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9f826aaa7ff8443bac6a494cf743f591488ea940dd360e7dd330e30dd772a5ab"}, + {file = "mypy-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82d056e6faa508501af333a6af192c700b33e15865bda49611e3d7d8358ebea2"}, + {file = "mypy-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089bedc02307c2548eb51f426e085546db1fa7dd87fbb7c9fa561575cf6eb1ff"}, + {file = "mypy-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a2322896003ba66bbd1318c10d3afdfe24e78ef12ea10e2acd985e9d684a666"}, + {file = "mypy-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:021a68568082c5b36e977d54e8f1de978baf401a33884ffcea09bd8e88a98f4c"}, + {file = "mypy-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:54066fed302d83bf5128632d05b4ec68412e1f03ef2c300434057d66866cea4b"}, + {file = "mypy-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5436d11e89a3ad16ce8afe752f0f373ae9620841c50883dc96f8b8805620b13"}, + {file = "mypy-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2622af30bf01d8fc36466231bdd203d120d7a599a6d88fb22bdcb9dbff84090"}, + {file = "mypy-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d045d33c284e10a038f5e29faca055b90eee87da3fc63b8889085744ebabb5a1"}, + {file = "mypy-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4968f14f44c62e2ec4a038c8797a87315be8df7740dc3ee8d3bfe1c6bf5dba8"}, + {file = "mypy-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb14a4a871bb8efb1e4a50360d4e3c8d6c601e7a31028a2c79f9bb659b63d730"}, + {file = "mypy-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd4e1ebe126152a7bbaa4daedd781c90c8f9643c79b9748caa270ad542f12bec"}, + {file = "mypy-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e056237c89f1587a3be1a3a70a06a698d25e2479b9a2f57325ddaaffc3567b"}, + {file = "mypy-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b07e107affb9ee6ce1f342c07f51552d126c32cd62955f59a7db94a51ad12c0"}, + {file = "mypy-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fb60cbd85dc65d4d63d37cb5c86f4e3a301ec605f606ae3a9173e5cf34997b"}, + {file = "mypy-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7e32297a437cc915599e0578fa6bc68ae6a8dc059c9e009c628e1c47f91495d"}, + {file = "mypy-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:afe420c9380ccec31e744e8baff0d406c846683681025db3531b32db56962d52"}, + {file = "mypy-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:55f9076c6ce55dd3f8cd0c6fff26a008ca8e5131b89d5ba6d86bd3f47e736eeb"}, + {file = "mypy-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f56236114c425620875c7cf71700e3d60004858da856c6fc78998ffe767b73d3"}, + {file = "mypy-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:15486beea80be24ff067d7d0ede673b001d0d684d0095803b3e6e17a886a2a92"}, + {file = "mypy-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2ed0e0847a80655afa2c121835b848ed101cc7b8d8d6ecc5205aedc732b1436"}, + {file = "mypy-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb5fbc8063cb4fde7787e4c0406aa63094a34a2daf4673f359a1fb64050e9cb2"}, + {file = "mypy-1.16.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a5fcfdb7318c6a8dd127b14b1052743b83e97a970f0edb6c913211507a255e20"}, + {file = "mypy-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:2e7e0ad35275e02797323a5aa1be0b14a4d03ffdb2e5f2b0489fa07b89c67b21"}, + {file = "mypy-1.16.0-py3-none-any.whl", hash = "sha256:29e1499864a3888bca5c1542f2d7232c6e586295183320caa95758fc84034031"}, + {file = "mypy-1.16.0.tar.gz", hash = "sha256:84b94283f817e2aa6350a14b4a8fb2a35a53c286f97c9d30f53b63620e7af8ab"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +pathspec = ">=0.9.0" +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +description = "Cryptographic library for Python" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +files = [ + {file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4"}, + {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818"}, + {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39"}, + {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27"}, + {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c"}, + {file = "pycryptodome-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56"}, + {file = "pycryptodome-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6"}, + {file = "pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef"}, +] + +[[package]] +name = "pylint" +version = "3.3.7" +description = "python code static checker" +optional = false +python-versions = ">=3.9.0" +groups = ["dev"] +files = [ + {file = "pylint-3.3.7-py3-none-any.whl", hash = "sha256:43860aafefce92fca4cf6b61fe199cdc5ae54ea28f9bf4cd49de267b5195803d"}, + {file = "pylint-3.3.7.tar.gz", hash = "sha256:2b11de8bde49f9c5059452e0c310c079c746a0a8eeaa789e5aa966ecc23e4559"}, +] + +[package.dependencies] +astroid = ">=3.3.8,<=3.4.0.dev0" +colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} +dill = {version = ">=0.3.7", markers = "python_version >= \"3.12\""} +isort = ">=4.2.5,<5.13 || >5.13,<7" +mccabe = ">=0.6,<0.8" +platformdirs = ">=2.2" +tomlkit = ">=0.10.1" + +[package.extras] +spelling = ["pyenchant (>=3.2,<4.0)"] +testutils = ["gitpython (>3)"] + +[[package]] +name = "pytest" +version = "8.3.5" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.25.3" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3"}, + {file = "pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-cov" +version = "6.1.1" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, + {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, + {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "ruff" +version = "0.9.10" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d"}, + {file = "ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d"}, + {file = "ruff-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5284dcac6b9dbc2fcb71fdfc26a217b2ca4ede6ccd57476f52a587451ebe450d"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47678f39fa2a3da62724851107f438c8229a3470f533894b5568a39b40029c0c"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99713a6e2766b7a17147b309e8c915b32b07a25c9efd12ada79f217c9c778b3e"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524ee184d92f7c7304aa568e2db20f50c32d1d0caa235d8ddf10497566ea1a12"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df92aeac30af821f9acf819fc01b4afc3dfb829d2782884f8739fb52a8119a16"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de42e4edc296f520bb84954eb992a07a0ec5a02fecb834498415908469854a52"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d257f95b65806104b6b1ffca0ea53f4ef98454036df65b1eda3693534813ecd1"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60dec7201c0b10d6d11be00e8f2dbb6f40ef1828ee75ed739923799513db24c"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d838b60007da7a39c046fcdd317293d10b845001f38bcb55ba766c3875b01e43"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccaf903108b899beb8e09a63ffae5869057ab649c1e9231c05ae354ebc62066c"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9567d135265d46e59d62dc60c0bfad10e9a6822e231f5b24032dba5a55be6b5"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f202f0d93738c28a89f8ed9eaba01b7be339e5d8d642c994347eaa81c6d75b8"}, + {file = "ruff-0.9.10-py3-none-win32.whl", hash = "sha256:bfb834e87c916521ce46b1788fbb8484966e5113c02df216680102e9eb960029"}, + {file = "ruff-0.9.10-py3-none-win_amd64.whl", hash = "sha256:f2160eeef3031bf4b17df74e307d4c5fb689a6f3a26a2de3f7ef4044e3c484f1"}, + {file = "ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69"}, + {file = "ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7"}, +] + +[[package]] +name = "saic-ismart-client-ng" +version = "0.8.2" +description = "SAIC next gen client library (MG iSMART)" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "saic_ismart_client_ng-0.8.2-py3-none-any.whl", hash = "sha256:9eeb29af94ebef1b74a7b44203207718ea16d883cee126f6849c4ffdac1e35c5"}, + {file = "saic_ismart_client_ng-0.8.2.tar.gz", hash = "sha256:27d9d08cb5df6a8fbcb22a01bb0dfda9bea0b688a47d8fe0a5fd6acdf023507b"}, +] + +[package.dependencies] +dacite = ">=1.8.1,<2.0.0" +httpx = ">=0.27.0,<0.29.0" +pycryptodome = ">=3.20.0,<4.0.0" +tenacity = ">=9.0.0,<10.0.0" + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + +[[package]] +name = "tomlkit" +version = "0.13.2" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, + {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] +markers = {main = "python_version == \"3.12\""} + +[[package]] +name = "tzdata" +version = "2025.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.12,<4.0" +content-hash = "d7e8c7f324186715e8ab5a62b9c298e29498d6e942373e84fb4abbbc876b469a" diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 0000000..ab1033b --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[virtualenvs] +in-project = true diff --git a/publisher/core.py b/publisher/core.py deleted file mode 100644 index 56fd108..0000000 --- a/publisher/core.py +++ /dev/null @@ -1,130 +0,0 @@ -import json -import re -from abc import ABC -from typing import Optional - -import mqtt_topics -from configuration import Configuration - -INVALID_MQTT_CHARS = re.compile(r'[+#*$>]') - -class MqttCommandListener(ABC): - async def on_mqtt_command_received(self, *, vin: str, topic: str, payload: str) -> None: - raise NotImplementedError("Should have implemented this") - - async def on_charging_detected(self, vin: str) -> None: - raise NotImplementedError("Should have implemented this") - - -class Publisher(ABC): - def __init__(self, config: Configuration): - self.__configuration = config - self.__command_listener = None - self.__topic_root = self.__remove_special_mqtt_characters(config.mqtt_topic) - - async def connect(self): - pass - - def is_connected(self) -> bool: - raise NotImplementedError() - - def publish_json(self, key: str, data: dict, no_prefix: bool = False) -> None: - raise NotImplementedError() - - def publish_str(self, key: str, value: str, no_prefix: bool = False) -> None: - raise NotImplementedError() - - def publish_int(self, key: str, value: int, no_prefix: bool = False) -> None: - raise NotImplementedError() - - def publish_bool(self, key: str, value: bool | int | None, no_prefix: bool = False) -> None: - raise NotImplementedError() - - def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: - raise NotImplementedError() - - def get_mqtt_account_prefix(self) -> str: - return self.__remove_special_mqtt_characters( - f'{self.__topic_root}/{self.configuration.saic_user}' - ) - - def get_topic(self, key: str, no_prefix: bool) -> str: - if no_prefix: - topic = key - else: - topic = f'{self.__topic_root}/{key}' - return self.__remove_special_mqtt_characters(topic) - - def __remove_special_mqtt_characters(self, input_str: str) -> str: - return INVALID_MQTT_CHARS.sub('_', input_str) - - def __remove_byte_strings(self, data: dict) -> dict: - for key in data.keys(): - if isinstance(data[key], bytes): - data[key] = str(data[key]) - elif isinstance(data[key], dict): - data[key] = self.__remove_byte_strings(data[key]) - elif isinstance(data[key], list): - for item in data[key]: - if isinstance(item, dict): - self.__remove_byte_strings(item) - return data - - def __anonymize(self, data: dict) -> dict: - if isinstance(data, dict): - for key in data.keys(): - if isinstance(data[key], str): - match key: - case 'password': - data[key] = '******' - case 'uid' | 'email' | 'user_name' | 'account' | 'ping' | 'token' | 'access_token' | 'refreshToken' | 'refresh_token' | 'vin': - data[key] = Publisher.anonymize_str(data[key]) - case 'deviceId': - data[key] = self.anonymize_device_id(data[key]) - case 'seconds' | 'bindTime' | 'eventCreationTime' | 'latitude' | 'longitude': - data[key] = Publisher.anonymize_int(data[key]) - case 'eventID' | 'event-id' | 'event_id' | 'eventId' | 'event_id' | 'eventID' | 'lastKeySeen': - data[key] = 9999 - case 'content': - data[key] = re.sub('\\(\\*\\*\\*...\\)', '(***XXX)', data[key]) - elif isinstance(data[key], dict): - data[key] = self.__anonymize(data[key]) - elif isinstance(data[key], (list, set, tuple)): - data[key] = [self.__anonymize(item) for item in data[key]] - return data - - def keepalive(self): - self.publish_str(mqtt_topics.INTERNAL_LWT, 'online', False) - - @staticmethod - def anonymize_str(value: str) -> str: - r = re.sub('[a-zA-Z]', 'X', value) - return re.sub('[1-9]', '9', r) - - def anonymize_device_id(self, device_id: str) -> str: - elements = device_id.split('###') - return f'{self.anonymize_str(elements[0])}###{self.anonymize_str(elements[1])}' - - @staticmethod - def anonymize_int(value: int) -> int: - return int(value / 100000 * 100000) - - def dict_to_anonymized_json(self, data): - no_binary_strings = self.__remove_byte_strings(data) - if self.configuration.anonymized_publishing: - result = self.__anonymize(no_binary_strings) - else: - result = no_binary_strings - return json.dumps(result, indent=2) - - @property - def configuration(self) -> Configuration: - return self.__configuration - - @property - def command_listener(self) -> Optional[MqttCommandListener]: - return self.__command_listener - - @command_listener.setter - def command_listener(self, listener: MqttCommandListener): - self.__command_listener = listener diff --git a/publisher/mqtt_publisher.py b/publisher/mqtt_publisher.py deleted file mode 100644 index 9c8c871..0000000 --- a/publisher/mqtt_publisher.py +++ /dev/null @@ -1,181 +0,0 @@ -import logging -import os -import ssl -from typing import override - -import gmqtt - -import mqtt_topics -from configuration import Configuration -from integrations.openwb.charging_station import ChargingStation -from publisher.core import Publisher - -LOG = logging.getLogger(__name__) -LOG.setLevel(level=os.getenv('LOG_LEVEL', 'INFO').upper()) - -MQTT_LOG = logging.getLogger(gmqtt.__name__) -MQTT_LOG.setLevel(level=os.getenv('MQTT_LOG_LEVEL', 'INFO').upper()) - - -class MqttPublisher(Publisher): - def __init__(self, configuration: Configuration): - super().__init__(configuration) - self.publisher_id = configuration.mqtt_client_id - self.client = None - self.host = self.configuration.mqtt_host - self.port = self.configuration.mqtt_port - self.transport_protocol = self.configuration.mqtt_transport_protocol - self.vin_by_charge_state_topic: dict[str, str] = {} - self.last_charge_state_by_vin: [str, str] = {} - self.vin_by_charger_connected_topic: dict[str, str] = {} - - mqtt_client = gmqtt.Client( - client_id=str(self.publisher_id), - transport=self.transport_protocol.transport_mechanism, - logger=MQTT_LOG, - will_message=gmqtt.Message( - topic=self.get_topic(mqtt_topics.INTERNAL_LWT, False), - payload='offline', - retain=True - ) - ) - mqtt_client.on_connect = self.__on_connect - mqtt_client.on_message = self.__on_message - self.client = mqtt_client - - @override - async def connect(self): - if self.configuration.mqtt_user is not None: - if self.configuration.mqtt_password is not None: - self.client.set_auth_credentials( - username=self.configuration.mqtt_user, - password=self.configuration.mqtt_password - ) - else: - self.client.set_auth_credentials( - username=self.configuration.mqtt_user - ) - if self.transport_protocol.with_tls: - cert_uri = self.configuration.tls_server_cert_path - LOG.debug(f'Configuring network encryption and authentication options for MQTT using {cert_uri}') - ssl_context = ssl.SSLContext() - ssl_context.load_verify_locations(cafile=cert_uri) - ssl_context.check_hostname = False - else: - ssl_context = None - await self.client.connect(host=self.host, port=self.port, version=gmqtt.constants.MQTTv311, ssl=ssl_context) - - def __on_connect(self, _client, _flags, rc, _properties) -> None: - if rc == gmqtt.constants.CONNACK_ACCEPTED: - LOG.info('Connected to MQTT broker') - mqtt_account_prefix = self.get_mqtt_account_prefix() - self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/{mqtt_topics.SET_SUFFIX}') - self.client.subscribe(f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/+/{mqtt_topics.SET_SUFFIX}') - self.client.subscribe( - f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/{mqtt_topics.SET_SUFFIX}') - self.client.subscribe( - f'{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/{mqtt_topics.SET_SUFFIX}') - for charging_station in self.configuration.charging_stations_by_vin.values(): - LOG.debug(f'Subscribing to MQTT topic {charging_station.charge_state_topic}') - self.vin_by_charge_state_topic[charging_station.charge_state_topic] = charging_station.vin - self.client.subscribe(charging_station.charge_state_topic) - if charging_station.connected_topic: - LOG.debug(f'Subscribing to MQTT topic {charging_station.connected_topic}') - self.vin_by_charger_connected_topic[charging_station.connected_topic] = charging_station.vin - self.client.subscribe(charging_station.connected_topic) - self.keepalive() - else: - if rc == gmqtt.constants.CONNACK_REFUSED_BAD_USERNAME_PASSWORD: - LOG.error(f'MQTT connection error: bad username or password. Return code {rc}') - elif rc == gmqtt.constants.CONNACK_REFUSED_PROTOCOL_VERSION: - LOG.error(f'MQTT connection error: refused protocol version. Return code {rc}') - else: - LOG.error(f'MQTT connection error.Return code {rc}') - SystemExit(f'Unable to connect to MQTT broker. Return code: {rc}') - - async def __on_message(self, _client, topic, payload, _qos, _properties) -> None: - try: - if isinstance(payload, bytes): - payload = payload.decode('utf-8') - else: - payload = str(payload) - await self.__on_message_real(topic=topic, payload=payload) - except Exception as e: - LOG.exception(f'Error while processing MQTT message: {e}') - - async def __on_message_real(self, *, topic: str, payload: str) -> None: - if topic in self.vin_by_charge_state_topic: - LOG.debug(f'Received message over topic {topic} with payload {payload}') - vin = self.vin_by_charge_state_topic[topic] - charging_station = self.configuration.charging_stations_by_vin[vin] - if self.should_force_refresh(payload, charging_station): - LOG.info(f'Vehicle with vin {vin} is charging. Setting refresh mode to force') - if self.command_listener is not None: - await self.command_listener.on_charging_detected(vin) - elif topic in self.vin_by_charger_connected_topic: - LOG.debug(f'Received message over topic {topic} with payload {payload}') - vin = self.vin_by_charger_connected_topic[topic] - charging_station = self.configuration.charging_stations_by_vin[vin] - if payload == charging_station.connected_value: - LOG.debug(f'Vehicle with vin {vin} is connected to its charging station') - else: - LOG.debug(f'Vehicle with vin {vin} is disconnected from its charging station') - else: - vin = self.get_vin_from_topic(topic) - if self.command_listener is not None: - await self.command_listener.on_mqtt_command_received(vin=vin, topic=topic, payload=payload) - return - - def __publish(self, topic: str, payload) -> None: - self.client.publish(topic, payload, retain=True) - - @override - def is_connected(self) -> bool: - return self.client and self.client.is_connected - - @override - def publish_json(self, key: str, data: dict, no_prefix: bool = False) -> None: - payload = self.dict_to_anonymized_json(data) - self.__publish(topic=self.get_topic(key, no_prefix), payload=payload) - - @override - def publish_str(self, key: str, value: str, no_prefix: bool = False) -> None: - self.__publish(topic=self.get_topic(key, no_prefix), payload=value) - - @override - def publish_int(self, key: str, value: int, no_prefix: bool = False) -> None: - self.__publish(topic=self.get_topic(key, no_prefix), payload=value) - - @override - def publish_bool(self, key: str, value: bool | int | None, no_prefix: bool = False) -> None: - if value is None: - value = False - elif isinstance(value, int): - value = value == 1 - self.__publish(topic=self.get_topic(key, no_prefix), payload=value) - - @override - def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: - self.__publish(topic=self.get_topic(key, no_prefix), payload=value) - - def get_vin_from_topic(self, topic: str) -> str: - global_topic_removed = topic[len(self.configuration.mqtt_topic) + 1:] - elements = global_topic_removed.split('/') - return elements[2] - - def should_force_refresh(self, current_charging_value: str, charging_station: ChargingStation): - vin = charging_station.vin - last_charging_value: str | None = None - if vin in self.last_charge_state_by_vin: - last_charging_value = self.last_charge_state_by_vin[vin] - self.last_charge_state_by_vin[vin] = current_charging_value - - if last_charging_value: - if last_charging_value == current_charging_value: - LOG.debug('Last charging value equals current charging value. No refresh needed.') - return False - else: - LOG.info(f'Charging value has changed from {last_charging_value} to {current_charging_value}.') - return True - else: - return True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..68ffe77 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,253 @@ +[project] +name = "saic-python-mqtt-gateway" +version = "0.7.2-rc" +description = "A service that queries the data from an MG iSMART account and publishes the data over MQTT and to other sources" +authors = [ + { name = "Giovanni Condello", email = "saic-python-client@nanomad.net" } +] +license = "MIT" +readme = "README.md" + +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +requires-python = '>=3.12,<4.0' +dependencies = [ + "saic-ismart-client-ng (>=0.8.2,<0.9.0)", + 'httpx (>=0.28.1,<0.29.0)', + 'gmqtt (>=0.7.0,<0.8.0)', + 'inflection (>=0.5.1,<0.6.0)', + 'apscheduler (>=3.11.0,<4.0.0)', +] + +[project.urls] +Homepage = "https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway" +Issues = "https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/issues" + +[tool.poetry] +package-mode = false +requires-poetry = '>=2.0,<3.0' + +[tool.poetry.group.dev.dependencies] +pytest = "^8.2.2" +mock = "^5.1.0" +coverage = "^7.5.4" +ruff = "^0.9.1" +pytest-cov = "^6.0.0" +pytest-asyncio = "^0.25.2" +pytest-mock = "^3.14.0" +mypy = "^1.15.0" +pylint = "^3.3.6" + +[tool.poetry.dependencies] +saic-ismart-client-ng = { develop = true } + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +norecursedirs = ".git build dist" +testpaths = "tests" +pythonpath = [ + "src", + "tests" +] +mock_use_standalone_module = true +addopts = [ + "--import-mode=importlib", +] +asyncio_default_fixture_loop_scope = "function" + +[tool.coverage.run] +omit = [ + "tests/*", +] +branch = true +command_line = "-m pytest" + +[tool.coverage.report] +# Regexes for lines to exclude from consideration +exclude_lines = [ + # Have to re-enable the standard pragma + 'pragma: no cover', + # Don't complain about missing debug-only code: + 'def __repr__', + 'if self\.debug', + # Don't complain if tests don't hit defensive assertion code: + 'raise AssertionError', + 'raise NotImplementedError', + # Don't complain if non-runnable code isn't run: + 'if 0:', + 'if __name__ == .__main__.:', +] +ignore_errors = true + +[tool.ruff] +include = [ + "src/**/*.py", + "tests/**/*.py", + "**/pyproject.toml" +] +[tool.ruff.lint] +select = ["ALL"] + +ignore = [ + "ANN401", # Opinioated warning on disallowing dynamically typed expressions + "D203", # Conflicts with other rules + "D213", # Conflicts with other rules + "EM101", # raw-string-in-exception + + "D105", # Missing docstring in magic method + "D107", # Missing docstring in `__init__` + "E501", # line too long + + "FBT", # flake8-boolean-trap + + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + + # Used to map JSON responses + "N815", + # Conflicts with the Ruff formatter + "COM812", + # We use Exception istead of Error + "N818", + # Remove later + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D106", # Missing docstring in public nested class + "TD", # Todos + "A", # bultins + "DTZ", # use tz need to test it first + "TRY", # tryceratops + "FIX002", # Line contains TODO, consider resolving the issue + "BLE001", # Do not catch blind exception: `Exception`, + "PLR0913", # Too many arguments in function definition + "ERA001", # Commented-out code + "PLR0912", # Logging statement uses f-string + "G004", # Logging statement uses f-string +] + +[tool.ruff.lint.flake8-pytest-style] +fixture-parentheses = false + + +[tool.ruff.lint.isort] +combine-as-imports = true +force-sort-within-sections = true +required-imports = ["from __future__ import annotations"] + + +[tool.ruff.lint.per-file-ignores] +"tests/**" = [ + "D100", # Missing docstring in public module + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "N802", # Function name {name} should be lowercase + "N816", # Variable {name} in global scope should not be mixedCase + "S101", # Use of assert detected + "SLF001", # Private member accessed: {access} + "T201", # print found +] + +[tool.ruff.lint.mccabe] +max-complexity = 13 + +[tool.ruff.lint.pylint] +max-args = 10 + +[tool.mypy] +files = ["./src", "./tests"] +python_version = 3.12 +show_error_codes = true +strict_equality = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unused_ignores = true +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true +strict = true + +[[tool.mypy.overrides]] +module = ["apscheduler.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["gmqtt.*"] +ignore_missing_imports = true +follow_untyped_imports = true + +[[tool.mypy.overrides]] +module = ["publisher.mqtt_publisher"] +disallow_untyped_calls = false + +[[tool.mypy.overrides]] +module = ["configuration.argparse_extensions"] +disable_error_code = ["arg-type"] + +[tool.pylint.MAIN] +py-version = "3.11" +ignore = ["tests"] +fail-on = ["I"] + +[tool.pylint.BASIC] +good-names = ["i", "j", "k", "ex", "_", "T", "x", "y", "id", "tg"] + +[tool.pylint."MESSAGES CONTROL"] +# Reasons disabled: +# format - handled by black +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# 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 +# --- +# Pylint CodeStyle plugin +# consider-using-namedtuple-or-dataclass - too opinionated +# consider-using-assignment-expr - decision to use := better left to devs +disable = [ + "format", + "cyclic-import", + "duplicate-code", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-locals", + "too-many-ancestors", + "too-few-public-methods", + "invalid-name", + # Remove later + "missing-function-docstring", + "missing-module-docstring", + "missing-class-docstring", + "broad-exception-caught", + "logging-fstring-interpolation", + "fixme" +] +enable = ["useless-suppression", "use-symbolic-message-instead"] + +[tool.pylint.REPORTS] +score = false + +[tool.pylint.FORMAT] +expected-line-ending-format = "LF" + +[tool.pylint.EXCEPTIONS] +overgeneral-exceptions = ["builtins.BaseException", "builtins.Exception"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 11a228a..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -saic-ismart-client-ng==0.5.4 -httpx~=0.27.0 -gmqtt~=0.6.13 -inflection~=0.5.1 -apscheduler~=3.10.1 diff --git a/configuration/__init__.py b/src/configuration/__init__.py similarity index 51% rename from configuration/__init__.py rename to src/configuration/__init__.py index 6655b4b..77a5adb 100644 --- a/configuration/__init__.py +++ b/src/configuration/__init__.py @@ -1,41 +1,47 @@ +from __future__ import annotations + from enum import Enum +from typing import TYPE_CHECKING -from integrations.openwb.charging_station import ChargingStation +if TYPE_CHECKING: + from integrations.openwb.charging_station import ChargingStation class TransportProtocol(Enum): - def __init__(self, transport_mechanism: str, with_tls: bool): + def __init__(self, transport_mechanism: str, with_tls: bool) -> None: self.transport_mechanism = transport_mechanism self.with_tls = with_tls - TCP = 'tcp', False - WS = 'websockets', False - TLS = 'tcp', True + TCP = "tcp", False + WS = "websockets", False + TLS = "tcp", True class Configuration: - def __init__(self): + def __init__(self) -> None: self.saic_user: str | None = None self.saic_password: str | None = None - self.saic_phone_country_code: str | None = None - self.saic_rest_uri: str = 'https://gateway-mg-eu.soimt.com/api.app/v1/' - self.saic_region: str = 'eu' - self.saic_tenant_id: str = '459771' + self.__saic_phone_country_code: str | None = None + self.saic_rest_uri: str = "https://gateway-mg-eu.soimt.com/api.app/v1/" + self.saic_region: str = "eu" + self.saic_tenant_id: str = "459771" self.saic_relogin_delay: int = 15 * 60 # in seconds + self.saic_read_timeout: float = 10.0 # in seconds self.battery_capacity_map: dict[str, float] = {} self.mqtt_host: str | None = None - self.mqtt_port: int | None = None - self.mqtt_transport_protocol: TransportProtocol | None = None + self.mqtt_port: int = 1883 + self.mqtt_transport_protocol: TransportProtocol = TransportProtocol.TCP self.tls_server_cert_path: str | None = None self.mqtt_user: str | None = None self.mqtt_password: str | None = None - self.mqtt_client_id: str = 'saic-python-mqtt-gateway' - self.mqtt_topic: str = 'saic' + self.mqtt_client_id: str = "saic-python-mqtt-gateway" + self.mqtt_topic: str = "saic" + self.mqtt_allow_dots_in_topic: bool = True self.charging_stations_by_vin: dict[str, ChargingStation] = {} self.anonymized_publishing: bool = False self.messages_request_interval: int = 60 # in seconds self.ha_discovery_enabled: bool = True - self.ha_discovery_prefix: str = 'homeassistant' + self.ha_discovery_prefix: str = "homeassistant" self.ha_show_unavailable: bool = True self.charge_dynamic_polling_min_percentage: float = 1.0 self.publish_raw_api_data: bool = False @@ -55,5 +61,17 @@ def is_mqtt_enabled(self) -> bool: return self.mqtt_host is not None and len(str(self.mqtt_host)) > 0 @property - def username_is_email(self): - return '@' in self.saic_user + def username_is_email(self) -> bool: + return self.saic_user is not None and "@" in self.saic_user + + @property + def ha_lwt_topic(self) -> str: + return f"{self.ha_discovery_prefix}/status" + + @property + def saic_phone_country_code(self) -> str | None: + return None if self.username_is_email else self.__saic_phone_country_code + + @saic_phone_country_code.setter + def saic_phone_country_code(self, country_code: str | None) -> None: + self.__saic_phone_country_code = country_code diff --git a/src/configuration/argparse_extensions.py b/src/configuration/argparse_extensions.py new file mode 100644 index 0000000..eba9d94 --- /dev/null +++ b/src/configuration/argparse_extensions.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import argparse +from argparse import ArgumentParser, Namespace +import os +from typing import TYPE_CHECKING, Any, override + +if TYPE_CHECKING: + from collections.abc import Callable, Sequence + + +class EnvDefault(argparse.Action): + def __init__( + self, + envvar: str, + required: bool = True, + default: str | None = None, + **kwargs: dict[str, Any], + ) -> None: + if os.environ.get(envvar): + default = os.environ[envvar] + if required and default: + required = False + super().__init__(default=default, required=required, **kwargs) + + @override + def __call__( + self, + parser: ArgumentParser, + namespace: Namespace, + values: str | Sequence[str] | None, + option_string: str | None = None, + ) -> None: + setattr(namespace, self.dest, values) + + +def cfg_value_to_dict( + cfg_value: str, result_map: dict[str, Any], value_type: Callable[[str], Any] = str +) -> None: + map_entries = cfg_value.split(",") if "," in cfg_value else [cfg_value] + + for entry in map_entries: + if "=" in entry: + key_value_pair = entry.split("=") + key = key_value_pair[0] + value = key_value_pair[1] + result_map[key] = value_type(value) + + +def check_positive(value: str) -> int: + ivalue = int(value) + if ivalue <= 0: + msg = f"{ivalue} is an invalid positive int value" + raise argparse.ArgumentTypeError(msg) + return ivalue + + +def check_positive_float(value: str) -> float: + fvalue = float(value) + if fvalue <= 0: + msg = f"{fvalue} is an invalid positive float value" + raise argparse.ArgumentTypeError(msg) + return fvalue + + +def check_bool(value: str) -> bool: + return str(value).lower() in ["true", "1", "yes", "y"] diff --git a/src/configuration/parser.py b/src/configuration/parser.py new file mode 100644 index 0000000..9ab9dc6 --- /dev/null +++ b/src/configuration/parser.py @@ -0,0 +1,451 @@ +from __future__ import annotations + +import argparse +import json +import logging +from pathlib import Path +import urllib.parse + +from configuration import Configuration, TransportProtocol +from configuration.argparse_extensions import ( + EnvDefault, + cfg_value_to_dict, + check_bool, + check_positive, + check_positive_float, +) +from exceptions import MqttGatewayException +from integrations.openwb.charging_station import ChargingStation + +LOG = logging.getLogger(__name__) +CHARGING_STATIONS_FILE = "charging-stations.json" + + +def __process_charging_stations_file(config: Configuration, json_file: str) -> None: + try: + with Path(json_file).open(encoding="utf-8") as f: + data = json.load(f) + + for item in data: + charge_state_topic = item["chargeStateTopic"] + charging_value = item["chargingValue"] + vin = item["vin"] + if "socTopic" in item: + charging_station = ChargingStation( + vin, charge_state_topic, charging_value, item["socTopic"] + ) + else: + charging_station = ChargingStation( + vin, charge_state_topic, charging_value + ) + if "rangeTopic" in item: + charging_station.range_topic = item["rangeTopic"] + if "chargerConnectedTopic" in item: + charging_station.connected_topic = item["chargerConnectedTopic"] + if "chargerConnectedValue" in item: + charging_station.connected_value = item["chargerConnectedValue"] + config.charging_stations_by_vin[vin] = charging_station + except FileNotFoundError: + LOG.warning(f"File {json_file} does not exist") + except json.JSONDecodeError as e: + msg = f"Reading {json_file} failed" + raise MqttGatewayException(msg) from e + + +def process_arguments() -> Configuration: + config = Configuration() + parser = argparse.ArgumentParser(prog="MQTT Gateway") + try: + parser.add_argument( + "-m", + "--mqtt-uri", + help="The URI to the MQTT Server. Environment Variable: MQTT_URI," + "TCP: tcp://mqtt.eclipseprojects.io:1883 " + "WebSocket: ws://mqtt.eclipseprojects.io:9001" + "TLS: tls://mqtt.eclipseprojects.io:8883", + dest="mqtt_uri", + required=False, + action=EnvDefault, + envvar="MQTT_URI", + ) + parser.add_argument( + "--mqtt-server-cert", + help="Path to the server certificate authority file in PEM format for TLS.", + dest="tls_server_cert_path", + required=False, + action=EnvDefault, + envvar="MQTT_SERVER_CERT", + ) + parser.add_argument( + "--mqtt-user", + help="The MQTT user name. Environment Variable: MQTT_USER", + dest="mqtt_user", + required=False, + action=EnvDefault, + envvar="MQTT_USER", + ) + parser.add_argument( + "--mqtt-password", + help="The MQTT password. Environment Variable: MQTT_PASSWORD", + dest="mqtt_password", + required=False, + action=EnvDefault, + envvar="MQTT_PASSWORD", + ) + parser.add_argument( + "--mqtt-client-id", + help="The MQTT Client Identifier. Environment Variable: " + "MQTT_CLIENT_ID " + "Default is saic-python-mqtt-gateway", + default="saic-python-mqtt-gateway", + dest="mqtt_client_id", + required=False, + action=EnvDefault, + envvar="MQTT_CLIENT_ID", + ) + parser.add_argument( + "--mqtt-topic-prefix", + help="MQTT topic prefix. Environment Variable: MQTT_TOPIC Default is saic", + default="saic", + dest="mqtt_topic", + required=False, + action=EnvDefault, + envvar="MQTT_TOPIC", + ) + parser.add_argument( + "--mqtt-allow-dots-in-topic", + help="Allow dots in MQTT topics. Environment Variable: MQTT_ALLOW_DOTS_IN_TOPIC Default is True", + dest="mqtt_allow_dots_in_topic", + required=False, + action=EnvDefault, + default=True, + type=check_bool, + envvar="MQTT_ALLOW_DOTS_IN_TOPIC", + ) + parser.add_argument( + "-s", + "--saic-rest-uri", + help="The SAIC uri. Environment Variable: SAIC_REST_URI Default is the European " + "Production Endpoint: https://tap-eu.soimt.com", + default="https://gateway-mg-eu.soimt.com/api.app/v1/", + dest="saic_rest_uri", + required=False, + action=EnvDefault, + envvar="SAIC_REST_URI", + ) + parser.add_argument( + "-u", + "--saic-user", + help="The SAIC user name. Environment Variable: SAIC_USER", + dest="saic_user", + required=True, + action=EnvDefault, + envvar="SAIC_USER", + ) + parser.add_argument( + "-p", + "--saic-password", + help="The SAIC password. Environment Variable: SAIC_PASSWORD", + dest="saic_password", + required=True, + action=EnvDefault, + envvar="SAIC_PASSWORD", + ) + parser.add_argument( + "--saic-phone-country-code", + help="The SAIC phone country code. Environment Variable: SAIC_PHONE_COUNTRY_CODE", + dest="saic_phone_country_code", + required=False, + action=EnvDefault, + envvar="SAIC_PHONE_COUNTRY_CODE", + ) + parser.add_argument( + "--saic-region", + "--saic-region", + help="The SAIC API region. Environment Variable: SAIC_REGION", + default="eu", + dest="saic_region", + required=False, + action=EnvDefault, + envvar="SAIC_REGION", + ) + parser.add_argument( + "--saic-tenant-id", + help="The SAIC API tenant id. Environment Variable: SAIC_TENANT_ID", + default="459771", + dest="saic_tenant_id", + required=False, + action=EnvDefault, + envvar="SAIC_TENANT_ID", + ) + parser.add_argument( + "--battery-capacity-mapping", + help="The mapping of VIN to full batteryc" + " apacity. Multiple mappings can be provided separated" + " by , Example: LSJXXXX=54.0,LSJYYYY=64.0," + " Environment Variable: BATTERY_CAPACITY_MAPPING", + dest="battery_capacity_mapping", + required=False, + action=EnvDefault, + envvar="BATTERY_CAPACITY_MAPPING", + ) + parser.add_argument( + "--charging-stations-json", + help="Custom charging stations configuration file name", + dest="charging_stations_file", + required=False, + action=EnvDefault, + envvar="CHARGING_STATIONS_JSON", + ) + parser.add_argument( + "--saic-relogin-delay", + help="How long to wait before attempting another login to the SAIC API. Environment " + "Variable: SAIC_RELOGIN_DELAY", + dest="saic_relogin_delay", + required=False, + action=EnvDefault, + envvar="SAIC_RELOGIN_DELAY", + type=check_positive, + ) + parser.add_argument( + "--saic-read-timeout", + help="HTTP Read timeout for the SAIC API. Environment " + "Variable: SAIC_READ_TIMEOUT", + dest="saic_read_timeout", + required=False, + action=EnvDefault, + envvar="SAIC_READ_TIMEOUT", + type=check_positive_float, + ) + parser.add_argument( + "--ha-discovery", + help="Enable Home Assistant Discovery. Environment Variable: HA_DISCOVERY_ENABLED", + dest="ha_discovery_enabled", + required=False, + action=EnvDefault, + envvar="HA_DISCOVERY_ENABLED", + default=True, + type=check_bool, + ) + parser.add_argument( + "--ha-discovery-prefix", + help="Home Assistant Discovery Prefix. Environment Variable: HA_DISCOVERY_PREFIX", + dest="ha_discovery_prefix", + required=False, + action=EnvDefault, + envvar="HA_DISCOVERY_PREFIX", + default="homeassistant", + ) + parser.add_argument( + "--ha-show-unavailable", + help="Show entities as Unavailable in Home Assistant when car polling fails. " + "Environment Variable: HA_SHOW_UNAVAILABLE", + dest="ha_show_unavailable", + required=False, + action=EnvDefault, + envvar="HA_SHOW_UNAVAILABLE", + default=True, + type=check_bool, + ) + parser.add_argument( + "--messages-request-interval", + help="The interval for retrieving messages in seconds. Environment Variable: " + "MESSAGES_REQUEST_INTERVAL", + dest="messages_request_interval", + required=False, + action=EnvDefault, + envvar="MESSAGES_REQUEST_INTERVAL", + default=60, + ) + parser.add_argument( + "--charge-min-percentage", + help="How many % points we should try to refresh the charge state. Environment Variable: " + "CHARGE_MIN_PERCENTAGE", + dest="charge_dynamic_polling_min_percentage", + required=False, + action=EnvDefault, + envvar="CHARGE_MIN_PERCENTAGE", + default="1.0", + type=check_positive_float, + ) + parser.add_argument( + "--publish-raw-api-data", + help="Publish raw SAIC API request/response to MQTT. Environment Variable: " + "PUBLISH_RAW_API_DATA_ENABLED", + dest="publish_raw_api_data", + required=False, + action=EnvDefault, + envvar="PUBLISH_RAW_API_DATA_ENABLED", + default=False, + type=check_bool, + ) + + # ABRP Integration + parser.add_argument( + "--abrp-api-key", + help="The API key for the A Better Route Planer telemetry API." + " Default is the open source telemetry" + " API key 8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d." + " Environment Variable: ABRP_API_KEY", + default="8cfc314b-03cd-4efe-ab7d-4431cd8f2e2d", + dest="abrp_api_key", + required=False, + action=EnvDefault, + envvar="ABRP_API_KEY", + ) + parser.add_argument( + "--abrp-user-token", + help="The mapping of VIN to ABRP User Token." + " Multiple mappings can be provided seperated by ," + " Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl," + " Environment Variable: ABRP_USER_TOKEN", + dest="abrp_user_token", + required=False, + action=EnvDefault, + envvar="ABRP_USER_TOKEN", + ) + parser.add_argument( + "--publish-raw-abrp-data", + help="Publish raw ABRP API request/response to MQTT. Environment Variable: " + "PUBLISH_RAW_ABRP_DATA_ENABLED", + dest="publish_raw_abrp_data", + required=False, + action=EnvDefault, + envvar="PUBLISH_RAW_ABRP_DATA_ENABLED", + default=False, + type=check_bool, + ) + # OsmAnd Integration + parser.add_argument( + "--osmand-server-uri", + help="The URL of your OsmAnd Server." + " Default unset" + " Environment Variable: OSMAND_SERVER_URI", + default=None, + dest="osmand_server_uri", + required=False, + action=EnvDefault, + envvar="OSMAND_SERVER_URI", + ) + parser.add_argument( + "--osmand-device-id", + help="The mapping of VIN to OsmAnd Device ID." + " Multiple mappings can be provided seperated by ," + " Example: LSJXXXX=12345-abcdef,LSJYYYY=67890-ghijkl," + " Default is to use the car VIN as Device ID, " + " Environment Variable: OSMAND_DEVICE_ID", + dest="osmand_device_id", + required=False, + action=EnvDefault, + envvar="OSMAND_DEVICE_ID", + ) + parser.add_argument( + "--publish-raw-osmand-data", + help="Publish raw ABRP OsmAnd request/response to MQTT. Environment Variable: " + "PUBLISH_RAW_OSMAND_DATA_ENABLED", + dest="publish_raw_osmand_data", + required=False, + action=EnvDefault, + envvar="PUBLISH_RAW_OSMAND_DATA_ENABLED", + default=False, + type=check_bool, + ) + + args = parser.parse_args() + config.mqtt_user = args.mqtt_user + config.mqtt_password = args.mqtt_password + config.mqtt_client_id = args.mqtt_client_id + config.charge_dynamic_polling_min_percentage = ( + args.charge_dynamic_polling_min_percentage + ) + + if args.saic_relogin_delay: + config.saic_relogin_delay = args.saic_relogin_delay + + if args.saic_read_timeout: + config.saic_read_timeout = args.saic_read_timeout + + config.mqtt_topic = args.mqtt_topic + config.mqtt_allow_dots_in_topic = args.mqtt_allow_dots_in_topic + config.saic_rest_uri = args.saic_rest_uri + config.saic_region = args.saic_region + config.saic_tenant_id = str(args.saic_tenant_id) + config.saic_user = args.saic_user + config.saic_password = args.saic_password + config.saic_phone_country_code = args.saic_phone_country_code + if args.battery_capacity_mapping: + cfg_value_to_dict( + args.battery_capacity_mapping, + config.battery_capacity_map, + value_type=check_positive_float, + ) + if args.charging_stations_file: + __process_charging_stations_file(config, args.charging_stations_file) + else: + __process_charging_stations_file(config, f"./{CHARGING_STATIONS_FILE}") + + config.saic_password = args.saic_password + + if args.ha_discovery_enabled is not None: + config.ha_discovery_enabled = args.ha_discovery_enabled + + if args.publish_raw_api_data is not None: + config.publish_raw_api_data = args.publish_raw_api_data + + if args.ha_show_unavailable is not None: + config.ha_show_unavailable = args.ha_show_unavailable + + if args.ha_discovery_prefix: + config.ha_discovery_prefix = args.ha_discovery_prefix + + try: + config.messages_request_interval = int(args.messages_request_interval) + except ValueError as ve: + msg = f"No valid integer value for messages_request_interval: {args.messages_request_interval}" + raise SystemExit(msg) from ve + + if args.mqtt_uri is not None and len(args.mqtt_uri) > 0: + parse_result = urllib.parse.urlparse(args.mqtt_uri) + if parse_result.scheme == "tcp": + config.mqtt_transport_protocol = TransportProtocol.TCP + elif parse_result.scheme == "ws": + config.mqtt_transport_protocol = TransportProtocol.WS + elif parse_result.scheme == "tls": + config.mqtt_transport_protocol = TransportProtocol.TLS + if args.tls_server_cert_path: + config.tls_server_cert_path = args.tls_server_cert_path + else: + msg = f"No server certificate authority file provided for TLS MQTT URI {args.mqtt_uri}" + raise SystemExit(msg) + else: + msg = f"Invalid MQTT URI scheme: {parse_result.scheme}, use tcp or ws" + raise SystemExit(msg) + + if not parse_result.port: + if config.mqtt_transport_protocol == TransportProtocol.TCP: + config.mqtt_port = 1883 + else: + config.mqtt_port = 9001 + else: + config.mqtt_port = parse_result.port + + config.mqtt_host = str(parse_result.hostname) + + # ABRP Integration + config.abrp_api_key = args.abrp_api_key + if args.abrp_user_token: + cfg_value_to_dict(args.abrp_user_token, config.abrp_token_map) + if args.publish_raw_abrp_data is not None: + config.publish_raw_abrp_data = args.publish_raw_abrp_data + + # OsmAnd Integration + config.osmand_server_uri = args.osmand_server_uri + if args.osmand_device_id: + cfg_value_to_dict(args.osmand_device_id, config.osmand_device_id_map) + if args.publish_raw_osmand_data is not None: + config.publish_raw_osmand_data = args.publish_raw_osmand_data + + return config + except argparse.ArgumentError as err: + parser.print_help() + raise SystemExit(err) from err diff --git a/src/exceptions.py b/src/exceptions.py new file mode 100644 index 0000000..eaaf8ab --- /dev/null +++ b/src/exceptions.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +class MqttGatewayException(Exception): + def __init__(self, msg: str) -> None: + self.message = msg + + def __str__(self) -> str: + return self.message diff --git a/handlers/__init__.py b/src/handlers/__init__.py similarity index 100% rename from handlers/__init__.py rename to src/handlers/__init__.py diff --git a/src/handlers/message.py b/src/handlers/message.py new file mode 100644 index 0000000..9cb8a1d --- /dev/null +++ b/src/handlers/message.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import datetime +import logging +from typing import TYPE_CHECKING + +from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException + +from vehicle import RefreshMode + +if TYPE_CHECKING: + from saic_ismart_client_ng import SaicApi + from saic_ismart_client_ng.api.message.schema import MessageEntity + + from handlers.relogin import ReloginHandler + from handlers.vehicle import VehicleHandlerLocator + +LOG = logging.getLogger(__name__) + + +class MessageHandler: + def __init__( + self, + gateway: VehicleHandlerLocator, + relogin_handler: ReloginHandler, + saicapi: SaicApi, + ) -> None: + self.gateway = gateway + self.saicapi = saicapi + self.relogin_handler = relogin_handler + self.last_message_ts = datetime.datetime.min + self.last_message_id: str | int | None = None + + async def check_for_new_messages(self) -> None: + if self.__should_poll(): + try: + LOG.debug("Checking for new messages") + await self.__polling() + except Exception as e: + LOG.exception("MessageHandler poll loop failed", exc_info=e) + + async def __polling(self) -> None: + try: + all_messages = await self.__get_all_alarm_messages() + LOG.info(f"{len(all_messages)} messages received") + + new_messages = [m for m in all_messages if m.read_status != "read"] + for message in new_messages: + LOG.info(message.details) + await self.__read_message(message) + + latest_message = self.__get_latest_message(all_messages) + if ( + latest_message is not None + and latest_message.messageId != self.last_message_id + and latest_message.message_time > self.last_message_ts + ): + self.last_message_id = latest_message.messageId + self.last_message_ts = latest_message.message_time + LOG.info( + f"{latest_message.title} detected at {latest_message.message_time}" + ) + if (vin := latest_message.vin) and ( + vehicle_handler := self.gateway.get_vehicle_handler(vin) + ): + vehicle_handler.vehicle_state.notify_message(latest_message) + + # Delete vehicle start messages unless they are the latest + vehicle_start_messages = [ + m + for m in all_messages + if m.messageType == "323" and m.messageId != self.last_message_id + ] + for vehicle_start_message in vehicle_start_messages: + await self.__delete_message(vehicle_start_message) + except SaicLogoutException as e: + LOG.error("API Client was logged out, waiting for a new login", exc_info=e) + self.relogin_handler.relogin() + except SaicApiException as e: + LOG.exception( + "MessageHandler poll loop failed during SAIC API Call", exc_info=e + ) + except Exception as e: + LOG.exception("MessageHandler poll loop failed unexpectedly", exc_info=e) + + async def __get_all_alarm_messages(self) -> list[MessageEntity]: + idx = 1 + all_messages = [] + while True: + try: + message_list = await self.saicapi.get_alarm_list( + page_num=idx, page_size=1 + ) + if ( + message_list is not None + and message_list.messages + and len(message_list.messages) > 0 + ): + all_messages.extend(message_list.messages) + else: + return all_messages + oldest_message = self.__get_oldest_message(all_messages) + if ( + oldest_message is not None + and oldest_message.message_time < self.last_message_ts + ): + return all_messages + except SaicLogoutException as e: + raise e + except Exception as e: + LOG.exception( + "Error while fetching a message from the SAIC API, please open the app and clear them, " + "then report this as a bug.", + exc_info=e, + ) + finally: + idx = idx + 1 + + async def __delete_message(self, message: MessageEntity) -> None: + try: + message_id = message.messageId + if message_id is not None: + await self.saicapi.delete_message(message_id=message_id) + LOG.info(f"{message.title} message with ID {message_id} deleted") + else: + LOG.warning("Could not delete message '%s' as it has no ID", message) + except Exception as e: + LOG.exception("Could not delete message from server", exc_info=e) + + async def __read_message(self, message: MessageEntity) -> None: + try: + message_id = message.messageId + if message_id is not None: + await self.saicapi.read_message(message_id=message_id) + LOG.info(f"{message.title} message with ID {message_id} marked as read") + else: + LOG.warning( + "Could not mark message '%s' as read as it has not ID", message + ) + except Exception as e: + LOG.exception("Could not mark message as read from server", exc_info=e) + + def __should_poll(self) -> bool: + vehicle_handlers = self.gateway.vehicle_handlers or {} + refresh_modes = [ + vh.vehicle_state.refresh_mode + for vh in vehicle_handlers.values() + if vh.vehicle_state is not None + ] + # We do not poll if we have no cars or all cars have RefreshMode.OFF + if len(refresh_modes) == 0 or all( + mode == RefreshMode.OFF for mode in refresh_modes + ): + logging.debug( + "Not checking for new messages as all cars have RefreshMode.OFF" + ) + return False + if self.relogin_handler.relogin_in_progress: + logging.warning( + "Not checking for new messages as we are waiting to log back in" + ) + return False + return True + + @staticmethod + def __get_latest_message( + vehicle_start_messages: list[MessageEntity], + ) -> MessageEntity | None: + if len(vehicle_start_messages) == 0: + return None + return max(vehicle_start_messages, key=lambda m: m.message_time) + + @staticmethod + def __get_oldest_message( + vehicle_start_messages: list[MessageEntity], + ) -> MessageEntity | None: + if len(vehicle_start_messages) == 0: + return None + return min(vehicle_start_messages, key=lambda m: m.message_time) diff --git a/handlers/relogin.py b/src/handlers/relogin.py similarity index 54% rename from handlers/relogin.py rename to src/handlers/relogin.py index 024ac31..fdd2c4b 100644 --- a/handlers/relogin.py +++ b/src/handlers/relogin.py @@ -1,22 +1,21 @@ +from __future__ import annotations + +from datetime import datetime, timedelta import logging -from datetime import timedelta, datetime +from typing import TYPE_CHECKING -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from saic_ismart_client_ng import SaicApi +if TYPE_CHECKING: + from apscheduler.schedulers.asyncio import AsyncIOScheduler + from saic_ismart_client_ng import SaicApi LOG = logging.getLogger(__name__) -JOB_ID = 'relogin_task' - +JOB_ID = "relogin_task" -class ReloginHandler(): +class ReloginHandler: def __init__( - self, - *, - relogin_relay: int, - api: SaicApi, - scheduler: AsyncIOScheduler - ): + self, *, relogin_relay: int, api: SaicApi, scheduler: AsyncIOScheduler + ) -> None: self.__relogin_relay = relogin_relay self.__scheduler = scheduler self.__api = api @@ -26,25 +25,29 @@ def __init__( def relogin_in_progress(self) -> bool: return self.__login_task is not None - def relogin(self): + def relogin(self) -> None: if self.__login_task is None: - logging.warning(f"API Client got logged out, logging back in {self.__relogin_relay} seconds") + logging.warning( + f"API Client got logged out, logging back in {self.__relogin_relay} seconds" + ) self.__login_task = self.__scheduler.add_job( func=self.login, - trigger='date', + trigger="date", run_date=datetime.now() + timedelta(seconds=self.__relogin_relay), id=JOB_ID, - name='Re-login the API client after a set delay', - max_instances=1 + name="Re-login the API client after a set delay", + max_instances=1, ) - async def login(self): + async def login(self) -> None: try: LOG.info("Logging in to SAIC API") login_response_message = await self.__api.login() LOG.info("Logged in as %s", login_response_message.account) except Exception as e: - logging.exception("Could not login to the SAIC API due to an error", exc_info=e) + logging.exception( + "Could not login to the SAIC API due to an error", exc_info=e + ) raise e finally: if self.__scheduler.get_job(JOB_ID) is not None: diff --git a/src/handlers/vehicle.py b/src/handlers/vehicle.py new file mode 100644 index 0000000..487ff71 --- /dev/null +++ b/src/handlers/vehicle.py @@ -0,0 +1,655 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +import asyncio +import datetime +import json +import logging +from typing import TYPE_CHECKING + +from saic_ismart_client_ng.api.vehicle_charging import ( + ChargeCurrentLimitCode, + ChrgMgmtDataResp, + ScheduledBatteryHeatingResp, + ScheduledChargingMode, + TargetBatteryCode, +) +from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException + +from exceptions import MqttGatewayException +from integrations import IntegrationException +from integrations.abrp.api import AbrpApi +from integrations.home_assistant.discovery import HomeAssistantDiscovery +from integrations.osmand.api import OsmAndApi +import mqtt_topics +from mqtt_topics import RESULT_SUFFIX, SET_SUFFIX +from saic_api_listener import MqttGatewayAbrpListener, MqttGatewayOsmAndListener +from status_publisher.vehicle_info import VehicleInfoPublisher +from vehicle import RefreshMode, VehicleState + +if TYPE_CHECKING: + from saic_ismart_client_ng import SaicApi + from saic_ismart_client_ng.api.vehicle.schema import VehicleStatusResp + + from configuration import Configuration + from handlers.relogin import ReloginHandler + from publisher.core import Publisher + from status_publisher.charge.chrg_mgmt_data_resp import ( + ChrgMgmtDataRespProcessingResult, + ) + from status_publisher.vehicle.vehicle_status_resp import ( + VehicleStatusRespProcessingResult, + ) + from vehicle_info import VehicleInfo + +LOG = logging.getLogger(__name__) + + +class VehicleHandler: + def __init__( + self, + config: Configuration, + relogin_handler: ReloginHandler, + saicapi: SaicApi, + publisher: Publisher, + vin_info: VehicleInfo, + vehicle_state: VehicleState, + ) -> None: + self.configuration = config + self.relogin_handler = relogin_handler + self.saic_api = saicapi + self.publisher = publisher + self.vin_info = vin_info + self.vehicle_prefix = self.publisher.get_topic( + f"{self.configuration.saic_user}/vehicles/{self.vin_info.vin}", True + ) + self.vehicle_state = vehicle_state + self.__ha_discovery = self.__setup_ha_discovery(vehicle_state, vin_info, config) + + self.__setup_abrp(config, vin_info) + self.__setup_osmand(config, vin_info) + self.__vehicle_info_publisher = VehicleInfoPublisher( + self.vin_info, self.publisher, self.vehicle_prefix + ) + + def __setup_abrp(self, config: Configuration, vin_info: VehicleInfo) -> None: + if vin_info.vin in self.configuration.abrp_token_map: + abrp_user_token = self.configuration.abrp_token_map[vin_info.vin] + else: + abrp_user_token = None + if config.publish_raw_abrp_data: + abrp_api_listener = MqttGatewayAbrpListener(self.publisher) + else: + abrp_api_listener = None + self.abrp_api = AbrpApi( + self.configuration.abrp_api_key, abrp_user_token, listener=abrp_api_listener + ) + + def __setup_osmand(self, config: Configuration, vin_info: VehicleInfo) -> None: + if not self.configuration.osmand_server_uri: + self.osmand_api = None + return + + if config.publish_raw_osmand_data: + api_listener = MqttGatewayOsmAndListener(self.publisher) + else: + api_listener = None + osmand_device_id = self.configuration.osmand_device_id_map.get( + vin_info.vin, vin_info.vin + ) + self.osmand_api = OsmAndApi( + server_uri=self.configuration.osmand_server_uri, + device_id=osmand_device_id, + listener=api_listener, + ) + + async def handle_vehicle(self) -> None: + start_time = datetime.datetime.now() + self.__vehicle_info_publisher.publish() + self.vehicle_state.notify_car_activity() + + while True: + if self.__should_complete_configuration(start_time): + self.vehicle_state.configure_missing() + + if self.__should_poll(): + try: + LOG.debug("Polling vehicle status") + await self.__polling() + except SaicLogoutException as e: + self.vehicle_state.mark_failed_refresh() + LOG.error( + "API Client was logged out, waiting for a new login", exc_info=e + ) + self.relogin_handler.relogin() + except SaicApiException as e: + self.vehicle_state.mark_failed_refresh() + LOG.exception( + "handle_vehicle loop failed during SAIC API call", exc_info=e + ) + except IntegrationException as ae: + LOG.exception( + "handle_vehicle loop failed during integration processing", + exc_info=ae, + ) + except Exception as e: + self.vehicle_state.mark_failed_refresh() + LOG.exception( + "handle_vehicle loop failed with an unexpected exception", + exc_info=e, + ) + finally: + self.publish_ha_discovery_messages(force=False) + else: + # car not active, wait a second + await asyncio.sleep(1.0) + + async def __polling(self) -> None: + ( + vehicle_status, + vehicle_status_processing_result, + ) = await self.update_vehicle_status() + + if self.vin_info.is_ev: + try: + ( + charge_status, + charge_status_processing_result, + ) = await self.update_charge_status() + except Exception as e: + LOG.exception("Error updating charge status", exc_info=e) + charge_status = None + charge_status_processing_result = None + + try: + await self.update_scheduled_battery_heating_status() + except Exception as e: + LOG.exception( + "Error updating scheduled battery heating status", exc_info=e + ) + else: + LOG.debug("Skipping EV-related updates as the vehicle is not an EV") + charge_status = None + + self.vehicle_state.update_data_conflicting_in_vehicle_and_bms( + vehicle_status_processing_result, charge_status_processing_result + ) + + self.vehicle_state.mark_successful_refresh() + LOG.info("Refreshing vehicle status succeeded...") + + await self.__refresh_abrp(charge_status, vehicle_status) + await self.__refresh_osmand(charge_status, vehicle_status) + + def __should_poll(self) -> bool: + return ( + not self.relogin_handler.relogin_in_progress + and self.vehicle_state.is_complete() + and self.vehicle_state.should_refresh() + ) + + def __should_complete_configuration(self, start_time: datetime.datetime) -> bool: + return ( + not self.vehicle_state.is_complete() + and datetime.datetime.now() > start_time + datetime.timedelta(seconds=10) + ) + + async def __refresh_osmand( + self, + charge_status: ChrgMgmtDataResp | None, + vehicle_status: VehicleStatusResp | None, + ) -> None: + if not self.osmand_api: + return + refreshed, response = await self.osmand_api.update_osmand( + vehicle_status, charge_status + ) + self.publisher.publish_str( + f"{self.vehicle_prefix}/{mqtt_topics.INTERNAL_OSMAND}", response + ) + if refreshed: + LOG.info("Refreshing OsmAnd status succeeded...") + else: + LOG.info(f"OsmAnd not refreshed, reason {response}") + + async def __refresh_abrp( + self, + charge_status: ChrgMgmtDataResp | None, + vehicle_status: VehicleStatusResp | None, + ) -> None: + abrp_refreshed, abrp_response = await self.abrp_api.update_abrp( + vehicle_status, charge_status + ) + self.publisher.publish_str( + f"{self.vehicle_prefix}/{mqtt_topics.INTERNAL_ABRP}", abrp_response + ) + if abrp_refreshed: + LOG.info("Refreshing ABRP status succeeded...") + else: + LOG.info(f"ABRP not refreshed, reason {abrp_response}") + + def publish_ha_discovery_messages(self, *, force: bool = False) -> None: + if self.__ha_discovery is not None: + LOG.info( + f"Sending HA discovery messages for {self.vin_info.vin} (Force: {force})" + ) + self.__ha_discovery.publish_ha_discovery_messages(force=force) + + async def update_vehicle_status( + self, + ) -> tuple[VehicleStatusResp, VehicleStatusRespProcessingResult]: + LOG.info("Updating vehicle status") + vehicle_status_response = await self.saic_api.get_vehicle_status( + self.vin_info.vin + ) + result = self.vehicle_state.handle_vehicle_status(vehicle_status_response) + return (vehicle_status_response, result) + + async def update_charge_status( + self, + ) -> tuple[ChrgMgmtDataResp, ChrgMgmtDataRespProcessingResult]: + LOG.info("Updating charging status") + charge_mgmt_data = await self.saic_api.get_vehicle_charging_management_data( + self.vin_info.vin + ) + result = self.vehicle_state.handle_charge_status(charge_mgmt_data) + return charge_mgmt_data, result + + async def update_scheduled_battery_heating_status( + self, + ) -> ScheduledBatteryHeatingResp: + LOG.info("Updating scheduled battery heating status") + scheduled_battery_heating_status = ( + await self.saic_api.get_vehicle_battery_heating_schedule(self.vin_info.vin) + ) + self.vehicle_state.handle_scheduled_battery_heating_status( + scheduled_battery_heating_status + ) + return scheduled_battery_heating_status + + async def handle_mqtt_command(self, *, topic: str, payload: str) -> None: + topic, result_topic = self.__get_command_topics(topic) + try: + should_force_refresh = True + match topic: + case mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE_SET: + match payload.strip().lower(): + case "true": + LOG.info("HV battery is now active") + self.vehicle_state.hv_battery_active = True + case "false": + LOG.info("HV battery is now inactive") + self.vehicle_state.hv_battery_active = False + case _: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) + case mqtt_topics.DRIVETRAIN_CHARGING_SET: + match payload.strip().lower(): + case "true": + LOG.info("Charging will be started") + await self.saic_api.control_charging( + self.vin_info.vin, stop_charging=False + ) + case "false": + LOG.info("Charging will be stopped") + await self.saic_api.control_charging( + self.vin_info.vin, stop_charging=True + ) + case _: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) + case mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SET: + match payload.strip().lower(): + case "true": + LOG.info("Battery heater wil be will be switched on") + response = await self.saic_api.control_battery_heating( + self.vin_info.vin, enable=True + ) + case "false": + LOG.info("Battery heater wil be will be switched off") + response = await self.saic_api.control_battery_heating( + self.vin_info.vin, enable=False + ) + case _: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) + if response is not None and response.ptcHeatResp is not None: + decoded = response.heating_stop_reason + self.publisher.publish_str( + self.vehicle_state.get_topic( + mqtt_topics.DRIVETRAIN_BATTERY_HEATING_STOP_REASON + ), + f"UNKNOWN ({response.ptcHeatResp})" + if decoded is None + else decoded.name, + ) + + case mqtt_topics.CLIMATE_REMOTE_TEMPERATURE_SET: + payload = payload.strip() + try: + LOG.info( + "Setting remote climate target temperature to %s", payload + ) + temp = int(payload) + changed = self.vehicle_state.set_ac_temperature(temp) + if changed and self.vehicle_state.is_remote_ac_running: + await self.saic_api.start_ac( + self.vin_info.vin, + temperature_idx=self.vehicle_state.get_ac_temperature_idx(), + ) + + except ValueError as e: + msg = f"Error setting temperature target: {e}" + raise MqttGatewayException(msg) from e + case mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE_SET: + match payload.strip().lower(): + case "off": + LOG.info("A/C will be switched off") + await self.saic_api.stop_ac(self.vin_info.vin) + case "blowingonly": + LOG.info("A/C will be set to blowing only") + await self.saic_api.start_ac_blowing(self.vin_info.vin) + case "on": + LOG.info("A/C will be switched on") + await self.saic_api.start_ac( + self.vin_info.vin, + temperature_idx=self.vehicle_state.get_ac_temperature_idx(), + ) + case "front": + LOG.info("A/C will be set to front seats only") + await self.saic_api.start_front_defrost(self.vin_info.vin) + case _: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) + case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL_SET: + try: + LOG.info("Setting heated seats front left level to %s", payload) + level = int(payload.strip().lower()) + changed = ( + self.vehicle_state.update_heated_seats_front_left_level( + level + ) + ) + if changed: + await self.saic_api.control_heated_seats( + self.vin_info.vin, + left_side_level=self.vehicle_state.remote_heated_seats_front_left_level, + right_side_level=self.vehicle_state.remote_heated_seats_front_right_level, + ) + else: + LOG.info("Heated seats front left level not changed") + except Exception as e: + msg = f"Error setting heated seats: {e}" + raise MqttGatewayException(msg) from e + + case mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL_SET: + try: + LOG.info( + "Setting heated seats front right level to %s", payload + ) + level = int(payload.strip().lower()) + changed = ( + self.vehicle_state.update_heated_seats_front_right_level( + level + ) + ) + if changed: + await self.saic_api.control_heated_seats( + self.vin_info.vin, + left_side_level=self.vehicle_state.remote_heated_seats_front_left_level, + right_side_level=self.vehicle_state.remote_heated_seats_front_right_level, + ) + else: + LOG.info("Heated seats front right level not changed") + except Exception as e: + msg = f"Error setting heated seats: {e}" + raise MqttGatewayException(msg) from e + + case mqtt_topics.DOORS_BOOT_SET: + match payload.strip().lower(): + case "true": + LOG.info( + f"We cannot lock vehicle {self.vin_info.vin} boot remotely" + ) + case "false": + LOG.info( + f"Vehicle {self.vin_info.vin} boot will be unlocked" + ) + await self.saic_api.open_tailgate(self.vin_info.vin) + case _: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) + case mqtt_topics.DOORS_LOCKED_SET: + match payload.strip().lower(): + case "true": + LOG.info(f"Vehicle {self.vin_info.vin} will be locked") + await self.saic_api.lock_vehicle(self.vin_info.vin) + case "false": + LOG.info(f"Vehicle {self.vin_info.vin} will be unlocked") + await self.saic_api.unlock_vehicle(self.vin_info.vin) + case _: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) + case mqtt_topics.CLIMATE_BACK_WINDOW_HEAT_SET: + match payload.strip().lower(): + case "off": + LOG.info("Rear window heating will be switched off") + await self.saic_api.control_rear_window_heat( + self.vin_info.vin, enable=False + ) + case "on": + LOG.info("Rear window heating will be switched on") + await self.saic_api.control_rear_window_heat( + self.vin_info.vin, enable=True + ) + case _: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) + case mqtt_topics.CLIMATE_FRONT_WINDOW_HEAT_SET: + match payload.strip().lower(): + case "off": + LOG.info("Front window heating will be switched off") + await self.saic_api.stop_ac(self.vin_info.vin) + case "on": + LOG.info("Front window heating will be switched on") + await self.saic_api.start_front_defrost(self.vin_info.vin) + case _: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) + case mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT_SET: + payload = payload.strip().upper() + if self.vehicle_state.target_soc is not None: + try: + LOG.info("Setting charging current limit to %s", payload) + raw_charge_current_limit = str(payload) + charge_current_limit = ChargeCurrentLimitCode.to_code( + raw_charge_current_limit + ) + await self.saic_api.set_target_battery_soc( + self.vin_info.vin, + target_soc=self.vehicle_state.target_soc, + charge_current_limit=charge_current_limit, + ) + self.vehicle_state.update_charge_current_limit( + charge_current_limit + ) + except ValueError as e: + msg = f"Error setting value for payload {payload}" + raise MqttGatewayException(msg) from e + else: + logging.info( + "Unknown Target SOC: waiting for state update before changing charge current limit" + ) + msg = f"Error setting charge current limit - SOC {self.vehicle_state.target_soc}" + raise MqttGatewayException(msg) + case mqtt_topics.DRIVETRAIN_SOC_TARGET_SET: + payload = payload.strip() + try: + LOG.info("Setting SoC target to %s", payload) + target_battery_code = TargetBatteryCode.from_percentage( + int(payload) + ) + await self.saic_api.set_target_battery_soc( + self.vin_info.vin, target_soc=target_battery_code + ) + self.vehicle_state.update_target_soc(target_battery_code) + except ValueError as e: + msg = f"Error setting SoC target: {e}" + raise MqttGatewayException(msg) from e + case mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE_SET: + payload = payload.strip() + try: + LOG.info("Setting charging schedule to %s", payload) + payload_json = json.loads(payload) + start_time = datetime.time.fromisoformat( + payload_json["startTime"] + ) + end_time = datetime.time.fromisoformat(payload_json["endTime"]) + mode = ScheduledChargingMode[payload_json["mode"].upper()] + await self.saic_api.set_schedule_charging( + self.vin_info.vin, + start_time=start_time, + end_time=end_time, + mode=mode, + ) + self.vehicle_state.update_scheduled_charging(start_time, mode) + except Exception as e: + msg = f"Error setting charging schedule: {e}" + raise MqttGatewayException(msg) from e + case mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE_SET: + payload = payload.strip() + try: + LOG.info("Setting battery heating schedule to %s", payload) + payload_json = json.loads(payload) + start_time = datetime.time.fromisoformat( + payload_json["startTime"] + ) + battery_heating_mode = payload_json["mode"].upper() + should_enable = battery_heating_mode == "ON" + changed = self.vehicle_state.update_scheduled_battery_heating( + start_time, should_enable + ) + if changed: + if should_enable: + LOG.info( + f"Setting battery heating schedule to {start_time}" + ) + await self.saic_api.enable_schedule_battery_heating( + self.vin_info.vin, start_time=start_time + ) + else: + LOG.info("Disabling battery heating schedule") + await self.saic_api.disable_schedule_battery_heating( + self.vin_info.vin + ) + else: + LOG.info("Battery heating schedule not changed") + except Exception as e: + msg = f"Error setting battery heating schedule: {e}" + raise MqttGatewayException(msg) from e + case mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK_SET: + match payload.strip().lower(): + case "false": + LOG.info( + f"Vehicle {self.vin_info.vin} charging cable will be unlocked" + ) + await self.saic_api.control_charging_port_lock( + self.vin_info.vin, unlock=True + ) + case "true": + LOG.info( + f"Vehicle {self.vin_info.vin} charging cable will be locked" + ) + await self.saic_api.control_charging_port_lock( + self.vin_info.vin, unlock=False + ) + case _: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) + case mqtt_topics.LOCATION_FIND_MY_CAR_SET: + vin = self.vin_info.vin + match payload.strip().lower(): + case "activate": + LOG.info( + f"Activating 'find my car' with horn and lights for vehicle {vin}" + ) + await self.saic_api.control_find_my_car(vin) + case "lights_only": + LOG.info( + f"Activating 'find my car' with lights only for vehicle {vin}" + ) + await self.saic_api.control_find_my_car( + vin, with_horn=False, with_lights=True + ) + case "horn_only": + LOG.info( + f"Activating 'find my car' with horn only for vehicle {vin}" + ) + await self.saic_api.control_find_my_car( + vin, with_horn=True, with_lights=False + ) + case "stop": + LOG.info(f"Stopping 'find my car' for vehicle {vin}") + await self.saic_api.control_find_my_car( + vin, should_stop=True + ) + case _: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) + case _: + # set mode, period (in)-active,... + should_force_refresh = False + await self.vehicle_state.configure_by_message( + topic=topic, payload=payload + ) + self.publisher.publish_str(result_topic, "Success") + if should_force_refresh: + self.vehicle_state.set_refresh_mode( + RefreshMode.FORCE, f"after command execution on topic {topic}" + ) + except MqttGatewayException as e: + self.publisher.publish_str(result_topic, f"Failed: {e.message}") + LOG.exception(e.message, exc_info=e) + except SaicLogoutException as se: + self.publisher.publish_str(result_topic, f"Failed: {se.message}") + LOG.error("API Client was logged out, waiting for a new login", exc_info=se) + self.relogin_handler.relogin() + except SaicApiException as se: + self.publisher.publish_str(result_topic, f"Failed: {se.message}") + LOG.exception(se.message, exc_info=se) + except Exception as se: + self.publisher.publish_str(result_topic, "Failed unexpectedly") + LOG.exception( + "handle_mqtt_command failed with an unexpected exception", exc_info=se + ) + + def __get_command_topics(self, topic: str) -> tuple[str, str]: + global_topic_removed = topic.removeprefix( + self.configuration.mqtt_topic + ).removeprefix("/") + set_topic = global_topic_removed.removeprefix(self.vehicle_prefix).removeprefix( + "/" + ) + result_topic = ( + global_topic_removed.removesuffix(SET_SUFFIX).removesuffix("/") + + "/" + + RESULT_SUFFIX + ) + return set_topic, result_topic + + def __setup_ha_discovery( + self, vehicle_state: VehicleState, vin_info: VehicleInfo, config: Configuration + ) -> HomeAssistantDiscovery | None: + if self.configuration.ha_discovery_enabled: + return HomeAssistantDiscovery(vehicle_state, vin_info, config) + return None + + +class VehicleHandlerLocator(ABC): + def get_vehicle_handler(self, vin: str) -> VehicleHandler | None: + raise NotImplementedError + + @property + @abstractmethod + def vehicle_handlers(self) -> dict[str, VehicleHandler]: + raise NotImplementedError diff --git a/src/integrations/__init__.py b/src/integrations/__init__.py new file mode 100644 index 0000000..1f7d61c --- /dev/null +++ b/src/integrations/__init__.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +class IntegrationException(Exception): + def __init__(self, integration: str, msg: str) -> None: + self.message = f"{integration}: {msg}" + + def __str__(self) -> str: + return self.message diff --git a/integrations/abrp/__init__.py b/src/integrations/abrp/__init__.py similarity index 100% rename from integrations/abrp/__init__.py rename to src/integrations/abrp/__init__.py diff --git a/src/integrations/abrp/api.py b/src/integrations/abrp/api.py new file mode 100644 index 0000000..0c88261 --- /dev/null +++ b/src/integrations/abrp/api.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +import json +import logging +from typing import TYPE_CHECKING, Any + +import httpx +from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus + +from integrations import IntegrationException +from utils import get_update_timestamp, value_in_range + +if TYPE_CHECKING: + from saic_ismart_client_ng.api.vehicle import VehicleStatusResp + from saic_ismart_client_ng.api.vehicle.schema import BasicVehicleStatus + from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp + from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus + +LOG = logging.getLogger(__name__) + + +class AbrpApiException(IntegrationException): + def __init__(self, msg: str) -> None: + super().__init__(__name__, msg) + + +class AbrpApiListener(ABC): + @abstractmethod + async def on_request( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: + pass + + @abstractmethod + async def on_response( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: + pass + + +class AbrpApi: + def __init__( + self, + abrp_api_key: str | None, + abrp_user_token: str | None, + listener: AbrpApiListener | None = None, + ) -> None: + self.abrp_api_key = abrp_api_key + self.abrp_user_token = abrp_user_token + self.__listener = listener + self.__base_uri = "https://api.iternio.com/1/" + self.client = httpx.AsyncClient( + event_hooks={ + "request": [self.invoke_request_listener], + "response": [self.invoke_response_listener], + } + ) + + async def update_abrp( + self, + vehicle_status: VehicleStatusResp | None, + charge_info: ChrgMgmtDataResp | None, + ) -> tuple[bool, str]: + charge_mgmt_data = None if charge_info is None else charge_info.chrgMgmtData + charge_status = None if charge_info is None else charge_info.rvsChargeStatus + + if ( + self.abrp_api_key is not None + and self.abrp_user_token is not None + and vehicle_status is not None + and charge_mgmt_data is not None + ): + # Request + tlm_send_url = f"{self.__base_uri}tlm/send" + data: dict[str, Any] = { + # Guess the timestamp from either the API, GPS info or current machine time + "utc": int(get_update_timestamp(vehicle_status).timestamp()), + } + if (soc := charge_mgmt_data.bmsPackSOCDsp) is not None: + data.update( + { + "soc": (soc / 10.0), + } + ) + + # Skip invalid current values reported by the API + decoded_current = charge_mgmt_data.decoded_current + is_valid_current = ( + charge_mgmt_data.bmsPackCrntV != 1 + and (raw_current := charge_mgmt_data.bmsPackCrnt) is not None + and value_in_range(raw_current, 0, 65535) + and decoded_current is not None + ) + if is_valid_current: + is_charging = ( + charge_status is not None + and charge_status.chargingGunState + and decoded_current is not None + and decoded_current < 0.0 + ) + data.update( + { + "power": charge_mgmt_data.decoded_power, + "voltage": charge_mgmt_data.decoded_voltage, + "current": decoded_current, + "is_charging": is_charging, + } + ) + + basic_vehicle_status = vehicle_status.basicVehicleStatus + if basic_vehicle_status is not None: + data.update(self.__extract_basic_vehicle_status(basic_vehicle_status)) + + # Extract electric range if available + data.update( + self.__extract_electric_range(basic_vehicle_status, charge_status) + ) + + gps_position = vehicle_status.gpsPosition + if gps_position is not None: + data.update(self.__extract_gps_position(gps_position)) + + headers = {"Authorization": f"APIKEY {self.abrp_api_key}"} + + try: + response = await self.client.post( + url=tlm_send_url, + headers=headers, + params={"token": self.abrp_user_token, "tlm": json.dumps(data)}, + ) + await response.aread() + return True, response.text + except httpx.ConnectError as ece: + msg = f"Connection error: {ece}" + raise AbrpApiException(msg) from ece + except httpx.TimeoutException as et: + msg = f"Timeout error {et}" + raise AbrpApiException(msg) from et + except httpx.RequestError as e: + msg = f"{e}" + raise AbrpApiException(msg) from e + except httpx.HTTPError as ehttp: + msg = f"HTTP error {ehttp}" + raise AbrpApiException(msg) from ehttp + else: + return False, "ABRP request skipped because of missing configuration" + + @staticmethod + def __extract_basic_vehicle_status( + basic_vehicle_status: BasicVehicleStatus, + ) -> dict[str, Any]: + data: dict[str, Any] = { + "is_parked": basic_vehicle_status.is_parked, + } + + exterior_temperature = basic_vehicle_status.exteriorTemperature + if exterior_temperature is not None and value_in_range( + exterior_temperature, -127, 127 + ): + data["ext_temp"] = exterior_temperature + mileage = basic_vehicle_status.mileage + # Skip invalid range readings + if mileage is not None and value_in_range(mileage, 1, 2147483647): + # Data must be reported in km + data["odometer"] = mileage / 10.0 + + if basic_vehicle_status.is_parked: + # We assume the vehicle is stationary, we will update it later from GPS if available + data["speed"] = 0.0 + + return data + + @staticmethod + def __extract_gps_position(gps_position: GpsPosition) -> dict[str, Any]: + data: dict[str, Any] = {} + + # Do not use GPS data if it is not available + if gps_position.gps_status_decoded not in [GpsStatus.FIX_2D, GpsStatus.FIX_3d]: + return data + + way_point = gps_position.wayPoint + if way_point is None: + return data + + speed = way_point.speed + if speed is not None and value_in_range(speed, -999, 4500): + data["speed"] = speed / 10 + + heading = way_point.heading + if heading is not None and value_in_range(heading, 0, 360): + data["heading"] = heading + + position = way_point.position + if position is None: + return data + + altitude = position.altitude + if altitude is not None and value_in_range(altitude, -500, 8900): + data["elevation"] = altitude + + if (raw_lat := position.latitude) is not None and ( + raw_lon := position.longitude + ) is not None: + lat_degrees = raw_lat / 1000000.0 + lon_degrees = raw_lon / 1000000.0 + + if abs(lat_degrees) <= 90 and abs(lon_degrees) <= 180: + data.update( + { + "lat": lat_degrees, + "lon": lon_degrees, + } + ) + + return data + + def __extract_electric_range( + self, + basic_vehicle_status: BasicVehicleStatus | None, + charge_status: RvsChargeStatus | None, + ) -> dict[str, Any]: + data = {} + + range_elec_vehicle = 0.0 + if ( + basic_vehicle_status + and (fuel_range := basic_vehicle_status.fuelRangeElec) is not None + ): + range_elec_vehicle = self.__parse_electric_range(raw_value=fuel_range) + + range_elec_bms = 0.0 + if charge_status and (fuel_range := charge_status.fuelRangeElec) is not None: + range_elec_bms = self.__parse_electric_range(raw_value=fuel_range) + + range_elec = max(range_elec_vehicle, range_elec_bms) + if range_elec > 0: + data["est_battery_range"] = range_elec + + return data + + @staticmethod + def __parse_electric_range(raw_value: int) -> float: + if value_in_range(raw_value, 1, 20460): + return float(raw_value) / 10.0 + return 0.0 + + async def invoke_request_listener(self, request: httpx.Request) -> None: + if not self.__listener: + return + try: + body = None + if request.content: + try: + body = request.content.decode("utf-8") + except Exception as e: + LOG.warning(f"Error decoding request content: {e}") + + await self.__listener.on_request( + path=str(request.url).replace(self.__base_uri, "/"), + body=body, + headers=dict(request.headers), + ) + except Exception as e: + LOG.warning(f"Error invoking request listener: {e}", exc_info=e) + + async def invoke_response_listener(self, response: httpx.Response) -> None: + if not self.__listener: + return + try: + body = await response.aread() + decoded_body: str | None = None + if body: + try: + decoded_body = body.decode("utf-8") + except Exception as e: + LOG.warning(f"Error decoding request content: {e}") + + await self.__listener.on_response( + path=str(response.url).replace(self.__base_uri, "/"), + body=decoded_body, + headers=dict(response.headers), + ) + except Exception as e: + LOG.warning(f"Error invoking request listener: {e}", exc_info=e) diff --git a/integrations/home_assistant/__init__.py b/src/integrations/home_assistant/__init__.py similarity index 100% rename from integrations/home_assistant/__init__.py rename to src/integrations/home_assistant/__init__.py diff --git a/src/integrations/home_assistant/discovery.py b/src/integrations/home_assistant/discovery.py new file mode 100644 index 0000000..b080f30 --- /dev/null +++ b/src/integrations/home_assistant/discovery.py @@ -0,0 +1,1318 @@ +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any + +import inflection +from saic_ismart_client_ng.api.vehicle_charging import ( + ChargeCurrentLimitCode, + ScheduledChargingMode, +) + +import mqtt_topics +from publisher.mqtt_publisher import MqttPublisher +from vehicle import RefreshMode, VehicleState + +if TYPE_CHECKING: + from collections.abc import Collection + + from configuration import Configuration + from vehicle_info import VehicleInfo + +LOG = logging.getLogger(__name__) + + +class HaCustomAvailabilityEntry: + def __init__( + self, + *, + topic: str, + template: str | None = None, + payload_available: str = "online", + payload_not_available: str = "offline", + ) -> None: + self.__topic = topic + self.__template = template + self.__payload_available = payload_available + self.__payload_not_available = payload_not_available + + def to_dict(self) -> dict[str, Any]: + result = { + "topic": self.__topic, + "payload_available": self.__payload_available, + "payload_not_available": self.__payload_not_available, + } + if self.__template: + result.update({"value_template": self.__template}) + return result + + def __key(self) -> Collection[str | None]: + return ( + self.__topic, + self.__template, + self.__payload_available, + self.__payload_not_available, + ) + + def __hash__(self) -> int: + return hash(self.__key()) + + def __eq__(self, other: object) -> bool: + if isinstance(other, HaCustomAvailabilityEntry): + return self.__key() == other.__key() + return NotImplemented + + +class HaCustomAvailabilityConfig: + def __init__( + self, + *, + rules: list[HaCustomAvailabilityEntry], + mode: str = "all", + ) -> None: + self.__rules = rules + self.__mode = mode + + def to_dict(self) -> dict[str, Any]: + return { + "availability": [r.to_dict() for r in set(self.__rules)], + "availability_mode": self.__mode, + } + + +class HomeAssistantDiscovery: + def __init__( + self, + vehicle_state: VehicleState, + vin_info: VehicleInfo, + configuration: Configuration, + ) -> None: + self.__vehicle_state = vehicle_state + self.__vin_info = vin_info + self.__discovery_prefix = configuration.ha_discovery_prefix + self.__system_availability = HaCustomAvailabilityEntry( + topic=self.__get_system_topic(mqtt_topics.INTERNAL_LWT) + ) + if configuration.ha_show_unavailable: + self.__vehicle_availability = HaCustomAvailabilityEntry( + topic=self.__get_vehicle_topic(mqtt_topics.AVAILABLE) + ) + else: + self.__vehicle_availability = self.__system_availability + self.__system_availability_config = HaCustomAvailabilityConfig( + rules=[self.__system_availability] + ) + self.__standard_availability_config = HaCustomAvailabilityConfig( + rules=[self.__system_availability, self.__vehicle_availability] + ) + self.published = False + + def publish_ha_discovery_messages(self, *, force: bool = False) -> None: + if not self.__vehicle_state.is_complete(): + LOG.warning( + "Skipping Home Assistant discovery messages as vehicle state is not yet complete" + ) + return + + if self.published and not force: + LOG.debug( + "Skipping Home Assistant discovery messages as it was already published" + ) + return + + self.__publish_ha_discovery_messages_real() + self.published = True + + def __publish_ha_discovery_messages_real(self) -> None: + LOG.debug("Publishing Home Assistant discovery messages") + + # Gateway Control + self.__publish_select( + mqtt_topics.REFRESH_MODE, + "Gateway refresh mode", + [m.value for m in RefreshMode], + entity_category="config", + icon="mdi:refresh", + custom_availability=self.__system_availability_config, + ) + self.__publish_number( + mqtt_topics.REFRESH_PERIOD_ACTIVE, + "Gateway active refresh period", + entity_category="config", + unit_of_measurement="s", + icon="mdi:timer", + min_value=30, + max_value=60 * 60, + step=1, + custom_availability=self.__system_availability_config, + ) + self.__publish_number( + mqtt_topics.REFRESH_PERIOD_INACTIVE, + "Gateway inactive refresh period", + entity_category="config", + unit_of_measurement="s", + icon="mdi:timer", + min_value=1 * 60 * 60, + max_value=5 * 24 * 60 * 60, + step=1, + custom_availability=self.__system_availability_config, + ) + self.__publish_number( + mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN, + "Gateway refresh period after car shutdown", + entity_category="config", + unit_of_measurement="s", + icon="mdi:timer", + min_value=30, + max_value=12 * 60 * 60, + step=1, + custom_availability=self.__system_availability_config, + ) + self.__publish_number( + mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE, + "Gateway grace period after car shutdown", + entity_category="config", + unit_of_measurement="s", + icon="mdi:timer", + min_value=30, + max_value=12 * 60 * 60, + step=1, + custom_availability=self.__system_availability_config, + ) + self.__publish_sensor( + mqtt_topics.REFRESH_PERIOD_CHARGING, + "Gateway charging refresh period", + entity_category="diagnostic", + unit_of_measurement="s", + icon="mdi:timer", + custom_availability=self.__system_availability_config, + ) + self.__publish_sensor( + mqtt_topics.REFRESH_PERIOD_ERROR, + "Gateway error refresh period", + entity_category="diagnostic", + unit_of_measurement="s", + icon="mdi:timer", + custom_availability=self.__system_availability_config, + ) + self.__publish_sensor( + mqtt_topics.REFRESH_LAST_ACTIVITY, + "Last car activity", + device_class="timestamp", + entity_category="diagnostic", + custom_availability=self.__system_availability_config, + ) + self.__publish_sensor( + mqtt_topics.REFRESH_LAST_CHARGE_STATE, + "Last charge state", + device_class="timestamp", + entity_category="diagnostic", + custom_availability=self.__system_availability_config, + ) + self.__publish_sensor( + mqtt_topics.REFRESH_LAST_VEHICLE_STATE, + "Last vehicle state", + device_class="timestamp", + entity_category="diagnostic", + custom_availability=self.__system_availability_config, + ) + self.__publish_sensor( + mqtt_topics.REFRESH_LAST_ERROR, + "Last poll error", + device_class="timestamp", + entity_category="diagnostic", + custom_availability=self.__system_availability_config, + ) + + self.__publish_sensor( + mqtt_topics.INFO_LAST_MESSAGE_CONTENT, + "Last car message", + entity_category="diagnostic", + enabled=False, + custom_availability=self.__system_availability_config, + ) + + # Complex sensors + self.__publish_remote_ac() + self.__publish_heated_seats() + self.__publish_vehicle_tracker() + self.__publish_scheduled_charging() + self.__publish_scheduled_battery_heating() + + # Switches + self.__publish_switch(mqtt_topics.DRIVETRAIN_CHARGING, "Charging") + self.__publish_switch( + mqtt_topics.DRIVETRAIN_BATTERY_HEATING, + "Battery heating", + icon="mdi:heat-wave", + ) + self.__publish_switch(mqtt_topics.WINDOWS_DRIVER, "Window driver") + self.__publish_switch(mqtt_topics.WINDOWS_PASSENGER, "Window passenger") + self.__publish_switch(mqtt_topics.WINDOWS_REAR_LEFT, "Window rear left") + self.__publish_switch(mqtt_topics.WINDOWS_REAR_RIGHT, "Window rear right") + + if self.__vin_info.has_sunroof: + self.__publish_switch(mqtt_topics.WINDOWS_SUN_ROOF, "Sun roof") + self.__publish_binary_sensor(mqtt_topics.WINDOWS_SUN_ROOF, "Sun roof") + else: + self.__unpublish_ha_discovery_message("switch", "Sun roof") + self.__unpublish_ha_discovery_message("binary_sensor", "Sun roof") + + self.__publish_switch( + mqtt_topics.CLIMATE_BACK_WINDOW_HEAT, + "Rear window defroster heating", + icon="mdi:car-defrost-rear", + payload_on="on", + payload_off="off", + ) + self.__publish_switch( + mqtt_topics.LOCATION_FIND_MY_CAR, + "Find my car", + icon="mdi:car-search", + payload_on="activate", + payload_off="stop", + ) + + # Locks + self.__publish_lock( + mqtt_topics.DOORS_LOCKED, "Doors Lock", icon="mdi:car-door-lock" + ) + self.__publish_lock( + mqtt_topics.DOORS_BOOT, + "Boot Lock", + icon="mdi:car-door-lock", + state_locked="False", + state_unlocked="True", + ) + self.__publish_lock( + mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK, + "Charging Cable Lock", + icon="mdi:lock", + ) + + # Target SoC + self.__publish_number( + mqtt_topics.DRIVETRAIN_SOC_TARGET, + "Target SoC", + device_class="battery", + unit_of_measurement="%", + min_value=40, + max_value=100, + step=10, + mode="slider", + icon="mdi:battery-charging-70", + enabled=self.__vin_info.supports_target_soc, + ) + self.__publish_select( + mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT, + "Charge current limit", + [ + m.limit + for m in ChargeCurrentLimitCode + if m != ChargeCurrentLimitCode.C_IGNORE + ], + icon="mdi:current-ac", + ) + + # Standard sensors + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_SOC, + "SoC", + device_class="battery", + state_class="measurement", + unit_of_measurement="%", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_SOC_KWH, + "SoC_kWh", + device_class="ENERGY_STORAGE", + state_class="measurement", + icon="mdi:battery-charging-70", + unit_of_measurement="kWh", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_LAST_CHARGE_ENDING_POWER, + "Last Charge SoC kWh", + device_class="ENERGY_STORAGE", + state_class="total_increasing", + icon="mdi:battery-charging-70", + unit_of_measurement="kWh", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_POWER_USAGE_SINCE_LAST_CHARGE, + "Energy Usage Since Last Charge", + device_class="ENERGY_STORAGE", + state_class="measurement", + icon="mdi:battery-charging-70", + unit_of_measurement="kWh", + enabled=False, + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_POWER_USAGE_OF_DAY, + "Energy Usage of the Day", + device_class="ENERGY_STORAGE", + state_class="measurement", + icon="mdi:battery-charging-70", + unit_of_measurement="kWh", + enabled=False, + ) + + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME, + "Remaining charging time", + device_class="duration", + state_class="measurement", + unit_of_measurement="s", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME, + "Charging finished", + device_class="timestamp", + value_template="{{ (now() + timedelta(seconds = value | int)).isoformat() }}", + custom_availability=HaCustomAvailabilityConfig( + rules=[ + self.__system_availability, + self.__vehicle_availability, + HaCustomAvailabilityEntry( + topic=self.__get_vehicle_topic( + mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME + ), + template="{{ 'online' if (value | int) > 0 else 'offline' }}", + ), + ] + ), + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_CHARGING_LAST_START, + "Last Charge Start Time", + device_class="timestamp", + value_template="{{ value | int | timestamp_utc }}", + icon="mdi:clock-start", + custom_availability=HaCustomAvailabilityConfig( + rules=[ + self.__system_availability, + self.__vehicle_availability, + HaCustomAvailabilityEntry( + topic=self.__get_vehicle_topic( + mqtt_topics.DRIVETRAIN_CHARGING_LAST_START + ), + template="{{ 'online' if (value | int) > 0 else 'offline' }}", + ), + ] + ), + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_CHARGING_LAST_END, + "Last Charge End Time", + device_class="timestamp", + value_template="{{ value | int | timestamp_utc }}", + icon="mdi:clock-end", + custom_availability=HaCustomAvailabilityConfig( + rules=[ + self.__system_availability, + self.__vehicle_availability, + HaCustomAvailabilityEntry( + topic=self.__get_vehicle_topic( + mqtt_topics.DRIVETRAIN_CHARGING_LAST_END + ), + template="{{ 'online' if (value | int) > 0 else 'offline' }}", + ), + ] + ), + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_CHARGING_TYPE, + "Charging Mode", + entity_category="diagnostic", + enabled=False, + ) + self.__publish_sensor( + mqtt_topics.BMS_CHARGE_STATUS, + "BMS Charge Status", + entity_category="diagnostic", + enabled=False, + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_MILEAGE, + "Mileage", + device_class="distance", + state_class="total_increasing", + unit_of_measurement="km", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_MILEAGE_OF_DAY, + "Mileage of the day", + device_class="distance", + state_class="total_increasing", + unit_of_measurement="km", + enabled=False, + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE, + "Mileage since last charge", + device_class="distance", + state_class="total_increasing", + unit_of_measurement="km", + enabled=False, + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_CURRENT_JOURNEY, + "Mileage of journey", + device_class="distance", + state_class="total_increasing", + unit_of_measurement="km", + value_template='{{ value_json["distance"] | int(0) }}', + enabled=False, + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_CURRENT_JOURNEY, + "Identifier of journey", + value_template='{{ value_json["id"] | int(0) }}', + enabled=False, + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE, + "Auxiliary battery voltage", + device_class="voltage", + state_class="measurement", + unit_of_measurement="V", + icon="mdi:car-battery", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_RANGE, + "Range", + device_class="distance", + unit_of_measurement="km", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_RANGE, + "Fossil fuel range", + device_class="distance", + unit_of_measurement="km", + enabled=self.__vin_info.has_fossil_fuel, + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE, + "Fossil fuel percentage", + state_class="measurement", + unit_of_measurement="%", + icon="mdi:fuel", + enabled=self.__vin_info.has_fossil_fuel, + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_CURRENT, + "Current", + device_class="current", + state_class="measurement", + unit_of_measurement="A", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_VOLTAGE, + "Voltage", + device_class="voltage", + state_class="measurement", + unit_of_measurement="V", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_POWER, + "Power", + device_class="power", + state_class="measurement", + unit_of_measurement="kW", + ) + + self.__publish_sensor( + mqtt_topics.OBC_CURRENT, + "OBC Current", + device_class="current", + state_class="measurement", + unit_of_measurement="A", + entity_category="diagnostic", + enabled=False, + ) + self.__publish_sensor( + mqtt_topics.OBC_VOLTAGE, + "OBC Voltage", + device_class="voltage", + state_class="measurement", + unit_of_measurement="V", + entity_category="diagnostic", + enabled=False, + ) + + self.__publish_sensor( + mqtt_topics.OBC_POWER_SINGLE_PHASE, + "OBC Power Single Phase", + device_class="power", + state_class="measurement", + unit_of_measurement="W", + entity_category="diagnostic", + enabled=False, + ) + + self.__publish_sensor( + mqtt_topics.OBC_POWER_THREE_PHASE, + "OBC Power Three Phase", + device_class="power", + state_class="measurement", + unit_of_measurement="W", + entity_category="diagnostic", + enabled=False, + ) + + self.__publish_sensor( + mqtt_topics.CCU_ONBOARD_PLUG_STATUS, + "CCU Onboard Plug Status", + state_class="measurement", + entity_category="diagnostic", + enabled=False, + ) + + self.__publish_sensor( + mqtt_topics.CCU_OFFBOARD_PLUG_STATUS, + "CCU Offboard Plug Status", + state_class="measurement", + entity_category="diagnostic", + enabled=False, + ) + + self.__publish_sensor( + mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE, + "Interior temperature", + device_class="temperature", + state_class="measurement", + unit_of_measurement="°C", + ) + self.__publish_sensor( + mqtt_topics.CLIMATE_EXTERIOR_TEMPERATURE, + "Exterior temperature", + device_class="temperature", + state_class="measurement", + unit_of_measurement="°C", + ) + self.__publish_sensor( + mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE, + "Remote climate state", + icon="mdi:car-connected", + ) + self.__publish_sensor( + mqtt_topics.CLIMATE_BACK_WINDOW_HEAT, + "Rear window defroster heating", + icon="mdi:car-defrost-rear", + ) + self.__publish_sensor( + mqtt_topics.LOCATION_HEADING, + "Heading", + icon="mdi:compass", + unit_of_measurement="°", + ) + self.__publish_sensor( + mqtt_topics.LOCATION_SPEED, + "Vehicle speed", + device_class="speed", + unit_of_measurement="km/h", + ) + self.__publish_sensor( + mqtt_topics.TYRES_FRONT_LEFT_PRESSURE, + "Tyres front left pressure", + device_class="pressure", + unit_of_measurement="bar", + icon="mdi:tire", + ) + self.__publish_sensor( + mqtt_topics.TYRES_FRONT_RIGHT_PRESSURE, + "Tyres front right pressure", + device_class="pressure", + unit_of_measurement="bar", + icon="mdi:tire", + ) + self.__publish_sensor( + mqtt_topics.TYRES_REAR_LEFT_PRESSURE, + "Tyres rear left pressure", + device_class="pressure", + unit_of_measurement="bar", + icon="mdi:tire", + ) + self.__publish_sensor( + mqtt_topics.TYRES_REAR_RIGHT_PRESSURE, + "Tyres rear right pressure", + device_class="pressure", + unit_of_measurement="bar", + icon="mdi:tire", + ) + # Binary sensors + self.__publish_binary_sensor( + mqtt_topics.DRIVETRAIN_CHARGER_CONNECTED, + "Charger connected", + device_class="plug", + icon="mdi:power-plug-battery", + ) + self.__publish_binary_sensor( + mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE, + "HV Battery Active", + device_class="power", + icon="mdi:battery-check", + ) + self.__publish_binary_sensor( + mqtt_topics.DRIVETRAIN_CHARGING, + "Battery Charging", + device_class="battery_charging", + icon="mdi:battery-charging", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_CHARGING_STOP_REASON, + "Battery charging stop reason", + icon="mdi:battery-charging", + enabled=False, + ) + self.__publish_binary_sensor( + mqtt_topics.DRIVETRAIN_BATTERY_HEATING, + "Battery heating", + icon="mdi:heat-wave", + ) + self.__publish_sensor( + mqtt_topics.DRIVETRAIN_BATTERY_HEATING_STOP_REASON, + "Battery heating stop reason", + icon="mdi:heat-wave", + enabled=False, + ) + self.__publish_binary_sensor( + mqtt_topics.DRIVETRAIN_RUNNING, + "Vehicle Running", + device_class="running", + icon="mdi:car-side", + ) + self.__publish_binary_sensor( + mqtt_topics.DOORS_DRIVER, + "Door driver", + device_class="door", + icon="mdi:car-door", + ) + self.__publish_binary_sensor( + mqtt_topics.DOORS_PASSENGER, + "Door passenger", + device_class="door", + icon="mdi:car-door", + ) + self.__publish_binary_sensor( + mqtt_topics.DOORS_REAR_LEFT, + "Door rear left", + device_class="door", + icon="mdi:car-door", + ) + self.__publish_binary_sensor( + mqtt_topics.DOORS_REAR_RIGHT, + "Door rear right", + device_class="door", + icon="mdi:car-door", + ) + self.__publish_binary_sensor( + mqtt_topics.DOORS_BONNET, "Bonnet", device_class="door", icon="mdi:car-door" + ) + self.__publish_binary_sensor( + mqtt_topics.DOORS_BOOT, "Boot", device_class="door", icon="mdi:car-door" + ) + self.__publish_binary_sensor( + mqtt_topics.LIGHTS_MAIN_BEAM, + "Lights Main Beam", + device_class="light", + icon="mdi:car-light-high", + ) + self.__publish_binary_sensor( + mqtt_topics.LIGHTS_DIPPED_BEAM, + "Lights Dipped Beam", + device_class="light", + icon="mdi:car-light-dimmed", + ) + self.__publish_binary_sensor( + mqtt_topics.LIGHTS_SIDE, + "Lights Side", + device_class="light", + icon="mdi:car-light-dimmed", + ) + + # Remove deprecated sensors + self.__unpublish_ha_discovery_message( + "sensor", "Front window defroster heating" + ) + LOG.debug("Completed publishing Home Assistant discovery messages") + + def __publish_vehicle_tracker(self) -> None: + self.__publish_ha_discovery_message( + "device_tracker", + "Vehicle position", + { + "json_attributes_topic": self.__get_vehicle_topic( + mqtt_topics.LOCATION_POSITION + ) + }, + ) + + def __publish_remote_ac(self) -> None: + # This has been converted into 2 switches and a climate entity for ease of operation + + self.__publish_switch( + mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE, + "Front window defroster heating", + icon="mdi:car-defrost-front", + value_template='{% if value == "front" %}front{% else %}off{% endif %}', + payload_on="front", + payload_off="off", + ) + + self.__publish_switch( + mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE, + "Vehicle climate fan only", + icon="mdi:fan", + value_template='{% if value == "blowingonly" %}blowingonly{% else %}off{% endif %}', + payload_on="blowingonly", + payload_off="off", + ) + + self.__publish_ha_discovery_message( + "climate", + "Vehicle climate", + { + "precision": 1.0, + "temperature_unit": "C", + "mode_state_topic": self.__get_vehicle_topic( + mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE + ), + "mode_command_topic": self.__get_vehicle_topic( + mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE_SET + ), + "mode_state_template": '{% if value == "on" %}auto{% else %}off{% endif %}', + "mode_command_template": '{% if value == "auto" %}on{% else %}off{% endif %}', + "modes": ["off", "auto"], + "current_temperature_topic": self.__get_vehicle_topic( + mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE + ), + "current_temperature_template": "{{ value }}", + "temperature_command_topic": self.__get_vehicle_topic( + mqtt_topics.CLIMATE_REMOTE_TEMPERATURE_SET + ), + "temperature_command_template": "{{ value | int }}", + "temperature_state_topic": self.__get_vehicle_topic( + mqtt_topics.CLIMATE_REMOTE_TEMPERATURE + ), + "temperature_state_template": "{{ value | int }}", + "min_temp": self.__vin_info.min_ac_temperature, + "max_temp": self.__vin_info.max_ac_temperature, + }, + ) + + def __publish_switch( + self, + topic: str, + name: str, + *, + enabled: bool = True, + icon: str | None = None, + value_template: str = "{{ value }}", + payload_on: str = "True", + payload_off: str = "False", + custom_availability: HaCustomAvailabilityConfig | None = None, + ) -> str: + payload = { + "state_topic": self.__get_vehicle_topic(topic), + "command_topic": self.__get_vehicle_set_topic(topic), + "value_template": value_template, + "payload_on": payload_on, + "payload_off": payload_off, + "optimistic": False, + "qos": 0, + "enabled_by_default": enabled, + } + if icon is not None: + payload["icon"] = icon + return self.__publish_ha_discovery_message( + "switch", name, payload, custom_availability + ) + + def __publish_lock( + self, + topic: str, + name: str, + enabled: bool = True, + icon: str | None = None, + payload_lock: str = "True", + payload_unlock: str = "False", + state_locked: str = "True", + state_unlocked: str = "False", + custom_availability: HaCustomAvailabilityConfig | None = None, + ) -> str: + payload = { + "state_topic": self.__get_vehicle_topic(topic), + "command_topic": self.__get_vehicle_set_topic(topic), + "payload_lock": payload_lock, + "payload_unlock": payload_unlock, + "state_locked": state_locked, + "state_unlocked": state_unlocked, + "optimistic": False, + "qos": 0, + "enabled_by_default": enabled, + } + if icon is not None: + payload["icon"] = icon + return self.__publish_ha_discovery_message( + "lock", name, payload, custom_availability + ) + + def __publish_sensor( + self, + topic: str, + name: str, + enabled: bool = True, + entity_category: str | None = None, + device_class: str | None = None, + state_class: str | None = None, + unit_of_measurement: str | None = None, + icon: str | None = None, + value_template: str = "{{ value }}", + custom_availability: HaCustomAvailabilityConfig | None = None, + ) -> str: + payload = { + "state_topic": self.__get_vehicle_topic(topic), + "value_template": value_template, + "enabled_by_default": enabled, + } + if entity_category is not None: + payload["entity_category"] = entity_category + if device_class is not None: + payload["device_class"] = device_class + if state_class is not None: + payload["state_class"] = state_class + if unit_of_measurement is not None: + payload["unit_of_measurement"] = unit_of_measurement + if icon is not None: + payload["icon"] = icon + + return self.__publish_ha_discovery_message( + "sensor", name, payload, custom_availability + ) + + def __publish_number( + self, + topic: str, + name: str, + enabled: bool = True, + entity_category: str | None = None, + device_class: str | None = None, + state_class: str | None = None, + unit_of_measurement: str | None = None, + icon: str | None = None, + value_template: str = "{{ value }}", + retain: bool = False, + mode: str = "auto", + min_value: float = 1.0, + max_value: float = 100.0, + step: float = 1.0, + custom_availability: HaCustomAvailabilityConfig | None = None, + ) -> str: + payload = { + "state_topic": self.__get_vehicle_topic(topic), + "command_topic": self.__get_vehicle_set_topic(topic), + "value_template": value_template, + "retain": str(retain).lower(), + "mode": mode, + "min": min_value, + "max": max_value, + "step": step, + "enabled_by_default": enabled, + } + if entity_category is not None: + payload["entity_category"] = entity_category + if device_class is not None: + payload["device_class"] = device_class + if state_class is not None: + payload["state_class"] = state_class + if unit_of_measurement is not None: + payload["unit_of_measurement"] = unit_of_measurement + if icon is not None: + payload["icon"] = icon + + return self.__publish_ha_discovery_message( + "number", name, payload, custom_availability + ) + + def __publish_text( + self, + topic: str, + name: str, + enabled: bool = True, + icon: str | None = None, + value_template: str = "{{ value }}", + command_template: str = "{{ value }}", + retain: bool = False, + min_value: int | None = None, + max_value: int | None = None, + pattern: str | None = None, + custom_availability: HaCustomAvailabilityConfig | None = None, + ) -> str: + payload = { + "state_topic": self.__get_vehicle_topic(topic), + "command_topic": self.__get_vehicle_set_topic(topic), + "value_template": value_template, + "command_template": command_template, + "retain": str(retain).lower(), + "enabled_by_default": enabled, + } + if min_value is not None: + payload["min"] = min_value + if max_value is not None: + payload["max"] = max_value + if pattern is not None: + payload["pattern"] = pattern + if icon is not None: + payload["icon"] = icon + + return self.__publish_ha_discovery_message( + "text", name, payload, custom_availability + ) + + def __publish_binary_sensor( + self, + topic: str, + name: str, + enabled: bool = True, + device_class: str | None = None, + value_template: str = "{{ value }}", + payload_on: str = "True", + payload_off: str = "False", + icon: str | None = None, + custom_availability: HaCustomAvailabilityConfig | None = None, + ) -> str: + payload = { + "state_topic": self.__get_vehicle_topic(topic), + "value_template": value_template, + "payload_on": payload_on, + "payload_off": payload_off, + "enabled_by_default": enabled, + } + if device_class is not None: + payload["device_class"] = device_class + if icon is not None: + payload["icon"] = icon + + return self.__publish_ha_discovery_message( + "binary_sensor", name, payload, custom_availability + ) + + def __publish_select( + self, + topic: str, + name: str, + options: list[str], + *, + entity_category: str | None = None, + enabled: bool = True, + value_template: str = "{{ value }}", + command_template: str = "{{ value }}", + icon: str | None = None, + custom_availability: HaCustomAvailabilityConfig | None = None, + ) -> str: + payload = { + "state_topic": self.__get_vehicle_topic(topic), + "command_topic": self.__get_vehicle_set_topic(topic), + "value_template": value_template, + "command_template": command_template, + "options": options, + "enabled_by_default": enabled, + } + if entity_category is not None: + payload["entity_category"] = entity_category + if icon is not None: + payload["icon"] = icon + + return self.__publish_ha_discovery_message( + "select", name, payload, custom_availability + ) + + def __get_common_attributes( + self, + unique_id: str, + name: str, + custom_availability: HaCustomAvailabilityConfig | None = None, + ) -> dict[str, Any]: + common_attributes = { + "name": name, + "device": self.__get_device_node(), + "unique_id": unique_id, + "object_id": unique_id, + } + + if custom_availability is not None: + common_attributes.update(custom_availability.to_dict()) + else: + common_attributes.update(self.__standard_availability_config.to_dict()) + + return common_attributes + + def __get_device_node(self) -> dict[str, Any]: + vin = self.vin + brand_name = decode_as_utf8(self.__vin_info.brand) + model_name = decode_as_utf8(self.__vin_info.model) + model_year = decode_as_utf8(self.__vin_info.model_year) + color_name = decode_as_utf8(self.__vin_info.color) + series = str(self.__vin_info.series) + # Create a long model name concatenating model_name, model_year and color_name without multiple spaces + final_model_name = f"{model_name} {model_year} {color_name}".strip().replace( + " ", " " + ) + return { + "name": f"{brand_name} {model_name} {vin}", + "manufacturer": brand_name, + "model": final_model_name, + "hw_version": series, + "identifiers": [vin], + } + + @property + def vin(self) -> str: + return self.__vin_info.vin + + def __get_system_topic(self, topic: str) -> str: + publisher = self.__vehicle_state.publisher + if isinstance(publisher, MqttPublisher): + return publisher.get_topic(topic, no_prefix=False) + return topic + + def __get_vehicle_topic(self, topic: str) -> str: + vehicle_topic: str = self.__vehicle_state.get_topic(topic) + publisher = self.__vehicle_state.publisher + if isinstance(publisher, MqttPublisher): + return publisher.get_topic(vehicle_topic, no_prefix=False) + return vehicle_topic + + def __get_vehicle_set_topic(self, topic: str) -> str: + return self.__get_vehicle_topic(topic) + "/" + mqtt_topics.SET_SUFFIX + + def __publish_ha_discovery_message( + self, + sensor_type: str, + sensor_name: str, + payload: dict[str, Any], + custom_availability: HaCustomAvailabilityConfig | None = None, + ) -> str: + vin = self.vin + unique_id = f"{vin}_{snake_case(sensor_name)}" + final_payload = ( + self.__get_common_attributes(unique_id, sensor_name, custom_availability) + | payload + ) + ha_topic = ( + f"{self.__discovery_prefix}/{sensor_type}/{vin}_mg/{unique_id}/config" + ) + self.__vehicle_state.publisher.publish_json( + ha_topic, final_payload, no_prefix=True + ) + return f"{sensor_type}.{unique_id}" + + # This de-registers an entity from Home Assistant + def __unpublish_ha_discovery_message( + self, sensor_type: str, sensor_name: str + ) -> None: + vin = self.vin + unique_id = f"{vin}_{snake_case(sensor_name)}" + ha_topic = ( + f"{self.__discovery_prefix}/{sensor_type}/{vin}_mg/{unique_id}/config" + ) + self.__vehicle_state.publisher.publish_str(ha_topic, "", no_prefix=True) + + def __publish_scheduled_charging(self) -> None: + start_time_id = self.__publish_sensor( + mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, + "Scheduled Charging Start", + value_template='{{ value_json["startTime"] }}', + icon="mdi:clock-start", + ) + end_time_id = self.__publish_sensor( + mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, + "Scheduled Charging End", + value_template='{{ value_json["endTime"] }}', + icon="mdi:clock-end", + ) + scheduled_charging_mode_id = self.__publish_sensor( + mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, + "Scheduled Charging Mode", + value_template='{{ value_json["mode"] }}', + icon="mdi:clock-outline", + ) + + change_mode_cmd_template = json.dumps( + { + "startTime": f"{{{{ states('{start_time_id}') }}}}", + "endTime": f"{{{{ states('{end_time_id}') }}}}", + "mode": "{{ value }}", + } + ) + self.__publish_select( + mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, + "Scheduled Charging Mode", + options=[m.name for m in ScheduledChargingMode], + value_template='{{ value_json["mode"] }}', + command_template=change_mode_cmd_template, + icon="mdi:clock-outline", + ) + + change_start_cmd_template = json.dumps( + { + "startTime": "{{ value }}", + "endTime": f"{{{{ states('{end_time_id}') }}}}", + "mode": f"{{{{ states('{scheduled_charging_mode_id}') }}}}", + } + ) + self.__publish_text( + mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, + "Scheduled Charging Start", + value_template='{{ value_json["startTime"] }}', + command_template=change_start_cmd_template, + min_value=4, + max_value=5, + pattern="^([01][0-9]|2[0-3]):[0-5][0-9]$", + icon="mdi:clock-start", + ) + + change_end_cmd_template = json.dumps( + { + "startTime": f"{{{{ states('{start_time_id}') }}}}", + "endTime": "{{ value }}", + "mode": f"{{{{ states('{scheduled_charging_mode_id}') }}}}", + } + ) + self.__publish_text( + mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, + "Scheduled Charging End", + value_template='{{ value_json["endTime"] }}', + command_template=change_end_cmd_template, + min_value=4, + max_value=5, + pattern="^([01][0-9]|2[0-3]):[0-5][0-9]$", + icon="mdi:clock-end", + ) + + def __publish_scheduled_battery_heating(self) -> None: + start_time_id = self.__publish_sensor( + mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE, + "Scheduled Battery Heating Start", + value_template='{{ value_json["startTime"] }}', + icon="mdi:clock-start", + ) + mode_id = self.__publish_binary_sensor( + mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE, + "Scheduled Battery Heating", + value_template='{{ value_json["mode"] }}', + icon="mdi:clock-outline", + payload_on="on", + payload_off="off", + ) + change_mode_cmd_template = json.dumps( + {"startTime": f"{{{{ states('{start_time_id}') }}}}", "mode": "{{ value }}"} + ) + self.__publish_select( + mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE, + "Scheduled Battery Heating", + options=["on", "off"], + value_template='{{ value_json["mode"] }}', + command_template=change_mode_cmd_template, + icon="mdi:clock-outline", + ) + + change_start_cmd_template = json.dumps( + {"startTime": "{{ value }}", "mode": f"{{{{ states('{mode_id}') }}}}"} + ) + self.__publish_text( + mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE, + "Scheduled Battery Heating Start", + value_template='{{ value_json["startTime"] }}', + command_template=change_start_cmd_template, + min_value=4, + max_value=5, + pattern="^([01][0-9]|2[0-3]):[0-5][0-9]$", + icon="mdi:clock-start", + ) + + def __publish_heated_seats(self) -> None: + if self.__vin_info.has_level_heated_seats: + self.__unpublish_heated_seat_switch("Front Left") + self.__unpublish_heated_seat_switch("Front Right") + self.__publish_heated_seat_level( + "Front Left", mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL + ) + self.__publish_heated_seat_level( + "Front Right", mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL + ) + elif self.__vin_info.has_on_off_heated_seats: + self.__unpublish_heated_seat_level("Front Left") + self.__unpublish_heated_seat_level("Front Right") + self.__publish_heated_seat_switch( + "Front Left", mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL + ) + self.__publish_heated_seat_switch( + "Front Right", mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL + ) + else: + self.__unpublish_heated_seat_level("Front Left") + self.__unpublish_heated_seat_level("Front Right") + self.__unpublish_heated_seat_switch("Front Left") + self.__unpublish_heated_seat_switch("Front Right") + + def __publish_heated_seat_level(self, seat: str, topic: str) -> None: + self.__publish_select( + topic, + f"Heated Seat {seat} Level", + options=["OFF", "LOW", "MEDIUM", "HIGH"], + value_template="{% set v = value | int %}" + "{% if v == 0 %}OFF" + "{% elif v == 1 %}LOW" + "{% elif v == 2 %}MEDIUM" + "{% else %}HIGH" + "{% endif %}", + command_template='{% if value == "OFF" %}0' + '{% elif value == "LOW" %}1' + '{% elif value == "MEDIUM" %}2' + "{% else %}3" + "{% endif %}", + icon="mdi:car-seat-heater", + ) + + def __unpublish_heated_seat_level(self, seat: str) -> None: + self.__unpublish_ha_discovery_message("select", f"Heated Seat {seat} Level") + + def __publish_heated_seat_switch(self, seat: str, topic: str) -> None: + self.__publish_switch( + topic, + f"Heated Seat {seat}", + payload_off="0", + payload_on="1", + icon="mdi:car-seat-heater", + ) + + def __unpublish_heated_seat_switch(self, seat: str) -> None: + self.__unpublish_ha_discovery_message("switch", f"Heated Seat {seat}") + + +def snake_case(s: str) -> str: + return inflection.underscore(s.lower()).replace(" ", "_") + + +def decode_as_utf8( + byte_string: str | None | bytes | bytearray, default: str = "" +) -> str: + if byte_string is None: + return default + if isinstance(byte_string, str): + return byte_string + if isinstance(byte_string, bytes | bytearray): + try: + return str(byte_string, encoding="utf8", errors="ignore") + except Exception: + LOG.exception(f"Failed to decode {byte_string!r} as utf8") + return default + else: + try: # type: ignore[unreachable] + return str(byte_string) + except Exception: + LOG.exception(f"Failed to decode {byte_string}") + return default diff --git a/integrations/openwb/__init__.py b/src/integrations/openwb/__init__.py similarity index 100% rename from integrations/openwb/__init__.py rename to src/integrations/openwb/__init__.py diff --git a/integrations/openwb/charging_station.py b/src/integrations/openwb/charging_station.py similarity index 63% rename from integrations/openwb/charging_station.py rename to src/integrations/openwb/charging_station.py index 5b9a879..b02542c 100644 --- a/integrations/openwb/charging_station.py +++ b/src/integrations/openwb/charging_station.py @@ -1,5 +1,14 @@ +from __future__ import annotations + + class ChargingStation: - def __init__(self, vin: str, charge_state_topic: str, charging_value: str, soc_topic: str | None = None): + def __init__( + self, + vin: str, + charge_state_topic: str, + charging_value: str, + soc_topic: str | None = None, + ) -> None: self.vin: str = vin self.charge_state_topic: str = charge_state_topic self.charging_value: str = charging_value diff --git a/integrations/osmand/__init__.py b/src/integrations/osmand/__init__.py similarity index 100% rename from integrations/osmand/__init__.py rename to src/integrations/osmand/__init__.py diff --git a/src/integrations/osmand/api.py b/src/integrations/osmand/api.py new file mode 100644 index 0000000..9adeb25 --- /dev/null +++ b/src/integrations/osmand/api.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +import logging +from typing import TYPE_CHECKING, Any + +import httpx +from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus + +from integrations import IntegrationException +from utils import get_update_timestamp, value_in_range + +if TYPE_CHECKING: + from saic_ismart_client_ng.api.vehicle import VehicleStatusResp + from saic_ismart_client_ng.api.vehicle.schema import BasicVehicleStatus + from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp + from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus + +LOG = logging.getLogger(__name__) + + +class OsmAndApiException(IntegrationException): + def __init__(self, msg: str) -> None: + super().__init__(__name__, msg) + + +class OsmAndApiListener(ABC): + @abstractmethod + async def on_request( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: + pass + + @abstractmethod + async def on_response( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: + pass + + +class OsmAndApi: + def __init__( + self, + *, + server_uri: str, + device_id: str, + listener: OsmAndApiListener | None = None, + ) -> None: + self.__device_id = device_id + self.__listener = listener + self.__server_uri = server_uri + self.client = httpx.AsyncClient( + event_hooks={ + "request": [self.invoke_request_listener], + "response": [self.invoke_response_listener], + } + ) + + async def update_osmand( + self, + vehicle_status: VehicleStatusResp | None, + charge_info: ChrgMgmtDataResp | None, + ) -> tuple[bool, str]: + charge_mgmt_data = None if charge_info is None else charge_info.chrgMgmtData + charge_status = None if charge_info is None else charge_info.rvsChargeStatus + + if vehicle_status is not None: + # Request + data: dict[str, Any] = { + "id": self.__device_id, + # Guess the timestamp from either the API, GPS info or current machine time + "timestamp": int(get_update_timestamp(vehicle_status).timestamp()), + } + + basic_vehicle_status = vehicle_status.basicVehicleStatus + if basic_vehicle_status is not None: + data.update(self.__extract_basic_vehicle_status(basic_vehicle_status)) + + gps_position = vehicle_status.gpsPosition + if gps_position is not None: + data.update(self.__extract_gps_position(gps_position)) + + if charge_mgmt_data is not None: + if (soc := charge_mgmt_data.bmsPackSOCDsp) is not None: + data.update({"soc": (soc / 10.0)}) + + # Skip invalid current values reported by the API + is_valid_current = ( + charge_mgmt_data.bmsPackCrntV != 1 + and (raw_current := charge_mgmt_data.bmsPackCrnt) is not None + and value_in_range(raw_current, 0, 65535) + ) + if is_valid_current: + is_charging = ( + charge_status is not None + and charge_status.chargingGunState + and is_valid_current + and charge_mgmt_data.decoded_current is not None + and charge_mgmt_data.decoded_current < 0.0 + ) + data.update( + { + "power": charge_mgmt_data.decoded_power, + "voltage": charge_mgmt_data.decoded_voltage, + "current": charge_mgmt_data.decoded_current, + "is_charging": is_charging, + } + ) + + # Extract electric range if available + data.update( + self.__extract_electric_range(basic_vehicle_status, charge_status) + ) + + try: + response = await self.client.post(url=self.__server_uri, params=data) + await response.aread() + return True, response.text + except httpx.ConnectError as ece: + msg = f"Connection error: {ece}" + raise OsmAndApiException(msg) from ece + except httpx.TimeoutException as et: + msg = f"Timeout error {et}" + raise OsmAndApiException(msg) from et + except httpx.RequestError as e: + msg = f"{e}" + raise OsmAndApiException(msg) from e + except httpx.HTTPError as ehttp: + msg = f"HTTP error {ehttp}" + raise OsmAndApiException(msg) from ehttp + else: + return False, "OsmAnd request skipped because of missing configuration" + + @staticmethod + def __extract_basic_vehicle_status( + basic_vehicle_status: BasicVehicleStatus, + ) -> dict[str, Any]: + data: dict[str, Any] = { + "is_parked": basic_vehicle_status.is_parked, + } + + exterior_temperature = basic_vehicle_status.exteriorTemperature + if exterior_temperature is not None and value_in_range( + exterior_temperature, -127, 127 + ): + data["ext_temp"] = exterior_temperature + mileage = basic_vehicle_status.mileage + # Skip invalid range readings + if mileage is not None and value_in_range(mileage, 1, 2147483647): + # Data must be reported in meters + data["odometer"] = 100.0 * mileage + + if basic_vehicle_status.is_parked: + # We assume the vehicle is stationary, we will update it later from GPS if available + data["speed"] = (0.0,) + + return data + + @staticmethod + def __extract_gps_position(gps_position: GpsPosition) -> dict[str, Any]: + data: dict[str, Any] = {} + + # Do not use GPS data if it is not available + if gps_position.gps_status_decoded not in [GpsStatus.FIX_2D, GpsStatus.FIX_3d]: + return data + + way_point = gps_position.wayPoint + if way_point is None: + return data + + speed = way_point.speed + if speed is not None and value_in_range(speed, -999, 4500): + data["speed"] = speed / 10 + + heading = way_point.heading + if heading is not None and value_in_range(heading, 0, 360): + data["heading"] = heading + + position = way_point.position + if position is None: + return data + + altitude = position.altitude + if altitude is not None and value_in_range(altitude, -500, 8900): + data["altitude"] = altitude + + if (raw_lat := position.latitude) is not None and ( + raw_lon := position.longitude + ) is not None: + lat_degrees = raw_lat / 1000000.0 + lon_degrees = raw_lon / 1000000.0 + + if abs(lat_degrees) <= 90 and abs(lon_degrees) <= 180: + data.update( + { + "hdop": way_point.hdop, + "lat": lat_degrees, + "lon": lon_degrees, + } + ) + + return data + + def __extract_electric_range( + self, + basic_vehicle_status: BasicVehicleStatus | None, + charge_status: RvsChargeStatus | None, + ) -> dict[str, Any]: + data = {} + + range_elec_vehicle = 0.0 + if ( + basic_vehicle_status + and (fuel_range := basic_vehicle_status.fuelRangeElec) is not None + ): + range_elec_vehicle = self.__parse_electric_range(raw_value=fuel_range) + + range_elec_bms = 0.0 + if charge_status and (fuel_range := charge_status.fuelRangeElec) is not None: + range_elec_bms = self.__parse_electric_range(raw_value=fuel_range) + + range_elec = max(range_elec_vehicle, range_elec_bms) + if range_elec > 0: + data["est_battery_range"] = range_elec + + return data + + @staticmethod + def __parse_electric_range(raw_value: int) -> float: + if value_in_range(raw_value, 1, 20460): + return float(raw_value) / 10.0 + return 0.0 + + async def invoke_request_listener(self, request: httpx.Request) -> None: + if not self.__listener: + return + try: + body = None + if request.content: + try: + body = request.content.decode("utf-8") + except Exception as e: + LOG.warning(f"Error decoding request content: {e}") + + await self.__listener.on_request( + path=str(request.url).replace(self.__server_uri, "/"), + body=body, + headers=dict(request.headers), + ) + except Exception as e: + LOG.warning(f"Error invoking request listener: {e}", exc_info=e) + + async def invoke_response_listener(self, response: httpx.Response) -> None: + if not self.__listener: + return + try: + body = await response.aread() + decoded_body: str | None = None + if body: + try: + decoded_body = body.decode("utf-8") + except Exception as e: + LOG.warning(f"Error decoding request content: {e}") + + await self.__listener.on_response( + path=str(response.url).replace(self.__server_uri, "/"), + body=decoded_body, + headers=dict(response.headers), + ) + except Exception as e: + LOG.warning(f"Error invoking request listener: {e}", exc_info=e) diff --git a/src/log_config.py b/src/log_config.py new file mode 100644 index 0000000..9c96cae --- /dev/null +++ b/src/log_config.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import logging +import logging.config +import os +from typing import Any + +MODULES_DEFAULT_LOG_LEVEL = { + "asyncio": "WARNING", + "gmqtt": "WARNING", + "httpcore": "WARNING", + "httpx": "WARNING", + "saic_ismart_client_ng": "WARNING", + "tzlocal": "WARNING", +} + +MODULES_REPLACE_ENV_PREFIX = {"gmqtt": "MQTT"} + + +def get_default_log_level() -> str: + return os.getenv("LOG_LEVEL", "INFO").upper() + + +def debug_log_enabled() -> bool: + return get_default_log_level() == "DEBUG" + + +# Function to fetch module-specific log levels from environment +def get_module_log_level(module_name: str) -> str | None: + default_log_level = MODULES_DEFAULT_LOG_LEVEL.get(module_name) + env_prefix = MODULES_REPLACE_ENV_PREFIX.get( + module_name, module_name.upper().replace(".", "_") + ) + return os.getenv(f"{env_prefix}_LOG_LEVEL", default_log_level) + + +def setup_logging() -> None: + logger = logging.getLogger(__name__) + # Read the default log level from the environment + default_log_level = get_default_log_level() + + logging_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": { + "format": "%(asctime)s [%(levelname)s]: %(message)s - %(name)s", + }, + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "standard", + }, + }, + # Catch-all logger with a default level + "root": { + "handlers": ["console"], + "level": default_log_level, + }, + } + + # Dynamically add loggers based on modules loaded + loaded_modules = list(logging.Logger.manager.loggerDict.keys()) + logger.debug("Loaded modules: %s", loaded_modules) + modules_override: dict[str, Any] = {} + for module_name in loaded_modules: + module_log_level = get_module_log_level(module_name) + + if module_log_level is not None: + logger.debug(f"Loaded module {module_name} log level: {module_log_level}") + modules_override[module_name] = { + "level": module_log_level.upper(), + "propagate": True, + } + logging_config.update({"loggers": modules_override}) + + # Apply the logging configuration + logging.config.dictConfig(logging_config) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..976a12d --- /dev/null +++ b/src/main.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import asyncio +import faulthandler +import signal +import sys + +from configuration.parser import process_arguments +from mqtt_gateway import MqttGateway + +if __name__ == "__main__": + # Keep this at the top! + from log_config import debug_log_enabled, setup_logging + + setup_logging() + + # Enable fault handler to get a thread dump on SIGQUIT + faulthandler.enable(file=sys.stderr, all_threads=True) + if hasattr(faulthandler, "register"): + faulthandler.register(signal.SIGQUIT, chain=False) + configuration = process_arguments() + + mqtt_gateway = MqttGateway(configuration) + asyncio.run(mqtt_gateway.run(), debug=debug_log_enabled()) diff --git a/src/mqtt_gateway.py b/src/mqtt_gateway.py new file mode 100644 index 0000000..b522664 --- /dev/null +++ b/src/mqtt_gateway.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +import asyncio +from asyncio import Task +import logging +from random import uniform +from typing import TYPE_CHECKING, Any, override + +import apscheduler.schedulers.asyncio +from saic_ismart_client_ng import SaicApi +from saic_ismart_client_ng.api.vehicle.alarm import AlarmType +from saic_ismart_client_ng.model import SaicApiConfiguration + +from exceptions import MqttGatewayException +from handlers.message import MessageHandler +from handlers.relogin import ReloginHandler +from handlers.vehicle import VehicleHandler, VehicleHandlerLocator +import mqtt_topics +from publisher.core import MqttCommandListener, Publisher +from publisher.log_publisher import ConsolePublisher +from publisher.mqtt_publisher import MqttPublisher +from saic_api_listener import MqttGatewaySaicApiListener +from vehicle import VehicleState +from vehicle_info import VehicleInfo + +if TYPE_CHECKING: + from saic_ismart_client_ng.api.vehicle import VinInfo + + from configuration import Configuration + from integrations.openwb.charging_station import ChargingStation + +MSG_CMD_SUCCESSFUL = "Success" + +LOG = logging.getLogger(__name__) + + +class MqttGateway(MqttCommandListener, VehicleHandlerLocator): + def __init__(self, config: Configuration) -> None: + self.configuration = config + self.__vehicle_handlers: dict[str, VehicleHandler] = {} + self.publisher = self.__select_publisher() + self.publisher.command_listener = self + if config.publish_raw_api_data: + listener = MqttGatewaySaicApiListener(self.publisher) + else: + listener = None + + if not self.configuration.saic_user or not self.configuration.saic_password: + raise MqttGatewayException("Please configure saic username and password") + + self.saic_api = SaicApi( + configuration=SaicApiConfiguration( + username=self.configuration.saic_user, + password=self.configuration.saic_password, + username_is_email=config.username_is_email, + phone_country_code=config.saic_phone_country_code, + base_uri=self.configuration.saic_rest_uri, + region=self.configuration.saic_region, + tenant_id=self.configuration.saic_tenant_id, + read_timeout=self.configuration.saic_read_timeout, + ), + listener=listener, + ) + self.__scheduler = apscheduler.schedulers.asyncio.AsyncIOScheduler() + self.__relogin_handler = ReloginHandler( + relogin_relay=self.configuration.saic_relogin_delay, + api=self.saic_api, + scheduler=self.__scheduler, + ) + + def __select_publisher(self) -> Publisher: + if self.configuration.is_mqtt_enabled: + return MqttPublisher(self.configuration) + LOG.warning("MQTT support disabled") + return ConsolePublisher(self.configuration) + + async def run(self) -> None: + message_request_interval = self.configuration.messages_request_interval + await self.__do_initial_login(message_request_interval) + + LOG.info("Fetching vehicle list") + vin_list = await self.saic_api.vehicle_list() + + alarm_switches = list(AlarmType) + + for vin_info in vin_list.vinList: + await self.setup_vehicle(alarm_switches, vin_info) + message_handler = MessageHandler( + gateway=self, relogin_handler=self.__relogin_handler, saicapi=self.saic_api + ) + self.__scheduler.add_job( + func=message_handler.check_for_new_messages, + trigger="interval", + seconds=message_request_interval, + id="message_handler", + name="Check for new messages", + max_instances=1, + ) + LOG.info("Connecting to MQTT Broker") + await self.publisher.connect() + + LOG.info("Starting scheduler") + self.__scheduler.start() + + LOG.info("Entering main loop") + await self.__main_loop() + + async def __do_initial_login(self, message_request_interval: int) -> None: + while True: + try: + await self.__relogin_handler.login() + break + except Exception as e: + LOG.exception( + "Could not complete initial login to the SAIC API, retrying in %d seconds", + message_request_interval, + exc_info=e, + ) + await asyncio.sleep(message_request_interval) + + async def setup_vehicle( + self, alarm_switches: list[AlarmType], original_vin_info: VinInfo + ) -> None: + if not original_vin_info.vin: + LOG.error("Skipping vehicle setup due to no vin: %s", original_vin_info) + return + + total_battery_capacity = self.configuration.battery_capacity_map.get( + original_vin_info.vin, None + ) + + vin_info = VehicleInfo(original_vin_info, total_battery_capacity) + + try: + LOG.info( + f"Registering for {[x.name for x in alarm_switches]} messages. vin={vin_info.vin}" + ) + await self.saic_api.set_alarm_switches( + alarm_switches=alarm_switches, vin=vin_info.vin + ) + LOG.info( + f"Registered for {[x.name for x in alarm_switches]} messages. vin={vin_info.vin}" + ) + except Exception as e: + LOG.exception( + f"Failed to register for {[x.name for x in alarm_switches]} messages. vin={vin_info.vin}", + exc_info=e, + ) + raise SystemExit("Failed to register for API messages") from e + account_prefix = ( + f"{self.configuration.saic_user}/{mqtt_topics.VEHICLES}/{vin_info.vin}" + ) + charging_station = self.get_charging_station(vin_info.vin) + if charging_station and charging_station.soc_topic: + LOG.debug( + "SoC of %s for charging station will be published over MQTT topic: %s", + vin_info.vin, + charging_station.soc_topic, + ) + if charging_station and charging_station.range_topic: + LOG.debug( + "Range of %s for charging station will be published over MQTT topic: %s", + vin_info.vin, + charging_station.range_topic, + ) + vehicle_state = VehicleState( + self.publisher, + self.__scheduler, + account_prefix, + vin_info, + charging_station, + charge_polling_min_percent=self.configuration.charge_dynamic_polling_min_percentage, + ) + vehicle_handler = VehicleHandler( + self.configuration, + self.__relogin_handler, + self.saic_api, + self.publisher, # Gateway pointer + vin_info, + vehicle_state, + ) + self.vehicle_handlers[vin_info.vin] = vehicle_handler + + @override + def get_vehicle_handler(self, vin: str) -> VehicleHandler | None: + if vin in self.vehicle_handlers: + return self.vehicle_handlers[vin] + LOG.error(f"No vehicle handler found for VIN {vin}") + return None + + @property + @override + def vehicle_handlers(self) -> dict[str, VehicleHandler]: + return self.__vehicle_handlers + + @override + async def on_mqtt_command_received( + self, *, vin: str, topic: str, payload: str + ) -> None: + vehicle_handler = self.get_vehicle_handler(vin) + if vehicle_handler: + await vehicle_handler.handle_mqtt_command(topic=topic, payload=payload) + else: + LOG.debug(f"Command for unknown vin {vin} received") + + @override + async def on_charging_detected(self, vin: str) -> None: + vehicle_handler = self.get_vehicle_handler(vin) + if vehicle_handler: + # just make sure that we don't set the is_charging flag too early + # and that it is immediately overwritten by a running vehicle state request + await asyncio.sleep(delay=3.0) + vehicle_handler.vehicle_state.set_is_charging(True) + else: + LOG.debug(f"Charging detected for unknown vin {vin}") + + @override + async def on_mqtt_global_command_received( + self, *, topic: str, payload: str + ) -> None: + match topic: + case self.configuration.ha_lwt_topic: + if payload == "online": + for vin, vh in self.vehicle_handlers.items(): + # wait randomly between 0.1 and 10 seconds before sending discovery + await asyncio.sleep(uniform(0.1, 10.0)) # noqa: S311 + LOG.debug(f"Send HomeAssistant discovery for car {vin}") + vh.publish_ha_discovery_messages(force=True) + case _: + LOG.warning(f"Received unknown global command {topic}: {payload}") + + def get_charging_station(self, vin: str) -> ChargingStation | None: + if vin in self.configuration.charging_stations_by_vin: + return self.configuration.charging_stations_by_vin[vin] + return None + + async def __main_loop(self) -> None: + tasks = [] + for key, vh in self.vehicle_handlers.items(): + LOG.info(f"Starting process for car {key}") + task = asyncio.create_task( + vh.handle_vehicle(), name=f"handle_vehicle_{key}" + ) + tasks.append(task) + + await self.__shutdown_handler(tasks) + + @staticmethod + async def __shutdown_handler(tasks: list[Task[Any]]) -> None: + while True: + done, pending = await asyncio.wait( + tasks, return_when=asyncio.FIRST_COMPLETED + ) + for task in done: + task_name = task.get_name() + if task.cancelled(): + LOG.debug( + f"{task_name!r} task was cancelled, this is only supposed if the application is " + "shutting down" + ) + else: + exception = task.exception() + if exception is not None: + LOG.exception( + f"{task_name!r} task crashed with an exception", + exc_info=exception, + ) + raise SystemExit(-1) + LOG.warning( + f"{task_name!r} task terminated cleanly with result={task.result()}" + ) + if len(pending) == 0: + break + LOG.warning( + f"There are still {len(pending)} tasks... waiting for them to complete" + ) diff --git a/src/mqtt_topics.py b/src/mqtt_topics.py new file mode 100644 index 0000000..38234ca --- /dev/null +++ b/src/mqtt_topics.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +SET_SUFFIX = "set" +RESULT_SUFFIX = "result" + +AVAILABLE = "available" + +CLIMATE = "climate" +CLIMATE_BACK_WINDOW_HEAT = CLIMATE + "/rearWindowDefrosterHeating" +CLIMATE_BACK_WINDOW_HEAT_SET = CLIMATE_BACK_WINDOW_HEAT + "/" + SET_SUFFIX +CLIMATE_FRONT_WINDOW_HEAT = CLIMATE + "/frontWindowDefrosterHeating" +CLIMATE_FRONT_WINDOW_HEAT_SET = CLIMATE_FRONT_WINDOW_HEAT + "/" + SET_SUFFIX +CLIMATE_EXTERIOR_TEMPERATURE = CLIMATE + "/exteriorTemperature" +CLIMATE_INTERIOR_TEMPERATURE = CLIMATE + "/interiorTemperature" +CLIMATE_REMOTE_CLIMATE_STATE = CLIMATE + "/remoteClimateState" +CLIMATE_REMOTE_CLIMATE_STATE_SET = CLIMATE_REMOTE_CLIMATE_STATE + "/" + SET_SUFFIX +CLIMATE_REMOTE_TEMPERATURE = CLIMATE + "/remoteTemperature" +CLIMATE_REMOTE_TEMPERATURE_SET = CLIMATE_REMOTE_TEMPERATURE + "/" + SET_SUFFIX +CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL = CLIMATE + "/heatedSeatsFrontLeftLevel" +CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL_SET = ( + CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL + "/" + SET_SUFFIX +) +CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL = CLIMATE + "/heatedSeatsFrontRightLevel" +CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL_SET = ( + CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL + "/" + SET_SUFFIX +) + +WINDOWS = "windows" +WINDOWS_DRIVER = WINDOWS + "/driver" +WINDOWS_PASSENGER = WINDOWS + "/passenger" +WINDOWS_REAR_LEFT = WINDOWS + "/rearLeft" +WINDOWS_REAR_RIGHT = WINDOWS + "/rearRight" +WINDOWS_SUN_ROOF = WINDOWS + "/sunRoof" + +DOORS = "doors" +DOORS_BONNET = DOORS + "/bonnet" +DOORS_BOOT = DOORS + "/boot" +DOORS_BOOT_SET = DOORS_BOOT + "/" + SET_SUFFIX +DOORS_DRIVER = DOORS + "/driver" +DOORS_LOCKED = DOORS + "/locked" +DOORS_LOCKED_SET = DOORS_LOCKED + "/" + SET_SUFFIX +DOORS_PASSENGER = DOORS + "/passenger" +DOORS_REAR_LEFT = DOORS + "/rearLeft" +DOORS_REAR_RIGHT = DOORS + "/rearRight" + +LIGHTS = "lights" +LIGHTS_MAIN_BEAM = LIGHTS + "/mainBeam" +LIGHTS_DIPPED_BEAM = LIGHTS + "/dippedBeam" +LIGHTS_SIDE = LIGHTS + "/side" + +DRIVETRAIN = "drivetrain" +DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE = DRIVETRAIN + "/auxiliaryBatteryVoltage" +DRIVETRAIN_CHARGER_CONNECTED = DRIVETRAIN + "/chargerConnected" +DRIVETRAIN_CHARGING = DRIVETRAIN + "/charging" +DRIVETRAIN_CHARGING_SET = DRIVETRAIN_CHARGING + "/" + SET_SUFFIX +DRIVETRAIN_CHARGING_STOP_REASON = DRIVETRAIN + "/chargingStopReason" +DRIVETRAIN_CHARGING_LAST_START = DRIVETRAIN_CHARGING + "/lastStart" +DRIVETRAIN_CHARGING_LAST_END = DRIVETRAIN_CHARGING + "/lastEnd" +DRIVETRAIN_BATTERY_HEATING = DRIVETRAIN + "/batteryHeating" +DRIVETRAIN_BATTERY_HEATING_SET = DRIVETRAIN_BATTERY_HEATING + "/" + SET_SUFFIX +DRIVETRAIN_BATTERY_HEATING_STOP_REASON = DRIVETRAIN + "/batteryHeatingStopReason" +DRIVETRAIN_CHARGING_SCHEDULE = DRIVETRAIN + "/chargingSchedule" +DRIVETRAIN_CHARGING_SCHEDULE_SET = DRIVETRAIN_CHARGING_SCHEDULE + "/" + SET_SUFFIX +DRIVETRAIN_BATTERY_HEATING_SCHEDULE = DRIVETRAIN + "/batteryHeatingSchedule" +DRIVETRAIN_BATTERY_HEATING_SCHEDULE_SET = ( + DRIVETRAIN_BATTERY_HEATING_SCHEDULE + "/" + SET_SUFFIX +) +DRIVETRAIN_CHARGING_TYPE = DRIVETRAIN + "/chargingType" +DRIVETRAIN_CURRENT = DRIVETRAIN + "/current" +DRIVETRAIN_HV_BATTERY_ACTIVE = DRIVETRAIN + "/hvBatteryActive" +DRIVETRAIN_HV_BATTERY_ACTIVE_SET = DRIVETRAIN_HV_BATTERY_ACTIVE + "/" + SET_SUFFIX +DRIVETRAIN_MILEAGE = DRIVETRAIN + "/mileage" +DRIVETRAIN_MILEAGE_OF_DAY = DRIVETRAIN + "/mileageOfTheDay" +DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE = DRIVETRAIN + "/mileageSinceLastCharge" +DRIVETRAIN_POWER = DRIVETRAIN + "/power" +DRIVETRAIN_POWER_USAGE_OF_DAY = DRIVETRAIN + "/powerUsageOfDay" +DRIVETRAIN_POWER_USAGE_SINCE_LAST_CHARGE = DRIVETRAIN + "/powerUsageSinceLastCharge" +DRIVETRAIN_RANGE = DRIVETRAIN + "/range" +DRIVETRAIN_RUNNING = DRIVETRAIN + "/running" +DRIVETRAIN_REMAINING_CHARGING_TIME = DRIVETRAIN + "/remainingChargingTime" +DRIVETRAIN_HYBRID_ELECTRICAL_RANGE = DRIVETRAIN + "/hybrid_electrical_range" +DRIVETRAIN_SOC = DRIVETRAIN + "/soc" +DRIVETRAIN_SOC_TARGET = DRIVETRAIN + "/socTarget" +DRIVETRAIN_SOC_TARGET_SET = DRIVETRAIN_SOC_TARGET + "/" + SET_SUFFIX +DRIVETRAIN_CHARGECURRENT_LIMIT = DRIVETRAIN + "/chargeCurrentLimit" +DRIVETRAIN_CHARGECURRENT_LIMIT_SET = DRIVETRAIN_CHARGECURRENT_LIMIT + "/" + SET_SUFFIX +DRIVETRAIN_SOC_KWH = DRIVETRAIN + "/soc_kwh" +DRIVETRAIN_LAST_CHARGE_ENDING_POWER = DRIVETRAIN + "/lastChargeEndingPower" +DRIVETRAIN_TOTAL_BATTERY_CAPACITY = DRIVETRAIN + "/totalBatteryCapacity" +DRIVETRAIN_VOLTAGE = DRIVETRAIN + "/voltage" +DRIVETRAIN_CHARGING_CABLE_LOCK = DRIVETRAIN + "/chargingCableLock" +DRIVETRAIN_CHARGING_CABLE_LOCK_SET = DRIVETRAIN_CHARGING_CABLE_LOCK + "/" + SET_SUFFIX +DRIVETRAIN_CURRENT_JOURNEY = DRIVETRAIN + "/currentJourney" +DRIVETRAIN_FOSSIL_FUEL = DRIVETRAIN + "/fossilFuel" +DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE = DRIVETRAIN_FOSSIL_FUEL + "/percentage" +DRIVETRAIN_FOSSIL_FUEL_RANGE = DRIVETRAIN_FOSSIL_FUEL + "/range" + +OBC = "obc" +OBC_CURRENT = OBC + "/current" +OBC_VOLTAGE = OBC + "/voltage" +OBC_POWER_SINGLE_PHASE = OBC + "/powerSinglePhase" +OBC_POWER_THREE_PHASE = OBC + "/powerThreePhase" + +CCU = "ccu" +CCU_ONBOARD_PLUG_STATUS = CCU + "/onboardChargerPlugStatus" +CCU_OFFBOARD_PLUG_STATUS = CCU + "/offboardChargerPlugStatus" + +BMS = "bms" +BMS_CHARGE_STATUS = BMS + "/chargeStatus" + +INFO = "info" +INFO_BRAND = INFO + "/brand" +INFO_MODEL = INFO + "/model" +INFO_YEAR = INFO + "/year" +INFO_SERIES = INFO + "/series" +INFO_COLOR = INFO + "/color" +INFO_CONFIGURATION = INFO + "/configuration" +INFO_LAST_MESSAGE = INFO + "/lastMessage" +INFO_LAST_MESSAGE_ID = INFO_LAST_MESSAGE + "/messageId" +INFO_LAST_MESSAGE_TYPE = INFO_LAST_MESSAGE + "/messageType" +INFO_LAST_MESSAGE_TITLE = INFO_LAST_MESSAGE + "/title" +INFO_LAST_MESSAGE_TIME = INFO_LAST_MESSAGE + "/messageTime" +INFO_LAST_MESSAGE_SENDER = INFO_LAST_MESSAGE + "/sender" +INFO_LAST_MESSAGE_CONTENT = INFO_LAST_MESSAGE + "/content" +INFO_LAST_MESSAGE_STATUS = INFO_LAST_MESSAGE + "/status" +INFO_LAST_MESSAGE_VIN = INFO_LAST_MESSAGE + "/vin" + +INTERNAL = "_internal" +INTERNAL_API = INTERNAL + "/api" +INTERNAL_LWT = INTERNAL + "/lwt" +INTERNAL_ABRP = INTERNAL + "/abrp" +INTERNAL_OSMAND = INTERNAL + "/osmand" +INTERNAL_CONFIGURATION_RAW = INTERNAL + "/configuration/raw" + +LOCATION = "location" +LOCATION_POSITION = LOCATION + "/position" +LOCATION_HEADING = LOCATION + "/heading" +LOCATION_LATITUDE = LOCATION + "/latitude" +LOCATION_LONGITUDE = LOCATION + "/longitude" +LOCATION_SPEED = LOCATION + "/speed" +LOCATION_ELEVATION = LOCATION + "/elevation" +LOCATION_FIND_MY_CAR = LOCATION + "/findMyCar" +LOCATION_FIND_MY_CAR_SET = LOCATION_FIND_MY_CAR + "/" + SET_SUFFIX + +REFRESH = "refresh" +REFRESH_LAST_ACTIVITY = REFRESH + "/lastActivity" +REFRESH_LAST_CHARGE_STATE = REFRESH + "/lastChargeState" +REFRESH_LAST_VEHICLE_STATE = REFRESH + "/lastVehicleState" +REFRESH_LAST_ERROR = REFRESH + "/lastError" +REFRESH_MODE = REFRESH + "/mode" +REFRESH_MODE_SET = REFRESH_MODE + "/" + SET_SUFFIX +REFRESH_PERIOD = REFRESH + "/period" +REFRESH_PERIOD_ACTIVE = REFRESH_PERIOD + "/active" +REFRESH_PERIOD_ACTIVE_SET = REFRESH_PERIOD_ACTIVE + "/" + SET_SUFFIX +REFRESH_PERIOD_CHARGING = REFRESH_PERIOD + "/charging" +REFRESH_PERIOD_INACTIVE = REFRESH_PERIOD + "/inActive" +REFRESH_PERIOD_INACTIVE_SET = REFRESH_PERIOD_INACTIVE + "/" + SET_SUFFIX +REFRESH_PERIOD_AFTER_SHUTDOWN = REFRESH_PERIOD + "/afterShutdown" +REFRESH_PERIOD_AFTER_SHUTDOWN_SET = REFRESH_PERIOD_AFTER_SHUTDOWN + "/" + SET_SUFFIX +REFRESH_PERIOD_INACTIVE_GRACE = REFRESH_PERIOD + "/inActiveGrace" +REFRESH_PERIOD_INACTIVE_GRACE_SET = REFRESH_PERIOD_INACTIVE_GRACE + "/" + SET_SUFFIX +REFRESH_PERIOD_ERROR = REFRESH_PERIOD + "/error" + +TYRES = "tyres" +TYRES_FRONT_LEFT_PRESSURE = TYRES + "/frontLeftPressure" +TYRES_FRONT_RIGHT_PRESSURE = TYRES + "/frontRightPressure" +TYRES_REAR_LEFT_PRESSURE = TYRES + "/rearLeftPressure" +TYRES_REAR_RIGHT_PRESSURE = TYRES + "/rearRightPressure" + +VEHICLES = "vehicles" diff --git a/publisher/__init__.py b/src/publisher/__init__.py similarity index 100% rename from publisher/__init__.py rename to src/publisher/__init__.py diff --git a/src/publisher/core.py b/src/publisher/core.py new file mode 100644 index 0000000..1c53bee --- /dev/null +++ b/src/publisher/core.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +import json +import re +from typing import TYPE_CHECKING, Any, TypeVar + +import mqtt_topics + +if TYPE_CHECKING: + from configuration import Configuration + +T = TypeVar("T") + + +class MqttCommandListener(ABC): + @abstractmethod + async def on_mqtt_command_received( + self, *, vin: str, topic: str, payload: str + ) -> None: + raise NotImplementedError("Should have implemented this") + + @abstractmethod + async def on_charging_detected(self, vin: str) -> None: + raise NotImplementedError("Should have implemented this") + + @abstractmethod + async def on_mqtt_global_command_received( + self, *, topic: str, payload: str + ) -> None: + raise NotImplementedError("Should have implemented this") + + +class Publisher(ABC): + def __init__(self, config: Configuration) -> None: + self.__configuration = config + self.__command_listener: MqttCommandListener | None = None + if config.mqtt_allow_dots_in_topic: + self.__invalid_mqtt_chars = re.compile(r"[+#*$>]") + else: + self.__invalid_mqtt_chars = re.compile(r"[+#*$>.]") + self.__topic_root = self.__remove_special_mqtt_characters(config.mqtt_topic) + + @abstractmethod + async def connect(self) -> None: + pass + + @abstractmethod + def is_connected(self) -> bool: + raise NotImplementedError + + @abstractmethod + def publish_json( + self, key: str, data: dict[str, Any], no_prefix: bool = False + ) -> None: + raise NotImplementedError + + @abstractmethod + def publish_str(self, key: str, value: str, no_prefix: bool = False) -> None: + raise NotImplementedError + + @abstractmethod + def publish_int(self, key: str, value: int, no_prefix: bool = False) -> None: + raise NotImplementedError + + @abstractmethod + def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: + raise NotImplementedError + + @abstractmethod + def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: + raise NotImplementedError + + def get_mqtt_account_prefix(self) -> str: + return self.__remove_special_mqtt_characters( + f"{self.__topic_root}/{self.configuration.saic_user}" + ) + + def get_topic(self, key: str, no_prefix: bool) -> str: + topic = key if no_prefix else f"{self.__topic_root}/{key}" + return self.__remove_special_mqtt_characters(topic) + + def __remove_special_mqtt_characters(self, input_str: str) -> str: + return self.__invalid_mqtt_chars.sub("_", input_str) + + def __remove_byte_strings(self, data: dict[str, Any]) -> dict[str, Any]: + for key in data: # noqa: PLC0206 + if isinstance(data[key], bytes): + data[key] = str(data[key]) + elif isinstance(data[key], dict): + data[key] = self.__remove_byte_strings(data[key]) + elif isinstance(data[key], list): + for item in data[key]: + if isinstance(item, dict): + self.__remove_byte_strings(item) + return data + + def __anonymize(self, data: T) -> T: + if isinstance(data, dict): + for key in data: + if isinstance(data[key], str): + match key: + case "password": + data[key] = "******" + case ( + "uid" + | "email" + | "user_name" + | "account" + | "ping" + | "token" + | "access_token" + | "refreshToken" + | "refresh_token" + | "vin" + ): + data[key] = Publisher.anonymize_str(data[key]) + case "deviceId": + data[key] = self.anonymize_device_id(data[key]) + case ( + "seconds" + | "bindTime" + | "eventCreationTime" + | "latitude" + | "longitude" + ): + data[key] = Publisher.anonymize_int(data[key]) + case ( + "eventID" + | "event-id" + | "event_id" + | "eventId" + | "event_id" + | "eventID" + | "lastKeySeen" + ): + data[key] = 9999 + case "content": + data[key] = re.sub( + "\\(\\*\\*\\*...\\)", "(***XXX)", data[key] + ) + elif isinstance(data[key], dict): + data[key] = self.__anonymize(data[key]) + elif isinstance(data[key], list | set | tuple): + data[key] = [self.__anonymize(item) for item in data[key]] + return data + + def keepalive(self) -> None: + self.publish_str(mqtt_topics.INTERNAL_LWT, "online", False) + + @staticmethod + def anonymize_str(value: str) -> str: + r = re.sub("[a-zA-Z]", "X", value) + return re.sub("[1-9]", "9", r) + + def anonymize_device_id(self, device_id: str) -> str: + elements = device_id.split("###") + return f"{self.anonymize_str(elements[0])}###{self.anonymize_str(elements[1])}" + + @staticmethod + def anonymize_int(value: int) -> int: + return int(value / 100000 * 100000) + + def dict_to_anonymized_json(self, data: dict[str, Any]) -> str: + no_binary_strings = self.__remove_byte_strings(data) + if self.configuration.anonymized_publishing: + result = self.__anonymize(no_binary_strings) + else: + result = no_binary_strings + return json.dumps(result, indent=2) + + @property + def configuration(self) -> Configuration: + return self.__configuration + + @property + def command_listener(self) -> MqttCommandListener | None: + return self.__command_listener + + @command_listener.setter + def command_listener(self, listener: MqttCommandListener) -> None: + self.__command_listener = listener diff --git a/publisher/log_publisher.py b/src/publisher/log_publisher.py similarity index 67% rename from publisher/log_publisher.py rename to src/publisher/log_publisher.py index 92b97e8..3299ebc 100644 --- a/publisher/log_publisher.py +++ b/src/publisher/log_publisher.py @@ -1,7 +1,8 @@ +from __future__ import annotations + import logging -from typing import override +from typing import Any, override -from configuration import Configuration from publisher.core import Publisher LOG = logging.getLogger(__name__) @@ -9,15 +10,18 @@ class ConsolePublisher(Publisher): - def __init__(self, configuration: Configuration): - super().__init__(configuration) + @override + async def connect(self) -> None: + pass @override def is_connected(self) -> bool: return True @override - def publish_json(self, key: str, data: dict, no_prefix: bool = False) -> None: + def publish_json( + self, key: str, data: dict[str, Any], no_prefix: bool = False + ) -> None: anonymized_json = self.dict_to_anonymized_json(data) self.internal_publish(key, anonymized_json) @@ -31,15 +35,11 @@ def publish_int(self, key: str, value: int, no_prefix: bool = False) -> None: @override def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: - if value is None: - value = False - elif isinstance(value, int): - value = value == 1 self.internal_publish(key, value) @override def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: self.internal_publish(key, value) - def internal_publish(self, key, value): - LOG.debug(f'{key}: {value}') + def internal_publish(self, key: str, value: Any) -> None: + LOG.debug(f"{key}: {value}") diff --git a/src/publisher/mqtt_publisher.py b/src/publisher/mqtt_publisher.py new file mode 100644 index 0000000..98c0e7e --- /dev/null +++ b/src/publisher/mqtt_publisher.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import logging +import ssl +from typing import TYPE_CHECKING, Any, Final, cast, override + +import gmqtt + +import mqtt_topics +from publisher.core import Publisher + +if TYPE_CHECKING: + from configuration import Configuration + from integrations.openwb.charging_station import ChargingStation + +LOG = logging.getLogger(__name__) + + +class MqttPublisher(Publisher): + def __init__(self, configuration: Configuration) -> None: + super().__init__(configuration) + self.publisher_id = configuration.mqtt_client_id + self.host = self.configuration.mqtt_host + self.port = self.configuration.mqtt_port + self.transport_protocol = self.configuration.mqtt_transport_protocol + self.vin_by_charge_state_topic: dict[str, str] = {} + self.last_charge_state_by_vin: dict[str, str] = {} + self.vin_by_charger_connected_topic: dict[str, str] = {} + + mqtt_client = gmqtt.Client( + client_id=str(self.publisher_id), + transport=self.transport_protocol.transport_mechanism, + will_message=gmqtt.Message( + topic=self.get_topic(mqtt_topics.INTERNAL_LWT, False), + payload="offline", + retain=True, + ), + ) + mqtt_client.on_connect = self.__on_connect + mqtt_client.on_message = self.__on_message + self.client: Final[gmqtt.Client] = mqtt_client + + @override + async def connect(self) -> None: + if self.configuration.mqtt_user is not None: + if self.configuration.mqtt_password is not None: + self.client.set_auth_credentials( + username=self.configuration.mqtt_user, + password=self.configuration.mqtt_password, + ) + else: + self.client.set_auth_credentials(username=self.configuration.mqtt_user) + if self.transport_protocol.with_tls: + cert_uri = self.configuration.tls_server_cert_path + LOG.debug( + f"Configuring network encryption and authentication options for MQTT using {cert_uri}" + ) + ssl_context = ssl.SSLContext() + ssl_context.load_verify_locations(cafile=cert_uri) + ssl_context.check_hostname = False + else: + ssl_context = None + await self.client.connect( + host=self.host, + port=self.port, + version=gmqtt.constants.MQTTv311, + ssl=ssl_context, + ) + + def __on_connect( + self, _client: Any, _flags: Any, rc: int, _properties: Any + ) -> None: + if rc == gmqtt.constants.CONNACK_ACCEPTED: + LOG.info("Connected to MQTT broker") + mqtt_account_prefix = self.get_mqtt_account_prefix() + self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/{mqtt_topics.SET_SUFFIX}" + ) + self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/+/{mqtt_topics.SET_SUFFIX}" + ) + self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/{mqtt_topics.SET_SUFFIX}" + ) + self.client.subscribe( + f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/{mqtt_topics.SET_SUFFIX}" + ) + for ( + charging_station + ) in self.configuration.charging_stations_by_vin.values(): + LOG.debug( + f"Subscribing to MQTT topic {charging_station.charge_state_topic}" + ) + self.vin_by_charge_state_topic[charging_station.charge_state_topic] = ( + charging_station.vin + ) + self.client.subscribe(charging_station.charge_state_topic) + if charging_station.connected_topic: + LOG.debug( + f"Subscribing to MQTT topic {charging_station.connected_topic}" + ) + self.vin_by_charger_connected_topic[ + charging_station.connected_topic + ] = charging_station.vin + self.client.subscribe(charging_station.connected_topic) + if self.configuration.ha_discovery_enabled: + # enable dynamic discovery pushing in case ha reconnects + self.client.subscribe(self.configuration.ha_lwt_topic) + self.keepalive() + else: + if rc == gmqtt.constants.CONNACK_REFUSED_BAD_USERNAME_PASSWORD: + LOG.error( + f"MQTT connection error: bad username or password. Return code {rc}" + ) + elif rc == gmqtt.constants.CONNACK_REFUSED_PROTOCOL_VERSION: + LOG.error( + f"MQTT connection error: refused protocol version. Return code {rc}" + ) + else: + LOG.error(f"MQTT connection error.Return code {rc}") + msg = f"Unable to connect to MQTT broker. Return code: {rc}" + raise SystemExit(msg) + + async def __on_message( + self, _client: Any, topic: str, payload: Any, _qos: Any, _properties: Any + ) -> None: + try: + if isinstance(payload, bytes): + payload = payload.decode("utf-8") + else: + payload = str(payload) + await self.__on_message_real(topic=topic, payload=payload) + except Exception as e: + LOG.exception(f"Error while processing MQTT message: {e}") + + async def __on_message_real(self, *, topic: str, payload: str) -> None: + if topic in self.vin_by_charge_state_topic: + LOG.debug(f"Received message over topic {topic} with payload {payload}") + vin = self.vin_by_charge_state_topic[topic] + charging_station = self.configuration.charging_stations_by_vin[vin] + if self.should_force_refresh(payload, charging_station): + LOG.info( + f"Vehicle with vin {vin} is charging. Setting refresh mode to force" + ) + if self.command_listener is not None: + await self.command_listener.on_charging_detected(vin) + elif topic in self.vin_by_charger_connected_topic: + LOG.debug(f"Received message over topic {topic} with payload {payload}") + vin = self.vin_by_charger_connected_topic[topic] + charging_station = self.configuration.charging_stations_by_vin[vin] + if payload == charging_station.connected_value: + LOG.debug( + f"Vehicle with vin {vin} is connected to its charging station" + ) + else: + LOG.debug( + f"Vehicle with vin {vin} is disconnected from its charging station" + ) + elif topic == self.configuration.ha_lwt_topic: + if self.command_listener is not None: + await self.command_listener.on_mqtt_global_command_received( + topic=topic, payload=payload + ) + else: + vin = self.get_vin_from_topic(topic) + if self.command_listener is not None: + await self.command_listener.on_mqtt_command_received( + vin=vin, topic=topic, payload=payload + ) + + def __publish(self, topic: str, payload: Any) -> None: + self.client.publish(topic, payload, retain=True) + + @override + def is_connected(self) -> bool: + return cast(bool, self.client.is_connected) + + @override + def publish_json( + self, key: str, data: dict[str, Any], no_prefix: bool = False + ) -> None: + payload = self.dict_to_anonymized_json(data) + self.__publish(topic=self.get_topic(key, no_prefix), payload=payload) + + @override + def publish_str(self, key: str, value: str, no_prefix: bool = False) -> None: + self.__publish(topic=self.get_topic(key, no_prefix), payload=value) + + @override + def publish_int(self, key: str, value: int, no_prefix: bool = False) -> None: + self.__publish(topic=self.get_topic(key, no_prefix), payload=value) + + @override + def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: + self.__publish(topic=self.get_topic(key, no_prefix), payload=value) + + @override + def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: + self.__publish(topic=self.get_topic(key, no_prefix), payload=value) + + def get_vin_from_topic(self, topic: str) -> str: + global_topic_removed = topic[len(self.configuration.mqtt_topic) + 1 :] + elements = global_topic_removed.split("/") + return elements[2] + + def should_force_refresh( + self, current_charging_value: str, charging_station: ChargingStation + ) -> bool: + vin = charging_station.vin + last_charging_value: str | None = None + if vin in self.last_charge_state_by_vin: + last_charging_value = self.last_charge_state_by_vin[vin] + self.last_charge_state_by_vin[vin] = current_charging_value + + if last_charging_value: + if last_charging_value == current_charging_value: + LOG.debug( + "Last charging value equals current charging value. No refresh needed." + ) + return False + LOG.info( + f"Charging value has changed from {last_charging_value} to {current_charging_value}." + ) + return True + return True diff --git a/saic_api_listener.py b/src/saic_api_listener.py similarity index 50% rename from saic_api_listener.py rename to src/saic_api_listener.py index ce40b8e..d880b13 100644 --- a/saic_api_listener.py +++ b/src/saic_api_listener.py @@ -1,26 +1,33 @@ +from __future__ import annotations + import json import logging -from abc import ABC -from typing import Optional, override -from urllib.parse import parse_qs -from urllib.parse import urlparse +from typing import TYPE_CHECKING, Any, override +from urllib.parse import parse_qs, urlparse from saic_ismart_client_ng.listener import SaicApiListener from integrations.abrp.api import AbrpApiListener from integrations.osmand.api import OsmAndApiListener -from mqtt_topics import INTERNAL_API, INTERNAL_ABRP, INTERNAL_OSMAND -from publisher.core import Publisher +from mqtt_topics import INTERNAL_ABRP, INTERNAL_API, INTERNAL_OSMAND + +if TYPE_CHECKING: + from publisher.core import Publisher LOG = logging.getLogger(__name__) -class MqttGatewayListenerApiListener(ABC): - def __init__(self, publisher: Publisher, topic_prefix: str): +class MqttGatewayListenerApiListener: + def __init__(self, publisher: Publisher, topic_prefix: str) -> None: self.__publisher = publisher self.__topic_prefix = topic_prefix - async def publish_request(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + async def publish_request( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: parsed_url = urlparse(path) query_string = parse_qs(parsed_url.query) if body: @@ -32,15 +39,19 @@ async def publish_request(self, path: str, body: Optional[str] = None, headers: "path": parsed_url.path, "query": query_string, "body": body, - "headers": headers + "headers": headers, } topic = parsed_url.path.strip("/") self.__internal_publish( - key=self.__topic_prefix + "/" + topic + "/request", - data=json_message + key=self.__topic_prefix + "/" + topic + "/request", data=json_message ) - async def publish_response(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + async def publish_response( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: parsed_url = urlparse(path) query_string = parse_qs(parsed_url.query) if body: @@ -52,58 +63,86 @@ async def publish_response(self, path: str, body: Optional[str] = None, headers: "path": parsed_url.path, "query": query_string, "body": body, - "headers": headers + "headers": headers, } topic = parsed_url.path.strip("/") self.__internal_publish( - key=self.__topic_prefix + "/" + topic + "/response", - data=json_message + key=self.__topic_prefix + "/" + topic + "/response", data=json_message ) - def __internal_publish(self, *, key: str, data: dict): + def __internal_publish(self, *, key: str, data: dict[str, Any]) -> None: if self.__publisher and self.__publisher.is_connected(): - self.__publisher.publish_json( - key=key, - data=data - ) + self.__publisher.publish_json(key=key, data=data) else: - LOG.info(f"Not publishing API response to MQTT since publisher is not connected. {data}") + LOG.info( + f"Not publishing API response to MQTT since publisher is not connected. {data}" + ) class MqttGatewayOsmAndListener(OsmAndApiListener, MqttGatewayListenerApiListener): - def __init__(self, publisher: Publisher): + def __init__(self, publisher: Publisher) -> None: super().__init__(publisher, INTERNAL_OSMAND) @override - async def on_request(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + async def on_request( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: await self.publish_request(path, body, headers) @override - async def on_response(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + async def on_response( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: await self.publish_response(path, body, headers) class MqttGatewayAbrpListener(AbrpApiListener, MqttGatewayListenerApiListener): - def __init__(self, publisher: Publisher): + def __init__(self, publisher: Publisher) -> None: super().__init__(publisher, INTERNAL_ABRP) @override - async def on_request(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + async def on_request( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: await self.publish_request(path, body, headers) @override - async def on_response(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + async def on_response( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: await self.publish_response(path, body, headers) class MqttGatewaySaicApiListener(SaicApiListener, MqttGatewayListenerApiListener): - def __init__(self, publisher: Publisher): + def __init__(self, publisher: Publisher) -> None: super().__init__(publisher, INTERNAL_API) @override - async def on_request(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + async def on_request( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: await self.publish_request(path, body, headers) @override - async def on_response(self, path: str, body: Optional[str] = None, headers: Optional[dict] = None): + async def on_response( + self, + path: str, + body: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: await self.publish_response(path, body, headers) diff --git a/src/status_publisher/__init__.py b/src/status_publisher/__init__.py new file mode 100644 index 0000000..620ad60 --- /dev/null +++ b/src/status_publisher/__init__.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any, Final, TypeVar + +from utils import datetime_to_str + +if TYPE_CHECKING: + from collections.abc import Callable + + from publisher.core import Publisher + from vehicle_info import VehicleInfo + +T = TypeVar("T") +Publishable = TypeVar("Publishable", str, int, float, bool, dict[str, Any], datetime) + + +class VehicleDataPublisher: + def __init__( + self, vin: VehicleInfo, publisher: Publisher, mqtt_vehicle_prefix: str + ) -> None: + self._vehicle_info: Final[VehicleInfo] = vin + self.__publisher: Final[Publisher] = publisher + self.__mqtt_vehicle_prefix: Final[str] = mqtt_vehicle_prefix + + def _publish( + self, + *, + topic: str, + value: Publishable | None, + validator: Callable[[Publishable], bool] = lambda _: True, + no_prefix: bool = False, + ) -> tuple[bool, Publishable | None]: + if value is None or not validator(value): + return False, None + actual_topic = topic if no_prefix else self.__get_topic(topic) + published = self._publish_directly(topic=actual_topic, value=value) + return published, value + + def _transform_and_publish( + self, + *, + topic: str, + value: T | None, + validator: Callable[[T], bool] = lambda _: True, + transform: Callable[[T], Publishable], + no_prefix: bool = False, + ) -> tuple[bool, Publishable | None]: + if value is None or not validator(value): + return False, None + actual_topic = topic if no_prefix else self.__get_topic(topic) + transformed_value = transform(value) + published = self._publish_directly(topic=actual_topic, value=transformed_value) + return published, transformed_value + + def _publish_directly(self, *, topic: str, value: Publishable) -> bool: + published = False + if isinstance(value, bool): + self.__publisher.publish_bool(topic, value) + published = True + elif isinstance(value, int): + self.__publisher.publish_int(topic, value) + published = True + elif isinstance(value, float): + self.__publisher.publish_float(topic, value) + published = True + elif isinstance(value, str): + self.__publisher.publish_str(topic, value) + published = True + elif isinstance(value, dict): + self.__publisher.publish_json(topic, value) + published = True + elif isinstance(value, datetime): + self.__publisher.publish_str(topic, datetime_to_str(value)) + published = True + return published + + def __get_topic(self, sub_topic: str) -> str: + return f"{self.__mqtt_vehicle_prefix}/{sub_topic}" diff --git a/src/status_publisher/charge/__init__.py b/src/status_publisher/charge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/status_publisher/charge/chrg_mgmt_data.py b/src/status_publisher/charge/chrg_mgmt_data.py new file mode 100644 index 0000000..c048c78 --- /dev/null +++ b/src/status_publisher/charge/chrg_mgmt_data.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import dataclasses +import datetime +import logging +import math + +from saic_ismart_client_ng.api.vehicle_charging import ( + ChargeCurrentLimitCode, + ChrgMgmtData, + ScheduledChargingMode, + TargetBatteryCode, +) + +import mqtt_topics +from status_publisher import VehicleDataPublisher +from utils import int_to_bool, value_in_range + +LOG = logging.getLogger(__name__) + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class ScheduledCharging: + start_time: datetime.time + mode: ScheduledChargingMode + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class ChrgMgmtDataProcessingResult: + charge_current_limit: ChargeCurrentLimitCode | None + target_soc: TargetBatteryCode | None + scheduled_charging: ScheduledCharging | None + is_charging: bool + remaining_charging_time: int | None + power: float | None + raw_soc: int | None + + +class ChrgMgmtDataPublisher(VehicleDataPublisher): + def on_chrg_mgmt_data( + self, charge_mgmt_data: ChrgMgmtData + ) -> ChrgMgmtDataProcessingResult: + is_valid_raw_current = ( + charge_mgmt_data.bmsPackCrntV != 1 + and charge_mgmt_data.bmsPackCrnt is not None + and value_in_range(charge_mgmt_data.bmsPackCrnt, 0, 65535) + and charge_mgmt_data.decoded_current is not None + ) + is_valid_current, _ = self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_CURRENT, + value=charge_mgmt_data.decoded_current, + validator=lambda _: is_valid_raw_current, + transform=lambda x: round(x, 3), + ) + + is_valid_raw_voltage = ( + charge_mgmt_data.bmsPackVol is not None + and value_in_range(charge_mgmt_data.bmsPackVol, 0, 65535) + ) + is_valid_voltage, _ = self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_VOLTAGE, + value=charge_mgmt_data.decoded_voltage, + validator=lambda _: is_valid_raw_voltage, + transform=lambda x: round(x, 3), + ) + + is_valid_power, _ = self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_POWER, + value=charge_mgmt_data.decoded_power, + validator=lambda _: is_valid_current and is_valid_voltage, + transform=lambda x: round(x, 3), + ) + + obc_voltage = charge_mgmt_data.onBdChrgrAltrCrntInptVol + obc_current = charge_mgmt_data.onBdChrgrAltrCrntInptCrnt + if obc_voltage is not None and obc_current is not None: + self._publish( + topic=mqtt_topics.OBC_CURRENT, + value=round(obc_current / 5.0, 1), + ) + self._publish( + topic=mqtt_topics.OBC_VOLTAGE, + value=2 * obc_voltage, + ) + self._publish( + topic=mqtt_topics.OBC_POWER_SINGLE_PHASE, + value=round(2.0 * obc_voltage * obc_current / 5.0, 1), + ) + self._publish( + topic=mqtt_topics.OBC_POWER_THREE_PHASE, + value=round(math.sqrt(3) * 2 * obc_voltage * obc_current / 15.0, 1), + ) + else: + self._publish( + topic=mqtt_topics.OBC_CURRENT, + value=0.0, + ) + self._publish( + topic=mqtt_topics.OBC_VOLTAGE, + value=0, + ) + + raw_charge_current_limit = charge_mgmt_data.bmsAltngChrgCrntDspCmd + charge_current_limit: ChargeCurrentLimitCode | None = None + if raw_charge_current_limit is not None and raw_charge_current_limit != 0: + try: + charge_current_limit = ChargeCurrentLimitCode(raw_charge_current_limit) + except ValueError: + LOG.warning( + f"Invalid charge current limit received: {raw_charge_current_limit}" + ) + + raw_target_soc = charge_mgmt_data.bmsOnBdChrgTrgtSOCDspCmd + target_soc: TargetBatteryCode | None = None + if raw_target_soc is not None: + try: + target_soc = TargetBatteryCode(raw_target_soc) + except ValueError: + LOG.warning(f"Invalid target SOC received: {raw_target_soc}") + + self._publish( + topic=mqtt_topics.DRIVETRAIN_HYBRID_ELECTRICAL_RANGE, + value=charge_mgmt_data.bmsEstdElecRng, + validator=lambda x: value_in_range(x, 0, 2046), + ) + + self._transform_and_publish( + topic=mqtt_topics.BMS_CHARGE_STATUS, + value=charge_mgmt_data.bms_charging_status, + transform=lambda x: f"UNKNOWN {charge_mgmt_data.bmsChrgSts}" + if x is None + else x.name, + ) + + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_CHARGING_STOP_REASON, + value=charge_mgmt_data.charging_stop_reason, + transform=lambda x: f"UNKNOWN {charge_mgmt_data.bmsChrgSpRsn}" + if x is None + else x.name, + ) + + self._publish( + topic=mqtt_topics.CCU_ONBOARD_PLUG_STATUS, + value=charge_mgmt_data.ccuOnbdChrgrPlugOn, + ) + + self._publish( + topic=mqtt_topics.CCU_OFFBOARD_PLUG_STATUS, + value=charge_mgmt_data.ccuOffBdChrgrPlugOn, + ) + + scheduled_charging: ScheduledCharging | None = None + if charge_mgmt_data is not None and ( + charge_mgmt_data.bmsReserStHourDspCmd is not None + and charge_mgmt_data.bmsReserStMintueDspCmd is not None + and charge_mgmt_data.bmsReserSpHourDspCmd is not None + and charge_mgmt_data.bmsReserSpMintueDspCmd is not None + ): + try: + start_hour = charge_mgmt_data.bmsReserStHourDspCmd + start_minute = charge_mgmt_data.bmsReserStMintueDspCmd + start_time = datetime.time(hour=start_hour, minute=start_minute) + end_hour = charge_mgmt_data.bmsReserSpHourDspCmd + end_minute = charge_mgmt_data.bmsReserSpMintueDspCmd + mode = ScheduledChargingMode(charge_mgmt_data.bmsReserCtrlDspCmd) + self._publish( + topic=mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, + value={ + "startTime": f"{start_hour:02d}:{start_minute:02d}", + "endTime": f"{end_hour:02d}:{end_minute:02d}", + "mode": mode.name, + }, + ) + scheduled_charging = ScheduledCharging(start_time=start_time, mode=mode) + + except ValueError: + LOG.exception("Error parsing scheduled charging info") + + # Only publish remaining charging time if the car tells us the value is OK + remaining_charging_time: int | None = None + valid_remaining_time, remaining_charging_time = self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME, + value=charge_mgmt_data.chrgngRmnngTime, + validator=lambda _: charge_mgmt_data.chrgngRmnngTimeV != 1, + transform=lambda x: x * 60, + ) + if not valid_remaining_time: + self._publish(topic=mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME, value=0) + + # We are charging if the BMS tells us so + is_charging = charge_mgmt_data.is_bms_charging + self._publish(topic=mqtt_topics.DRIVETRAIN_CHARGING, value=is_charging) + + self._publish( + topic=mqtt_topics.DRIVETRAIN_BATTERY_HEATING, + value=charge_mgmt_data.is_battery_heating, + ) + + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_BATTERY_HEATING_STOP_REASON, + value=charge_mgmt_data.heating_stop_reason, + transform=lambda x: f"UNKNOWN ({charge_mgmt_data.bmsPTCHeatResp})" + if x is None + else x.name, + ) + + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK, + value=charge_mgmt_data.charging_port_locked, + transform=int_to_bool, + ) + + return ChrgMgmtDataProcessingResult( + charge_current_limit=charge_current_limit, + target_soc=target_soc, + scheduled_charging=scheduled_charging, + is_charging=is_charging, + remaining_charging_time=remaining_charging_time, + power=charge_mgmt_data.decoded_power if is_valid_power else None, + raw_soc=charge_mgmt_data.bmsPackSOCDsp, + ) diff --git a/src/status_publisher/charge/chrg_mgmt_data_resp.py b/src/status_publisher/charge/chrg_mgmt_data_resp.py new file mode 100644 index 0000000..3277593 --- /dev/null +++ b/src/status_publisher/charge/chrg_mgmt_data_resp.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import dataclasses +import datetime +from typing import TYPE_CHECKING + +import mqtt_topics +from status_publisher import VehicleDataPublisher +from status_publisher.charge.chrg_mgmt_data import ( + ChrgMgmtDataProcessingResult, + ChrgMgmtDataPublisher, + ScheduledCharging, +) +from status_publisher.charge.rvs_charge_status import ( + RvsChargeStatusProcessingResult, + RvsChargeStatusPublisher, +) + +if TYPE_CHECKING: + from saic_ismart_client_ng.api.vehicle_charging import ( + ChargeCurrentLimitCode, + ChrgMgmtDataResp, + TargetBatteryCode, + ) + + from publisher.core import Publisher + from vehicle_info import VehicleInfo + + +@dataclasses.dataclass(kw_only=True, frozen=True) +class ChrgMgmtDataRespProcessingResult: + charge_current_limit: ChargeCurrentLimitCode | None + target_soc: TargetBatteryCode | None + scheduled_charging: ScheduledCharging | None + is_charging: bool | None + remaining_charging_time: int | None + power: float | None + real_total_battery_capacity: float + raw_soc: int | None + raw_fuel_range_elec: int | None + + +class ChrgMgmtDataRespPublisher(VehicleDataPublisher): + def __init__( + self, vin: VehicleInfo, publisher: Publisher, mqtt_vehicle_prefix: str + ) -> None: + super().__init__(vin, publisher, mqtt_vehicle_prefix) + self.__chrg_mgmt_data_publisher = ChrgMgmtDataPublisher( + vin, publisher, mqtt_vehicle_prefix + ) + self.__rvs_charge_status_publisher = RvsChargeStatusPublisher( + vin, publisher, mqtt_vehicle_prefix + ) + + def on_chrg_mgmt_data_resp( + self, chrg_mgmt_data_resp: ChrgMgmtDataResp + ) -> ChrgMgmtDataRespProcessingResult: + chrg_mgmt_data = chrg_mgmt_data_resp.chrgMgmtData + chrg_mgmt_data_result: ChrgMgmtDataProcessingResult | None = None + if chrg_mgmt_data is not None: + chrg_mgmt_data_result = self.__chrg_mgmt_data_publisher.on_chrg_mgmt_data( + chrg_mgmt_data + ) + + charge_status = chrg_mgmt_data_resp.rvsChargeStatus + charge_status_result: RvsChargeStatusProcessingResult | None = None + if charge_status is not None: + charge_status_result = ( + self.__rvs_charge_status_publisher.on_rvs_charge_status(charge_status) + ) + else: + pass + + if chrg_mgmt_data_result is not None or charge_status_result is not None: + self._publish( + topic=mqtt_topics.REFRESH_LAST_CHARGE_STATE, + value=datetime.datetime.now(), + ) + return ChrgMgmtDataRespProcessingResult( + charge_current_limit=chrg_mgmt_data_result.charge_current_limit + if chrg_mgmt_data_result is not None + else None, + target_soc=chrg_mgmt_data_result.target_soc + if chrg_mgmt_data_result is not None + else None, + scheduled_charging=chrg_mgmt_data_result.scheduled_charging + if chrg_mgmt_data_result is not None + else None, + is_charging=chrg_mgmt_data_result.is_charging + if chrg_mgmt_data_result is not None + else None, + remaining_charging_time=chrg_mgmt_data_result.remaining_charging_time + if chrg_mgmt_data_result is not None + else None, + power=chrg_mgmt_data_result.power + if chrg_mgmt_data_result is not None + else None, + real_total_battery_capacity=charge_status_result.real_total_battery_capacity + if charge_status_result is not None + else 0.0, + raw_soc=chrg_mgmt_data_result.raw_soc + if chrg_mgmt_data_result is not None + else None, + raw_fuel_range_elec=charge_status_result.raw_fuel_range_elec + if charge_status_result is not None + else None, + ) diff --git a/src/status_publisher/charge/rvs_charge_status.py b/src/status_publisher/charge/rvs_charge_status.py new file mode 100644 index 0000000..c1f11e5 --- /dev/null +++ b/src/status_publisher/charge/rvs_charge_status.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import TYPE_CHECKING + +import mqtt_topics +from status_publisher import VehicleDataPublisher +from utils import int_to_bool, value_in_range + +if TYPE_CHECKING: + from saic_ismart_client_ng.api.vehicle_charging import RvsChargeStatus + +LOG = logging.getLogger(__name__) + + +@dataclass(kw_only=True, frozen=True) +class RvsChargeStatusProcessingResult: + real_total_battery_capacity: float + raw_fuel_range_elec: int | None + + +class RvsChargeStatusPublisher(VehicleDataPublisher): + def on_rvs_charge_status( + self, charge_status: RvsChargeStatus + ) -> RvsChargeStatusProcessingResult: + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_MILEAGE_OF_DAY, + value=charge_status.mileageOfDay, + validator=lambda x: value_in_range(x, 0, 65535), + transform=lambda x: x / 10.0, + ) + + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE, + value=charge_status.mileageSinceLastCharge, + validator=lambda x: value_in_range(x, 0, 65535), + transform=lambda x: x / 10.0, + ) + + self._publish( + topic=mqtt_topics.DRIVETRAIN_CHARGING_TYPE, + value=charge_status.chargingType, + ) + + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_CHARGER_CONNECTED, + value=charge_status.chargingGunState, + transform=int_to_bool, + ) + + self._publish( + topic=mqtt_topics.DRIVETRAIN_CHARGING_LAST_START, + value=charge_status.startTime, + validator=lambda x: value_in_range(x, 1, 2147483647), + ) + + self._publish( + topic=mqtt_topics.DRIVETRAIN_CHARGING_LAST_END, + value=charge_status.endTime, + validator=lambda x: value_in_range(x, 1, 2147483647), + ) + + real_total_battery_capacity, battery_capacity_correction_factor = ( + self.get_actual_battery_capacity(charge_status) + ) + + self._publish( + topic=mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY, + value=real_total_battery_capacity, + validator=lambda x: x > 0, + ) + + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_SOC_KWH, + value=charge_status.realtimePower, + transform=lambda p: round( + (battery_capacity_correction_factor * p) / 10.0, 2 + ), + ) + + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_LAST_CHARGE_ENDING_POWER, + value=charge_status.lastChargeEndingPower, + validator=lambda x: value_in_range(x, 0, 65535), + transform=lambda p: round( + (battery_capacity_correction_factor * p) / 10.0, 2 + ), + ) + + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_POWER_USAGE_OF_DAY, + value=charge_status.powerUsageOfDay, + validator=lambda x: value_in_range(x, 0, 65535), + transform=lambda p: round( + (battery_capacity_correction_factor * p) / 10.0, 2 + ), + ) + + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_POWER_USAGE_SINCE_LAST_CHARGE, + value=charge_status.powerUsageSinceLastCharge, + validator=lambda x: value_in_range(x, 0, 65535), + transform=lambda p: round( + (battery_capacity_correction_factor * p) / 10.0, 2 + ), + ) + + return RvsChargeStatusProcessingResult( + real_total_battery_capacity=real_total_battery_capacity, + raw_fuel_range_elec=charge_status.fuelRangeElec, + ) + + def get_actual_battery_capacity( + self, charge_status: RvsChargeStatus + ) -> tuple[float, float]: + real_total_battery_capacity = self._vehicle_info.battery_capacity + if real_total_battery_capacity is not None and real_total_battery_capacity <= 0: + # Negative or 0 value for real capacity means we don't know that info + real_total_battery_capacity = None + + raw_total_battery_capacity = None + if ( + charge_status.totalBatteryCapacity is not None + and charge_status.totalBatteryCapacity > 0 + ): + raw_total_battery_capacity = charge_status.totalBatteryCapacity / 10.0 + + if raw_total_battery_capacity is not None: + if real_total_battery_capacity is not None: + LOG.debug( + "Calculating full battery capacity correction factor based on " + "real=%f and raw=%f", + real_total_battery_capacity, + raw_total_battery_capacity, + ) + return ( + real_total_battery_capacity, + real_total_battery_capacity / raw_total_battery_capacity, + ) + LOG.debug( + "Setting real battery capacity to raw battery capacity %f", + raw_total_battery_capacity, + ) + return raw_total_battery_capacity, 1.0 + if real_total_battery_capacity is not None: + LOG.debug( + "Setting raw battery capacity to real battery capacity %f", + real_total_battery_capacity, + ) + return real_total_battery_capacity, 1.0 + LOG.warning("No battery capacity information available") + return 0, 1.0 diff --git a/src/status_publisher/message.py b/src/status_publisher/message.py new file mode 100644 index 0000000..379af14 --- /dev/null +++ b/src/status_publisher/message.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import TYPE_CHECKING + +import mqtt_topics +from status_publisher import VehicleDataPublisher + +if TYPE_CHECKING: + from saic_ismart_client_ng.api.message import MessageEntity + + from publisher.core import Publisher + from vehicle_info import VehicleInfo + + +@dataclass(kw_only=True, frozen=True) +class MessagePublisherProcessingResult: + processed: bool + + +class MessagePublisher(VehicleDataPublisher): + def __init__( + self, vin: VehicleInfo, publisher: Publisher, mqtt_vehicle_prefix: str + ) -> None: + super().__init__(vin, publisher, mqtt_vehicle_prefix) + self.__last_car_vehicle_message = datetime.min + + def on_message(self, message: MessageEntity) -> MessagePublisherProcessingResult: + if ( + self.__last_car_vehicle_message == datetime.min + or message.message_time > self.__last_car_vehicle_message + ): + self.__last_car_vehicle_message = message.message_time + self._publish( + topic=mqtt_topics.INFO_LAST_MESSAGE_TIME, + value=self.__last_car_vehicle_message, + ) + + if isinstance(message.messageId, str): + self._publish( + topic=mqtt_topics.INFO_LAST_MESSAGE_ID, + value=message.messageId, + ) + else: + self._transform_and_publish( + topic=mqtt_topics.INFO_LAST_MESSAGE_ID, + value=message.messageId, + transform=lambda x: str(x), + ) + + self._publish( + topic=mqtt_topics.INFO_LAST_MESSAGE_TYPE, + value=message.messageType, + ) + + self._publish( + topic=mqtt_topics.INFO_LAST_MESSAGE_TITLE, + value=message.title, + ) + + self._publish( + topic=mqtt_topics.INFO_LAST_MESSAGE_SENDER, + value=message.sender, + ) + + self._publish( + topic=mqtt_topics.INFO_LAST_MESSAGE_CONTENT, + value=message.content, + ) + + self._publish( + topic=mqtt_topics.INFO_LAST_MESSAGE_STATUS, + value=message.read_status, + ) + + self._publish( + topic=mqtt_topics.INFO_LAST_MESSAGE_VIN, + value=message.vin, + ) + + return MessagePublisherProcessingResult(processed=True) + return MessagePublisherProcessingResult(processed=False) diff --git a/src/status_publisher/vehicle/__init__.py b/src/status_publisher/vehicle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/status_publisher/vehicle/basic_vehicle_status.py b/src/status_publisher/vehicle/basic_vehicle_status.py new file mode 100644 index 0000000..60cd915 --- /dev/null +++ b/src/status_publisher/vehicle/basic_vehicle_status.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import mqtt_topics +from status_publisher import VehicleDataPublisher +from utils import int_to_bool, is_valid_temperature, to_remote_climate, value_in_range + +if TYPE_CHECKING: + from saic_ismart_client_ng.api.vehicle import BasicVehicleStatus + +PRESSURE_TO_BAR_FACTOR = 0.04 + + +@dataclass(kw_only=True, frozen=True) +class BasicVehicleStatusProcessingResult: + hv_battery_active_from_car: bool + remote_ac_running: bool + remote_heated_seats_front_right_level: int | None + remote_heated_seats_front_left_level: int | None + is_parked: bool + fuel_rage_elec: int | None + raw_soc: int | None + + +class BasicVehicleStatusPublisher(VehicleDataPublisher): + def on_basic_vehicle_status( + self, basic_vehicle_status: BasicVehicleStatus + ) -> BasicVehicleStatusProcessingResult: + is_engine_running = basic_vehicle_status.is_engine_running + remote_climate_status = basic_vehicle_status.remoteClimateStatus or 0 + rear_window_heat_state = basic_vehicle_status.rmtHtdRrWndSt or 0 + + hv_battery_active_from_car = ( + is_engine_running or remote_climate_status > 0 or rear_window_heat_state > 0 + ) + + is_valid_mileage, _ = self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_MILEAGE, + value=basic_vehicle_status.mileage, + validator=lambda x: value_in_range(x, 1, 2147483647), + transform=lambda x: x / 10.0, + ) + + self._publish( + topic=mqtt_topics.DRIVETRAIN_RUNNING, + value=is_engine_running, + ) + + self._publish( + topic=mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE, + value=basic_vehicle_status.interiorTemperature, + validator=is_valid_temperature, + ) + + self._publish( + topic=mqtt_topics.CLIMATE_EXTERIOR_TEMPERATURE, + value=basic_vehicle_status.exteriorTemperature, + validator=is_valid_temperature, + ) + + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE, + value=basic_vehicle_status.batteryVoltage, + validator=lambda x: value_in_range(x, 1, 65535), + transform=lambda x: x / 10.0, + ) + + if is_valid_mileage: + self._transform_and_publish( + topic=mqtt_topics.WINDOWS_DRIVER, + value=basic_vehicle_status.driverWindow, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.WINDOWS_PASSENGER, + value=basic_vehicle_status.passengerWindow, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.WINDOWS_REAR_LEFT, + value=basic_vehicle_status.rearLeftWindow, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.WINDOWS_REAR_RIGHT, + value=basic_vehicle_status.rearRightWindow, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.WINDOWS_SUN_ROOF, + value=basic_vehicle_status.sunroofStatus, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.DOORS_LOCKED, + value=basic_vehicle_status.lockStatus, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.DOORS_DRIVER, + value=basic_vehicle_status.driverDoor, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.DOORS_PASSENGER, + value=basic_vehicle_status.passengerDoor, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.DOORS_REAR_LEFT, + value=basic_vehicle_status.rearLeftDoor, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.DOORS_REAR_RIGHT, + value=basic_vehicle_status.rearRightDoor, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.DOORS_BONNET, + value=basic_vehicle_status.bonnetStatus, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.DOORS_BOOT, + value=basic_vehicle_status.bootStatus, + transform=int_to_bool, + ) + + self.__publish_tyre( + basic_vehicle_status.frontLeftTyrePressure, + mqtt_topics.TYRES_FRONT_LEFT_PRESSURE, + ) + + self.__publish_tyre( + basic_vehicle_status.frontRightTyrePressure, + mqtt_topics.TYRES_FRONT_RIGHT_PRESSURE, + ) + self.__publish_tyre( + basic_vehicle_status.rearLeftTyrePressure, + mqtt_topics.TYRES_REAR_LEFT_PRESSURE, + ) + self.__publish_tyre( + basic_vehicle_status.rearRightTyrePressure, + mqtt_topics.TYRES_REAR_RIGHT_PRESSURE, + ) + + self._transform_and_publish( + topic=mqtt_topics.LIGHTS_MAIN_BEAM, + value=basic_vehicle_status.mainBeamStatus, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.LIGHTS_DIPPED_BEAM, + value=basic_vehicle_status.dippedBeamStatus, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.LIGHTS_SIDE, + value=basic_vehicle_status.sideLightStatus, + transform=int_to_bool, + ) + + self._transform_and_publish( + topic=mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE, + value=remote_climate_status, + transform=lambda x: to_remote_climate(x), + ) + + remote_ac_running = remote_climate_status == 2 + + self._transform_and_publish( + topic=mqtt_topics.CLIMATE_BACK_WINDOW_HEAT, + value=rear_window_heat_state, + transform=lambda x: "off" if x == 0 else "on", + ) + + _, front_left_seat_level = self._publish( + topic=mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL, + value=basic_vehicle_status.frontLeftSeatHeatLevel, + validator=lambda x: value_in_range(x, 0, 255), + ) + + _, front_right_seat_level = self._publish( + topic=mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL, + value=basic_vehicle_status.frontRightSeatHeatLevel, + validator=lambda x: value_in_range(x, 0, 255), + ) + + # Standard fossil fuels vehicles + self._transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_RANGE, + value=basic_vehicle_status.fuelRange, + validator=lambda x: value_in_range(x, 0, 65535), + transform=lambda x: x / 10.0, + ) + + self._publish( + topic=mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE, + value=basic_vehicle_status.fuelLevelPrc, + validator=lambda x: value_in_range(x, 0, 100, is_max_excl=False), + ) + + if (journey_id := basic_vehicle_status.currentJourneyId) is not None and ( + journey_distance := basic_vehicle_status.currentJourneyDistance + ) is not None: + self._publish( + topic=mqtt_topics.DRIVETRAIN_CURRENT_JOURNEY, + value={ + "id": journey_id, + "distance": round(journey_distance / 10.0, 1), + }, + ) + return BasicVehicleStatusProcessingResult( + hv_battery_active_from_car=hv_battery_active_from_car, + remote_ac_running=remote_ac_running, + is_parked=basic_vehicle_status.is_parked, + remote_heated_seats_front_left_level=front_left_seat_level, + remote_heated_seats_front_right_level=front_right_seat_level, + fuel_rage_elec=basic_vehicle_status.fuelRangeElec, + raw_soc=basic_vehicle_status.extendedData1, + ) + + def __publish_tyre(self, raw_value: int | None, topic: str) -> None: + self._transform_and_publish( + topic=topic, + value=raw_value, + validator=lambda x: value_in_range(x, 1, 255), + transform=lambda x: round(x * PRESSURE_TO_BAR_FACTOR, 2), + ) diff --git a/src/status_publisher/vehicle/gps_position.py b/src/status_publisher/vehicle/gps_position.py new file mode 100644 index 0000000..47b8709 --- /dev/null +++ b/src/status_publisher/vehicle/gps_position.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus + +import mqtt_topics +from status_publisher import VehicleDataPublisher +from utils import value_in_range + + +@dataclass(kw_only=True, frozen=True) +class GpsPositionProcessingResult: + speed: float | None + + +class GpsPositionPublisher(VehicleDataPublisher): + def on_gps_position(self, gps_position: GpsPosition) -> GpsPositionProcessingResult: + speed: float | None = None + if gps_position.gps_status_decoded in [ + GpsStatus.FIX_2D, + GpsStatus.FIX_3d, + ]: + way_point = gps_position.wayPoint + if way_point: + if way_point.speed is not None: + speed = way_point.speed / 10.0 + + self._publish( + topic=mqtt_topics.LOCATION_HEADING, + value=way_point.heading, + ) + + position = way_point.position + if ( + position + and (raw_lat := position.latitude) is not None + and (raw_long := position.longitude) is not None + ): + latitude = raw_lat / 1000000.0 + longitude = raw_long / 1000000.0 + if abs(latitude) <= 90 and abs(longitude) <= 180: + self._publish( + topic=mqtt_topics.LOCATION_LATITUDE, value=latitude + ) + self._publish( + topic=mqtt_topics.LOCATION_LONGITUDE, value=longitude + ) + position_json = { + "latitude": latitude, + "longitude": longitude, + } + _, altitude = self._publish( + topic=mqtt_topics.LOCATION_ELEVATION, + value=position.altitude, + validator=lambda x: value_in_range(x, -500, 8900), + ) + if altitude is not None: + position_json["altitude"] = altitude + self._publish( + topic=mqtt_topics.LOCATION_POSITION, + value=position_json, + ) + return GpsPositionProcessingResult(speed=speed) diff --git a/src/status_publisher/vehicle/vehicle_status_resp.py b/src/status_publisher/vehicle/vehicle_status_resp.py new file mode 100644 index 0000000..609395d --- /dev/null +++ b/src/status_publisher/vehicle/vehicle_status_resp.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from dataclasses import dataclass +import datetime +from typing import TYPE_CHECKING, Final + +from exceptions import MqttGatewayException +import mqtt_topics +from status_publisher import VehicleDataPublisher +from status_publisher.vehicle.basic_vehicle_status import ( + BasicVehicleStatusProcessingResult, + BasicVehicleStatusPublisher, +) +from status_publisher.vehicle.gps_position import ( + GpsPositionProcessingResult, + GpsPositionPublisher, +) + +if TYPE_CHECKING: + from saic_ismart_client_ng.api.schema import GpsPosition + from saic_ismart_client_ng.api.vehicle import BasicVehicleStatus, VehicleStatusResp + + from publisher.core import Publisher + from vehicle_info import VehicleInfo + + +@dataclass(kw_only=True, frozen=True) +class VehicleStatusRespProcessingResult: + hv_battery_active_from_car: bool + remote_ac_running: bool + remote_heated_seats_front_right_level: int | None + remote_heated_seats_front_left_level: int | None + fuel_range_elec: int | None + raw_soc: int | None + + +class VehicleStatusRespPublisher(VehicleDataPublisher): + def __init__( + self, vin: VehicleInfo, publisher: Publisher, mqtt_vehicle_prefix: str + ) -> None: + super().__init__(vin, publisher, mqtt_vehicle_prefix) + self.__gps_position_publisher: Final[GpsPositionPublisher] = ( + GpsPositionPublisher(vin, publisher, mqtt_vehicle_prefix) + ) + self.__basic_vehicle_status_publisher: Final[BasicVehicleStatusPublisher] = ( + BasicVehicleStatusPublisher(vin, publisher, mqtt_vehicle_prefix) + ) + + def on_vehicle_status_resp( + self, vehicle_status: VehicleStatusResp + ) -> VehicleStatusRespProcessingResult: + vehicle_status_time = datetime.datetime.fromtimestamp( + vehicle_status.statusTime or 0, tz=datetime.UTC + ) + now_time = datetime.datetime.now(tz=datetime.UTC) + vehicle_status_drift = abs(now_time - vehicle_status_time) + + if vehicle_status_drift > datetime.timedelta(minutes=15): + msg = f"Vehicle status time drifted more than 15 minutes from current time: {vehicle_status_drift}. Server reported {vehicle_status_time}" + raise MqttGatewayException(msg) + + basic_vehicle_status = vehicle_status.basicVehicleStatus + if basic_vehicle_status: + return self.__on_basic_vehicle_status( + basic_vehicle_status, vehicle_status.gpsPosition + ) + msg = f"Missing basic vehicle status data: {basic_vehicle_status}. We'll mark this poll as failed" + raise MqttGatewayException(msg) + + def __on_basic_vehicle_status( + self, basic_vehicle_status: BasicVehicleStatus, gps_position: GpsPosition | None + ) -> VehicleStatusRespProcessingResult: + basic_vehicle_status_result = ( + self.__basic_vehicle_status_publisher.on_basic_vehicle_status( + basic_vehicle_status + ) + ) + + if gps_position: + self.__on_gps_position(basic_vehicle_status_result, gps_position) + + self._publish( + topic=mqtt_topics.REFRESH_LAST_VEHICLE_STATE, + value=datetime.datetime.now(), + ) + + return VehicleStatusRespProcessingResult( + hv_battery_active_from_car=basic_vehicle_status_result.hv_battery_active_from_car, + remote_ac_running=basic_vehicle_status_result.remote_ac_running, + remote_heated_seats_front_left_level=basic_vehicle_status_result.remote_heated_seats_front_left_level, + remote_heated_seats_front_right_level=basic_vehicle_status_result.remote_heated_seats_front_right_level, + raw_soc=basic_vehicle_status_result.raw_soc, + fuel_range_elec=basic_vehicle_status_result.fuel_rage_elec, + ) + + def __on_gps_position( + self, + basic_vehicle_status_result: BasicVehicleStatusProcessingResult, + gps_position: GpsPosition, + ) -> None: + gps_position_result = self.__gps_position_publisher.on_gps_position( + gps_position + ) + self.__fuse_data(basic_vehicle_status_result, gps_position_result) + + def __fuse_data( + self, + basic_vehicle_status_result: BasicVehicleStatusProcessingResult, + gps_position_result: GpsPositionProcessingResult, + ) -> None: + gps_speed = gps_position_result.speed + is_parked = basic_vehicle_status_result.is_parked + if gps_speed is not None and is_parked: + gps_speed = 0.0 + + self._publish(topic=mqtt_topics.LOCATION_SPEED, value=gps_speed) diff --git a/src/status_publisher/vehicle_info.py b/src/status_publisher/vehicle_info.py new file mode 100644 index 0000000..c8baa13 --- /dev/null +++ b/src/status_publisher/vehicle_info.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from dataclasses import asdict +import json +import logging + +import mqtt_topics +from status_publisher import VehicleDataPublisher + +LOG = logging.getLogger(__name__) + + +class VehicleInfoPublisher(VehicleDataPublisher): + def publish(self) -> None: + LOG.info("Publishing vehicle info to MQTT") + self._transform_and_publish( + topic=mqtt_topics.INTERNAL_CONFIGURATION_RAW, + value=self._vehicle_info.configuration, + transform=lambda c: json.dumps([asdict(x) for x in c]), + ) + self._publish( + topic=mqtt_topics.INFO_BRAND, + value=self._vehicle_info.brand, + ) + self._publish( + topic=mqtt_topics.INFO_MODEL, + value=self._vehicle_info.model, + ) + self._publish(topic=mqtt_topics.INFO_YEAR, value=self._vehicle_info.model_year) + self._publish( + topic=mqtt_topics.INFO_SERIES, + value=self._vehicle_info.series, + ) + self._publish( + topic=mqtt_topics.INFO_COLOR, + value=self._vehicle_info.color, + ) + for c in self._vehicle_info.configuration: + property_value = c.itemValue + if property_value is None: + continue + if property_name := c.itemName: + property_name_topic = ( + f"{mqtt_topics.INFO_CONFIGURATION}/{property_name}" + ) + self._publish( + topic=property_name_topic, + value=property_value, + ) + if property_code := c.itemCode: + property_code_topic = ( + f"{mqtt_topics.INFO_CONFIGURATION}/{property_code}" + ) + self._publish( + topic=property_code_topic, + value=property_value, + ) diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..70628ea --- /dev/null +++ b/src/utils.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, TypeVar + +from saic_ismart_client_ng.api.schema import GpsStatus + +if TYPE_CHECKING: + from saic_ismart_client_ng.api.vehicle import VehicleStatusResp + +Numeric = TypeVar("Numeric", int, float) + + +def value_in_range( + value: Numeric, + min_value: Numeric, + max_value: Numeric, + is_max_excl: bool = True, +) -> bool: + if value is None: + return False + if is_max_excl: + return min_value <= value < max_value + return min_value <= value <= max_value + + +def is_valid_temperature(value: Numeric) -> bool: + return value_in_range(value, -127, 127) and value != 87 + + +def get_update_timestamp(vehicle_status: VehicleStatusResp) -> datetime: + vehicle_status_time = datetime.fromtimestamp(vehicle_status.statusTime or 0, tz=UTC) + now_time = datetime.now(tz=UTC) + # Do not use GPS data if it is not available + if vehicle_status.gpsPosition and vehicle_status.gpsPosition.gps_status_decoded in [ + GpsStatus.FIX_2D, + GpsStatus.FIX_3d, + ]: + gps_time = datetime.fromtimestamp( + vehicle_status.gpsPosition.timeStamp or 0, tz=UTC + ) + else: + gps_time = datetime.fromtimestamp(0, tz=UTC) + vehicle_status_drift = abs(now_time - vehicle_status_time) + gps_time_drift = abs(now_time - gps_time) + reference_drift = min(gps_time_drift, vehicle_status_drift) + reference_time = ( + gps_time if gps_time_drift < vehicle_status_drift else vehicle_status_time + ) + if reference_drift < timedelta(minutes=15): + return reference_time + return now_time + + +def datetime_to_str(dt: datetime) -> str: + return datetime.astimezone(dt, tz=UTC).isoformat() + + +def int_to_bool(value: int) -> bool: + return value > 0 + + +def to_remote_climate(rmt_htd_rr_wnd_st: int) -> str: + match rmt_htd_rr_wnd_st: + case 0: + return "off" + case 1: + return "blowingonly" + case 2: + return "on" + case 5: + return "front" + + return f"unknown ({rmt_htd_rr_wnd_st})" diff --git a/src/vehicle.py b/src/vehicle.py new file mode 100644 index 0000000..17138e3 --- /dev/null +++ b/src/vehicle.py @@ -0,0 +1,810 @@ +from __future__ import annotations + +import datetime +from enum import Enum, unique +import logging +import math +from typing import TYPE_CHECKING, Any, Final, TypeVar + +from apscheduler.triggers.cron import CronTrigger +from saic_ismart_client_ng.api.vehicle_charging import ( + ChargeCurrentLimitCode, + ChrgMgmtDataResp, + ScheduledBatteryHeatingResp, + ScheduledChargingMode, + TargetBatteryCode, +) + +from exceptions import MqttGatewayException +import mqtt_topics +from status_publisher.charge.chrg_mgmt_data_resp import ( + ChrgMgmtDataRespProcessingResult, + ChrgMgmtDataRespPublisher, +) +from status_publisher.message import MessagePublisher +from status_publisher.vehicle.vehicle_status_resp import ( + VehicleStatusRespProcessingResult, + VehicleStatusRespPublisher, +) +from utils import datetime_to_str, value_in_range + +if TYPE_CHECKING: + from collections.abc import Callable + + from apscheduler.job import Job + from apscheduler.schedulers.base import BaseScheduler + from saic_ismart_client_ng.api.message.schema import MessageEntity + from saic_ismart_client_ng.api.vehicle import ( + VehicleStatusResp, + ) + + from integrations.openwb.charging_station import ChargingStation + from publisher.core import Publisher + from vehicle_info import VehicleInfo + + T = TypeVar("T") + Publishable = TypeVar( + "Publishable", str, int, float, bool, dict[str, Any], datetime.datetime + ) + +DEFAULT_AC_TEMP = 22 +PRESSURE_TO_BAR_FACTOR = 0.04 + +LOG = logging.getLogger(__name__) + + +@unique +class RefreshMode(Enum): + FORCE = "force" + OFF = "off" + PERIODIC = "periodic" + + @staticmethod + def get(mode: str) -> RefreshMode: + return RefreshMode[mode.upper()] + + +class VehicleState: + def __init__( + self, + publisher: Publisher, + scheduler: BaseScheduler, + account_prefix: str, + vin_info: VehicleInfo, + charging_station: ChargingStation | None = None, + charge_polling_min_percent: float = 1.0, + ) -> None: + self.publisher = publisher + self.__message_publisher = MessagePublisher(vin_info, publisher, account_prefix) + self.__vehicle_response_publisher = VehicleStatusRespPublisher( + vin_info, publisher, account_prefix + ) + self.__charge_response_publisher = ChrgMgmtDataRespPublisher( + vin_info, publisher, account_prefix + ) + self.vehicle: Final[VehicleInfo] = vin_info + self.mqtt_vin_prefix = account_prefix + self.charging_station = charging_station + self.last_car_activity: datetime.datetime = datetime.datetime.min + self.last_successful_refresh: datetime.datetime = datetime.datetime.min + self.__last_failed_refresh: datetime.datetime | None = None + self.__failed_refresh_counter = 0 + self.__refresh_period_error = 30 + self.last_car_shutdown: datetime.datetime = datetime.datetime.now() + self.last_car_vehicle_message: datetime.datetime = datetime.datetime.min + # treat high voltage battery as active, if we don't have any other information + self.__hv_battery_active = True + self.__hv_battery_active_from_car = True + self.is_charging = False + self.refresh_period_active = -1 + self.refresh_period_inactive = -1 + self.refresh_period_after_shutdown = -1 + self.refresh_period_inactive_grace = -1 + self.target_soc: TargetBatteryCode | None = None + self.charge_current_limit: ChargeCurrentLimitCode | None = None + self.refresh_period_charging = 0 + self.charge_polling_min_percent = charge_polling_min_percent + self.refresh_mode = RefreshMode.OFF + self.previous_refresh_mode = RefreshMode.OFF + self.__remote_ac_temp: int | None = None + self.__remote_ac_running: bool = False + self.__remote_heated_seats_front_left_level: int = 0 + self.__remote_heated_seats_front_right_level: int = 0 + self.__scheduler = scheduler + self.__scheduled_battery_heating_enabled = False + self.__scheduled_battery_heating_start: datetime.time | None = None + + def set_refresh_period_active(self, seconds: int) -> None: + if seconds != self.refresh_period_active: + self.publisher.publish_int( + self.get_topic(mqtt_topics.REFRESH_PERIOD_ACTIVE), seconds + ) + human_readable_period = str(datetime.timedelta(seconds=seconds)) + LOG.info( + f"Setting active query interval in vehicle handler for VIN {self.vin} to {human_readable_period}" + ) + self.refresh_period_active = seconds + # Recompute charging refresh period, if active refresh period is changed + self.set_refresh_period_charging(self.refresh_period_charging) + + def set_refresh_period_inactive(self, seconds: int) -> None: + if seconds != self.refresh_period_inactive: + self.publisher.publish_int( + self.get_topic(mqtt_topics.REFRESH_PERIOD_INACTIVE), seconds + ) + human_readable_period = str(datetime.timedelta(seconds=seconds)) + LOG.info( + f"Setting inactive query interval in vehicle handler for VIN {self.vin} to {human_readable_period}" + ) + self.refresh_period_inactive = seconds + # Recompute charging refresh period, if inactive refresh period is changed + self.set_refresh_period_charging(self.refresh_period_charging) + + def set_refresh_period_charging(self, seconds: float) -> None: + # Do not refresh more than the active period and less than the inactive one + seconds = round(seconds) + seconds = ( + min(max(seconds, self.refresh_period_active), self.refresh_period_inactive) + if seconds > 0 + else 0 + ) + if seconds != self.refresh_period_charging: + self.publisher.publish_int( + self.get_topic(mqtt_topics.REFRESH_PERIOD_CHARGING), seconds + ) + human_readable_period = str(datetime.timedelta(seconds=seconds)) + LOG.info( + f"Setting charging query interval in vehicle handler for VIN {self.vin} to {human_readable_period}" + ) + self.refresh_period_charging = seconds + + def set_refresh_period_after_shutdown(self, seconds: int) -> None: + if seconds != self.refresh_period_after_shutdown: + self.publisher.publish_int( + self.get_topic(mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN), seconds + ) + human_readable_period = str(datetime.timedelta(seconds=seconds)) + LOG.info( + f"Setting after shutdown query interval in vehicle handler for VIN {self.vin} to {human_readable_period}" + ) + self.refresh_period_after_shutdown = seconds + + def set_refresh_period_inactive_grace( + self, refresh_period_inactive_grace: int + ) -> None: + if ( + self.refresh_period_inactive_grace == -1 + or self.refresh_period_inactive_grace != refresh_period_inactive_grace + ): + self.publisher.publish_int( + self.get_topic(mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE), + refresh_period_inactive_grace, + ) + self.refresh_period_inactive_grace = refresh_period_inactive_grace + + def update_target_soc(self, target_soc: TargetBatteryCode) -> None: + if self.target_soc != target_soc and target_soc is not None: + self.publisher.publish_int( + self.get_topic(mqtt_topics.DRIVETRAIN_SOC_TARGET), target_soc.percentage + ) + self.target_soc = target_soc + + def update_charge_current_limit( + self, charge_current_limit: ChargeCurrentLimitCode + ) -> None: + if ( + self.charge_current_limit != charge_current_limit + and charge_current_limit is not None + ): + try: + self.publisher.publish_str( + self.get_topic(mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT), + charge_current_limit.limit, + ) + self.charge_current_limit = charge_current_limit + except ValueError: + LOG.exception(f"Unhandled charge current limit {charge_current_limit}") + + def update_scheduled_charging( + self, start_time: datetime.time, mode: ScheduledChargingMode + ) -> None: + scheduled_charging_job_id = f"{self.vin}_scheduled_charging" + existing_job: Job | None = self.__scheduler.get_job(scheduled_charging_job_id) + if mode in [ + ScheduledChargingMode.UNTIL_CONFIGURED_TIME, + ScheduledChargingMode.UNTIL_CONFIGURED_SOC, + ]: + if self.refresh_period_inactive_grace > 0: + # Add a grace period to the start time, so that the car is not woken up too early + dt = datetime.datetime.now().replace( + hour=start_time.hour, + minute=start_time.minute, + second=0, + microsecond=0, + ) + datetime.timedelta(seconds=self.refresh_period_inactive_grace) + start_time = dt.time() + trigger = CronTrigger.from_crontab( + f"{start_time.minute} {start_time.hour} * * *" + ) + if existing_job is not None: + existing_job.reschedule(trigger=trigger) + LOG.info( + f"Rescheduled check for charging start for VIN {self.vin} at {start_time}" + ) + else: + self.__scheduler.add_job( + func=self.set_refresh_mode, + args=[RefreshMode.FORCE, "check for scheduled charging start"], + trigger=trigger, + kwargs={}, + name=scheduled_charging_job_id, + id=scheduled_charging_job_id, + replace_existing=True, + ) + LOG.info( + f"Scheduled check for charging start for VIN {self.vin} at {start_time}" + ) + elif existing_job is not None: + existing_job.remove() + LOG.info(f"Removed scheduled check for charging start for VIN {self.vin}") + + def is_complete(self) -> bool: + return ( + self.refresh_period_active != -1 + and self.refresh_period_inactive != -1 + and self.refresh_period_after_shutdown != -1 + and self.refresh_period_inactive_grace != -1 + and self.refresh_mode is not None + ) + + def set_is_charging(self, is_charging: bool) -> None: + self.is_charging = is_charging + self.hv_battery_active = self.is_charging + self.publisher.publish_bool( + self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING), self.is_charging + ) + + def handle_vehicle_status( + self, vehicle_status: VehicleStatusResp + ) -> VehicleStatusRespProcessingResult: + processing_result = self.__vehicle_response_publisher.on_vehicle_status_resp( + vehicle_status + ) + self.hv_battery_active_from_car = processing_result.hv_battery_active_from_car + self.__remote_ac_running = processing_result.remote_ac_running + if processing_result.remote_heated_seats_front_left_level is not None: + self.__remote_heated_seats_front_left_level = ( + processing_result.remote_heated_seats_front_left_level + ) + if processing_result.remote_heated_seats_front_right_level is not None: + self.__remote_heated_seats_front_right_level = ( + processing_result.remote_heated_seats_front_right_level + ) + return processing_result + + def __publish_electric_range(self, raw_value: int | None) -> bool: + published, electric_range = self.__transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_RANGE, + value=raw_value, + validator=lambda x: value_in_range(x, 1, 20460), + transform=lambda x: x / 10.0, + ) + if self.charging_station is not None and self.charging_station.range_topic: + self.__publish( + topic=self.charging_station.range_topic, + value=electric_range, + no_prefix=True, + ) + return published + + def __publish_soc(self, soc: float | None) -> bool: + published, published_soc = self.__transform_and_publish( + topic=mqtt_topics.DRIVETRAIN_SOC, + value=soc, + validator=lambda v: value_in_range(v, 0, 100.0, is_max_excl=False), + transform=lambda x: 1.0 * x, + ) + if self.charging_station is not None and self.charging_station.soc_topic: + self.__publish( + topic=self.charging_station.soc_topic, + value=published_soc, + no_prefix=True, + ) + return published + + @property + def hv_battery_active(self) -> bool: + return self.__hv_battery_active + + @hv_battery_active.setter + def hv_battery_active(self, new_state: bool) -> None: + self.__hv_battery_active = new_state + self.__publish( + topic=mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE, + value=new_state, + ) + if new_state: + self.notify_car_activity() + + @property + def hv_battery_active_from_car(self) -> bool: + return self.__hv_battery_active_from_car + + @hv_battery_active_from_car.setter + def hv_battery_active_from_car(self, new_state: bool) -> None: + old_state = self.__hv_battery_active_from_car + if old_state and not new_state: + self.last_car_shutdown = datetime.datetime.now() + LOG.info( + f"Detected vehicle {self.vin} shutdown at {self.last_car_shutdown}" + ) + self.__hv_battery_active_from_car = new_state + + def notify_car_activity(self) -> None: + self.last_car_activity = datetime.datetime.now() + self.__publish( + topic=mqtt_topics.REFRESH_LAST_ACTIVITY, + value=datetime_to_str(self.last_car_activity), + ) + + def notify_message(self, message: MessageEntity) -> None: + result = self.__message_publisher.on_message(message) + if result.processed: + self.notify_car_activity() + + def should_refresh(self) -> bool: + match self.refresh_mode: + case RefreshMode.OFF: + LOG.debug(f"Refresh mode is OFF, skipping vehicle {self.vin} refresh") + return False + case RefreshMode.FORCE: + LOG.debug(f"Refresh mode is FORCE, skipping vehicle {self.vin} refresh") + self.set_refresh_mode( + self.previous_refresh_mode, + "restoring of previous refresh mode after a FORCE execution", + ) + return True + # RefreshMode.PERIODIC is treated like default + case other: + LOG.debug( + f"Refresh mode is {other}, checking for other vehicle {self.vin} conditions" + ) + last_actual_poll = self.last_successful_refresh + if self.last_failed_refresh is not None: + last_actual_poll = max(last_actual_poll, self.last_failed_refresh) + + # Try refreshing even if we last failed as long as the last_car_activity is newer + if self.last_car_activity > last_actual_poll: + LOG.debug( + f"Polling vehicle {self.vin} as last_car_activity is newer than last_actual_poll." + f" {self.last_car_activity} > {last_actual_poll}" + ) + return True + + if self.last_failed_refresh is not None: + threshold = datetime.datetime.now() - datetime.timedelta( + seconds=float(self.refresh_period_error) + ) + result: bool = self.last_failed_refresh < threshold + LOG.debug( + f"Gateway failed refresh previously. Should refresh: {result}" + ) + return result + + if self.is_charging and self.refresh_period_charging > 0: + result = ( + self.last_successful_refresh + < datetime.datetime.now() + - datetime.timedelta( + seconds=float(self.refresh_period_charging) + ) + ) + LOG.debug(f"HV battery is charging. Should refresh: {result}") + return result + + if self.hv_battery_active: + result = ( + self.last_successful_refresh + < datetime.datetime.now() + - datetime.timedelta(seconds=float(self.refresh_period_active)) + ) + LOG.debug(f"HV battery is active. Should refresh: {result}") + return result + + last_shutdown_plus_refresh = ( + self.last_car_shutdown + + datetime.timedelta( + seconds=float(self.refresh_period_inactive_grace) + ) + ) + if last_shutdown_plus_refresh > datetime.datetime.now(): + result = ( + self.last_successful_refresh + < datetime.datetime.now() + - datetime.timedelta( + seconds=float(self.refresh_period_after_shutdown) + ) + ) + LOG.debug( + f"Refresh grace period after shutdown has not passed. Should refresh: {result}" + ) + return result + + result = ( + self.last_successful_refresh + < datetime.datetime.now() + - datetime.timedelta(seconds=float(self.refresh_period_inactive)) + ) + LOG.debug( + f"HV battery is inactive and refresh period after shutdown is over. Should refresh: {result}" + ) + return result + + def mark_successful_refresh(self) -> None: + self.last_successful_refresh = datetime.datetime.now() + self.last_failed_refresh = None + self.publisher.publish_str(self.get_topic(mqtt_topics.AVAILABLE), "online") + + def mark_failed_refresh(self) -> None: + self.last_failed_refresh = datetime.datetime.now() + self.publisher.publish_str(self.get_topic(mqtt_topics.AVAILABLE), "offline") + + @property + def refresh_period_error(self) -> int: + return self.__refresh_period_error + + @property + def last_failed_refresh(self) -> datetime.datetime | None: + return self.__last_failed_refresh + + @last_failed_refresh.setter + def last_failed_refresh(self, value: datetime.datetime | None) -> None: + self.__last_failed_refresh = value + if value is None: + self.__failed_refresh_counter = 0 + self.__refresh_period_error = self.refresh_period_active + elif self.__refresh_period_error < self.refresh_period_inactive: + self.__refresh_period_error = round( + min( + self.refresh_period_active * (2**self.__failed_refresh_counter), + self.refresh_period_inactive, + ) + ) + self.__failed_refresh_counter = self.__failed_refresh_counter + 1 + self.publisher.publish_str( + self.get_topic(mqtt_topics.REFRESH_LAST_ERROR), datetime_to_str(value) + ) + self.publisher.publish_int( + self.get_topic(mqtt_topics.REFRESH_PERIOD_ERROR), + self.__refresh_period_error, + ) + + def configure_missing(self) -> None: + if self.refresh_period_active == -1: + self.set_refresh_period_active(30) + if self.refresh_period_after_shutdown == -1: + self.set_refresh_period_after_shutdown(120) + if self.refresh_period_inactive == -1: + # in seconds (Once a day to protect your 12V battery) + self.set_refresh_period_inactive(86400) + if self.refresh_period_inactive_grace == -1: + self.set_refresh_period_inactive_grace(600) + if self.__remote_ac_temp is None: + self.set_ac_temperature(DEFAULT_AC_TEMP) + # Make sure the only refresh mode that is not supported at start is RefreshMode.PERIODIC + if self.refresh_mode in [RefreshMode.OFF, RefreshMode.FORCE]: + self.set_refresh_mode( + RefreshMode.PERIODIC, + f"initial gateway startup from an invalid state {self.refresh_mode}", + ) + + async def configure_by_message(self, *, topic: str, payload: str) -> None: + payload = payload.lower() + match topic: + case mqtt_topics.REFRESH_MODE_SET: + try: + refresh_mode = RefreshMode.get(payload) + self.set_refresh_mode( + refresh_mode, "MQTT direct set refresh mode command execution" + ) + except KeyError as e: + msg = f"Unsupported payload {payload}" + raise MqttGatewayException(msg) from e + case mqtt_topics.REFRESH_PERIOD_ACTIVE_SET: + try: + seconds = int(payload) + self.set_refresh_period_active(seconds) + except ValueError as e: + msg = f"Error setting value for payload {payload}" + raise MqttGatewayException(msg) from e + case mqtt_topics.REFRESH_PERIOD_INACTIVE_SET: + try: + seconds = int(payload) + self.set_refresh_period_inactive(seconds) + except ValueError as e: + msg = f"Error setting value for paylo d {payload}" + raise MqttGatewayException(msg) from e + case mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN_SET: + try: + seconds = int(payload) + self.set_refresh_period_after_shutdown(seconds) + except ValueError as e: + msg = f"Error setting value for payload {payload}" + raise MqttGatewayException(msg) from e + case mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE_SET: + try: + seconds = int(payload) + self.set_refresh_period_inactive_grace(seconds) + except ValueError as e: + msg = f"Error setting value for payload {payload}" + raise MqttGatewayException(msg) from e + case _: + msg = f"Unsupported topic {topic}" + raise MqttGatewayException(msg) + + def handle_charge_status( + self, charge_info_resp: ChrgMgmtDataResp + ) -> ChrgMgmtDataRespProcessingResult: + result = self.__charge_response_publisher.on_chrg_mgmt_data_resp( + charge_info_resp + ) + + if result.scheduled_charging is not None: + self.update_scheduled_charging( + result.scheduled_charging.start_time, result.scheduled_charging.mode + ) + + if result.charge_current_limit is not None: + self.update_charge_current_limit(result.charge_current_limit) + + if result.target_soc is not None: + self.update_target_soc(result.target_soc) + + # We are charging if the BMS tells us so + self.is_charging = result.is_charging or False + + if self.is_charging and result.power is not None and result.power < -1: + # Only compute a dynamic refresh period if we have detected at least 1kW of power during charging + time_for_1pct = ( + 36.0 * result.real_total_battery_capacity / abs(result.power) + ) + time_for_min_pct = math.ceil( + self.charge_polling_min_percent * time_for_1pct + ) + # It doesn't make sense to refresh less often than the estimated time for completion + if ( + result.remaining_charging_time is not None + and result.remaining_charging_time > 0 + ): + computed_refresh_period = float( + min(result.remaining_charging_time, time_for_min_pct) + ) + else: + computed_refresh_period = time_for_1pct + self.set_refresh_period_charging(computed_refresh_period) + elif not self.is_charging: + # Reset the charging refresh period if we detected we are no longer charging + self.set_refresh_period_charging(0) + else: + # Otherwise let's keep the last computed refresh period + # This avoids falling back to the active refresh period which, being too often, results in a ban from + # the SAIC API + pass + + return result + + def update_data_conflicting_in_vehicle_and_bms( + self, + vehicle_status: VehicleStatusRespProcessingResult, + charge_status: ChrgMgmtDataRespProcessingResult | None, + ) -> None: + # Deduce if the car is awake or not + hv_battery_active = self.is_charging or self.hv_battery_active_from_car + LOG.debug( + f"Vehicle {self.vin} hv_battery_active={hv_battery_active}. " + f"is_charging={self.is_charging} " + f"hv_battery_active_from_car={self.hv_battery_active_from_car}" + ) + self.hv_battery_active = hv_battery_active + + # We can read this from either the BMS or the Vehicle Info + electric_range_published = False + soc_published = False + + if charge_status is not None: + if (range := charge_status.raw_fuel_range_elec) is not None: + electric_range_published = self.__publish_electric_range(range) + + if (soc := charge_status.raw_soc) is not None: + soc_published = self.__publish_soc(soc / 10.0) + + if not electric_range_published: + electric_range_published = self.__publish_electric_range( + vehicle_status.fuel_range_elec + ) + if not soc_published: + soc_published = self.__publish_soc(vehicle_status.raw_soc) + + if not electric_range_published: + logging.warning("Could not extract a valid electric range") + + if not soc_published: + logging.warning("Could not extract a valid SoC") + + def handle_scheduled_battery_heating_status( + self, scheduled_battery_heating_status: ScheduledBatteryHeatingResp | None + ) -> None: + if scheduled_battery_heating_status: + is_enabled = scheduled_battery_heating_status.is_enabled + if is_enabled: + start_time = scheduled_battery_heating_status.decoded_start_time + else: + start_time = self.__scheduled_battery_heating_start + else: + start_time = self.__scheduled_battery_heating_start + is_enabled = False + + self.update_scheduled_battery_heating(start_time, is_enabled) + + def update_scheduled_battery_heating( + self, start_time: datetime.time | None, enabled: bool + ) -> bool: + changed = False + if self.__scheduled_battery_heating_start != start_time: + self.__scheduled_battery_heating_start = start_time + changed = True + if self.__scheduled_battery_heating_enabled != enabled: + self.__scheduled_battery_heating_enabled = enabled + changed = True + + computed_mode = ( + "on" + if start_time is not None and self.__scheduled_battery_heating_enabled + else "off" + ) + computed_start_time = ( + start_time.strftime("%H:%M") if start_time is not None else "00:00" + ) + self.publisher.publish_json( + self.get_topic(mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE), + {"mode": computed_mode, "startTime": computed_start_time}, + ) + return changed + + def get_topic(self, sub_topic: str) -> str: + return f"{self.mqtt_vin_prefix}/{sub_topic}" + + def set_refresh_mode(self, mode: RefreshMode, cause: str) -> None: + if mode is not None and ( + self.refresh_mode is None or self.refresh_mode != mode + ): + new_mode_value = mode.value + LOG.info("Setting refresh mode to %s due to %s", new_mode_value, cause) + self.publisher.publish_str( + self.get_topic(mqtt_topics.REFRESH_MODE), new_mode_value + ) + # Make sure we never store FORCE as previous refresh mode + if self.refresh_mode != RefreshMode.FORCE: + self.previous_refresh_mode = self.refresh_mode + self.refresh_mode = mode + LOG.debug("Refresh mode set to %s due to %s", self.refresh_mode, cause) + + @property + def is_heated_seats_running(self) -> bool: + return ( + self.__remote_heated_seats_front_right_level + + self.__remote_heated_seats_front_left_level + ) > 0 + + @property + def remote_heated_seats_front_left_level(self) -> int: + return self.__remote_heated_seats_front_left_level + + def update_heated_seats_front_left_level(self, level: int) -> bool: + if not self.__check_heated_seats_level(level): + return False + changed = self.__remote_heated_seats_front_left_level != level + self.__remote_heated_seats_front_left_level = level + return changed + + @property + def remote_heated_seats_front_right_level(self) -> int: + return self.__remote_heated_seats_front_right_level + + def update_heated_seats_front_right_level(self, level: int) -> bool: + if not self.__check_heated_seats_level(level): + return False + changed = self.__remote_heated_seats_front_right_level != level + self.__remote_heated_seats_front_right_level = level + return changed + + def __check_heated_seats_level(self, level: int) -> bool: + if not self.vehicle.has_heated_seats: + return False + if self.vehicle.has_level_heated_seats and not (0 <= level <= 3): + msg = f"Invalid heated seat level {level}. Range must be from 0 to 3 inclusive" + raise ValueError(msg) + if self.vehicle.has_on_off_heated_seats and not (0 <= level <= 1): + msg = f"Invalid heated seat level {level}. Range must be from 0 to 1 inclusive" + raise ValueError(msg) + return True + + def get_remote_ac_temperature(self) -> int: + return self.__remote_ac_temp or DEFAULT_AC_TEMP + + def set_ac_temperature(self, temp: int) -> bool: + temp = max( + self.vehicle.min_ac_temperature, + min(self.vehicle.max_ac_temperature, temp), + ) + if self.__remote_ac_temp != temp: + self.__remote_ac_temp = temp + LOG.info("Updating remote AC temperature to %d", temp) + self.publisher.publish_int( + self.get_topic(mqtt_topics.CLIMATE_REMOTE_TEMPERATURE), temp + ) + return True + return False + + def get_ac_temperature_idx(self) -> int: + return self.vehicle.get_ac_temperature_idx(self.get_remote_ac_temperature()) + + @property + def is_remote_ac_running(self) -> bool: + return self.__remote_ac_running + + def __publish( + self, + *, + topic: str, + value: Publishable | None, + validator: Callable[[Publishable], bool] = lambda _: True, + no_prefix: bool = False, + ) -> tuple[bool, Publishable | None]: + if value is None or not validator(value): + return False, None + actual_topic = topic if no_prefix else self.get_topic(topic) + published = self.__publish_directly(topic=actual_topic, value=value) + return published, value + + def __transform_and_publish( + self, + *, + topic: str, + value: T | None, + validator: Callable[[T], bool] = lambda _: True, + transform: Callable[[T], Publishable], + no_prefix: bool = False, + ) -> tuple[bool, Publishable | None]: + if value is None or not validator(value): + return False, None + actual_topic = topic if no_prefix else self.get_topic(topic) + transformed_value = transform(value) + published = self.__publish_directly(topic=actual_topic, value=transformed_value) + return published, transformed_value + + def __publish_directly(self, *, topic: str, value: Publishable) -> bool: + published = False + if isinstance(value, bool): + self.publisher.publish_bool(topic, value) + published = True + elif isinstance(value, int): + self.publisher.publish_int(topic, value) + published = True + elif isinstance(value, float): + self.publisher.publish_float(topic, value) + published = True + elif isinstance(value, str): + self.publisher.publish_str(topic, value) + published = True + elif isinstance(value, dict): + self.publisher.publish_json(topic, value) + published = True + elif isinstance(value, datetime.datetime): + self.publisher.publish_str(topic, datetime_to_str(value)) + published = True + return published + + @property + def vin(self) -> str: + return self.vehicle.vin diff --git a/src/vehicle_info.py b/src/vehicle_info.py new file mode 100644 index 0000000..7025e84 --- /dev/null +++ b/src/vehicle_info.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from exceptions import MqttGatewayException + +if TYPE_CHECKING: + from saic_ismart_client_ng.api.vehicle import VehicleModelConfiguration, VinInfo + + +class VehicleInfo: + def __init__( + self, + vin_info: VinInfo, + custom_battery_capacity: float | None, + ) -> None: + if not vin_info.vin: + raise MqttGatewayException("Could not handle a car without a vin") + self.vin: Final[str] = vin_info.vin + self.configuration: Final[list[VehicleModelConfiguration]] = ( + vin_info.vehicleModelConfiguration or [] + ) + self.brand: Final[str] = str(vin_info.brandName or "").strip() + self.model: Final[str] = str(vin_info.modelName or "").strip().upper() + self.model_year: Final[str] = str(vin_info.modelYear or "").strip() + self.series: Final[str] = str(vin_info.series or "").strip().upper() + self.color: Final[str] = str(vin_info.colorName or "").strip() + self.properties: Final[dict[str, dict[str, str | None]]] = ( + self.__properties_from_configuration(self.configuration) + ) + self.__custom_battery_capacity: float | None = custom_battery_capacity + + @staticmethod + def __properties_from_configuration( + configuration: list[VehicleModelConfiguration], + ) -> dict[str, dict[str, str | None]]: + properties = {} + for c in configuration: + property_name = c.itemName + property_code = c.itemCode + property_value = c.itemValue + if property_name is not None: + properties[property_name] = { + "name": property_name, + "code": property_code, + "value": property_value, + } + if property_code is not None: + properties[property_code] = { + "name": property_name, + "code": property_code, + "value": property_value, + } + return properties + + def get_ac_temperature_idx(self, remote_ac_temperature: int) -> int: + if self.series.startswith("EH32"): + return 3 + remote_ac_temperature - self.min_ac_temperature + return 2 + remote_ac_temperature - self.min_ac_temperature + + @property + def min_ac_temperature(self) -> int: + if self.series.startswith("EH32"): + return 17 + return 16 + + @property + def max_ac_temperature(self) -> int: + if self.series.startswith("EH32"): + return 33 + return 28 + + def __get_property_value(self, property_name: str) -> str | None: + if property_name in self.properties: + pdict = self.properties[property_name] + if pdict is not None and isinstance(pdict, dict) and "value" in pdict: + return pdict["value"] + return None + + @property + def is_ev(self) -> bool: + return not self.series.startswith("ZP22") + + @property + def has_fossil_fuel(self) -> bool: + return not self.is_ev + + @property + def has_sunroof(self) -> bool: + return self.__get_property_value("Sunroof") != "0" + + @property + def has_on_off_heated_seats(self) -> bool: + return self.__get_property_value("HeatedSeat") == "2" + + @property + def has_level_heated_seats(self) -> bool: + return self.__get_property_value("HeatedSeat") == "1" + + @property + def has_heated_seats(self) -> bool: + return self.has_level_heated_seats or self.has_on_off_heated_seats + + @property + def supports_target_soc(self) -> bool: + return self.__get_property_value("Battery") == "1" + + @property + def battery_capacity(self) -> float | None: + if ( + self.__custom_battery_capacity is not None + and self.__custom_battery_capacity > 0 + ): + return float(self.__custom_battery_capacity) + # MG4 high trim level + if self.series.startswith("EH32 S"): + if self.model.startswith("EH32 X3"): + # MG4 Trophy Extended Range + return 77.0 + if self.supports_target_soc: + # MG4 high trim level with NMC battery + return 64.0 + # MG4 High trim level with LFP battery + return 51.0 + # MG4 low trim level + # Note: EH32 X/ is used for the 2023 MY with both NMC and LFP batter chem + if self.series.startswith("EH32 L"): + if self.supports_target_soc: + # MG4 low trim level with NMC battery + return 64.0 + # MG4 low trim level with LFP battery + return 51.0 + # Model: MG5 Electric, variant MG5 SR Comfort + if self.series.startswith("EP2CP3"): + return 50.3 + # Model: MG5 Electric, variant MG5 MR Luxury + if self.series.startswith("EP2DP3"): + return 61.1 + # ZS EV Standard 2021 + if self.series.startswith("ZS EV S"): + return 49.0 + return None diff --git a/tests/__init__.py b/tests/__init__.py index 511a833..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,18 +0,0 @@ -import logging -from typing import override - -from configuration import Configuration -from publisher.log_publisher import ConsolePublisher - -LOG = logging.getLogger(__name__) - - -class MessageCapturingConsolePublisher(ConsolePublisher): - def __init__(self, configuration: Configuration): - super().__init__(configuration) - self.map = {} - - @override - def internal_publish(self, key, value): - self.map[key] = value - LOG.debug(f'{key}: {value}') diff --git a/tests/common_mocks.py b/tests/common_mocks.py index a4488b5..38c0f8c 100644 --- a/tests/common_mocks.py +++ b/tests/common_mocks.py @@ -1,22 +1,27 @@ +from __future__ import annotations + import time from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus from saic_ismart_client_ng.api.vehicle import VehicleStatusResp from saic_ismart_client_ng.api.vehicle.schema import BasicVehicleStatus from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp -from saic_ismart_client_ng.api.vehicle_charging.schema import ChrgMgmtData, RvsChargeStatus +from saic_ismart_client_ng.api.vehicle_charging.schema import ( + ChrgMgmtData, + RvsChargeStatus, +) -VIN = 'vin10000000000000' +VIN = "vin10000000000000" -DRIVETRAIN_RUNNING = False +DRIVETRAIN_RUNNING = True DRIVETRAIN_CHARGING = True DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE = 42 DRIVETRAIN_MILEAGE = 4000 DRIVETRAIN_RANGE_BMS = 250 DRIVETRAIN_RANGE_VEHICLE = 350 -DRIVETRAIN_CURRENT = 42 +DRIVETRAIN_CURRENT = -42 DRIVETRAIN_VOLTAGE = 42 -DRIVETRAIN_POWER = 1.764 +DRIVETRAIN_POWER = -1.764 DRIVETRAIN_SOC_BMS = 96.3 DRIVETRAIN_SOC_VEHICLE = 48 DRIVETRAIN_HYBRID_ELECTRICAL_RANGE = 0 @@ -30,7 +35,9 @@ DRIVETRAIN_CHARGING_CABLE_LOCK = 1 REAL_TOTAL_BATTERY_CAPACITY = 64.0 RAW_TOTAL_BATTERY_CAPACITY = 72.5 -BATTERY_CAPACITY_CORRECTION_FACTOR = REAL_TOTAL_BATTERY_CAPACITY / RAW_TOTAL_BATTERY_CAPACITY +BATTERY_CAPACITY_CORRECTION_FACTOR = ( + REAL_TOTAL_BATTERY_CAPACITY / RAW_TOTAL_BATTERY_CAPACITY +) CLIMATE_INTERIOR_TEMPERATURE = 22 CLIMATE_EXTERIOR_TEMPERATURE = 18 @@ -51,10 +58,10 @@ DOORS_LOCKED = True DOORS_DRIVER = False -DOORS_PASSENGER = False +DOORS_PASSENGER = True DOORS_REAR_LEFT = False DOORS_REAR_RIGHT = False -DOORS_BONNET = False +DOORS_BONNET = True DOORS_BOOT = False TYRES_FRONT_LEFT_PRESSURE = 2.8 @@ -66,12 +73,14 @@ LIGHTS_DIPPED_BEAM = False LIGHTS_SIDE = False +BMS_CHARGE_STATUS = "CHARGING_1" + -def get_mock_vehicle_status_resp(): +def get_mock_vehicle_status_resp() -> VehicleStatusResp: return VehicleStatusResp( statusTime=int(time.time()), basicVehicleStatus=BasicVehicleStatus( - engineStatus=0, + engineStatus=1 if DRIVETRAIN_RUNNING else 0, extendedData1=DRIVETRAIN_SOC_VEHICLE, extendedData2=1 if DRIVETRAIN_CHARGING else 0, batteryVoltage=DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE * 10, @@ -92,6 +101,7 @@ def get_mock_vehicle_status_resp(): rearRightDoor=DOORS_REAR_RIGHT, rearLeftDoor=DOORS_REAR_LEFT, bootStatus=DOORS_BOOT, + bonnetStatus=DOORS_BONNET, frontLeftTyrePressure=int(TYRES_FRONT_LEFT_PRESSURE * 25), frontRightTyrePressure=int(TYRES_FRONT_RIGHT_PRESSURE * 25), rearLeftTyrePressure=int(TYRES_REAR_LEFT_PRESSURE * 25), @@ -100,7 +110,7 @@ def get_mock_vehicle_status_resp(): dippedBeamStatus=LIGHTS_DIPPED_BEAM, sideLightStatus=LIGHTS_SIDE, frontLeftSeatHeatLevel=0, - frontRightSeatHeatLevel=1 + frontRightSeatHeatLevel=1, ), gpsPosition=GpsPosition( gpsStatus=GpsStatus.FIX_3d.value, @@ -109,18 +119,18 @@ def get_mock_vehicle_status_resp(): position=GpsPosition.WayPoint.Position( latitude=int(LOCATION_LATITUDE * 1000000), longitude=int(LOCATION_LONGITUDE * 1000000), - altitude=LOCATION_ELEVATION + altitude=LOCATION_ELEVATION, ), heading=LOCATION_HEADING, hdop=0, satellites=3, speed=20, - ) - ) + ), + ), ) -def get_moc_charge_management_data_resp(): +def get_mock_charge_management_data_resp() -> ChrgMgmtDataResp: return ChrgMgmtDataResp( chrgMgmtData=ChrgMgmtData( bmsPackCrntV=0, @@ -128,18 +138,25 @@ def get_moc_charge_management_data_resp(): bmsPackVol=DRIVETRAIN_VOLTAGE * 4, bmsPackSOCDsp=int(DRIVETRAIN_SOC_BMS * 10.0), bmsEstdElecRng=int(DRIVETRAIN_HYBRID_ELECTRICAL_RANGE * 10.0), - ccuEleccLckCtrlDspCmd=1 + ccuEleccLckCtrlDspCmd=1, + bmsChrgSts=1 if DRIVETRAIN_CHARGING else 0, ), rvsChargeStatus=RvsChargeStatus( mileageOfDay=int(DRIVETRAIN_MILEAGE_OF_DAY * 10.0), mileageSinceLastCharge=int(DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE * 10.0), - realtimePower=int((DRIVETRAIN_SOC_KWH / BATTERY_CAPACITY_CORRECTION_FACTOR) * 10), + realtimePower=int( + (DRIVETRAIN_SOC_KWH / BATTERY_CAPACITY_CORRECTION_FACTOR) * 10 + ), chargingType=DRIVETRAIN_CHARGING_TYPE, chargingGunState=DRIVETRAIN_CHARGER_CONNECTED, lastChargeEndingPower=int( - (DRIVETRAIN_LAST_CHARGE_ENDING_POWER / BATTERY_CAPACITY_CORRECTION_FACTOR) * 10.0), + ( + DRIVETRAIN_LAST_CHARGE_ENDING_POWER + / BATTERY_CAPACITY_CORRECTION_FACTOR + ) + * 10.0 + ), totalBatteryCapacity=int(RAW_TOTAL_BATTERY_CAPACITY * 10.0), - fuelRangeElec=int(DRIVETRAIN_RANGE_BMS * 10.0) + fuelRangeElec=int(DRIVETRAIN_RANGE_BMS * 10.0), ), - ) diff --git a/tests/mocks/__init__.py b/tests/mocks/__init__.py new file mode 100644 index 0000000..0818d80 --- /dev/null +++ b/tests/mocks/__init__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, override + +from publisher.log_publisher import ConsolePublisher + +if TYPE_CHECKING: + from configuration import Configuration + +LOG = logging.getLogger(__name__) + + +class MessageCapturingConsolePublisher(ConsolePublisher): + def __init__(self, configuration: Configuration) -> None: + super().__init__(configuration) + self.map: dict[str, Any] = {} + + @override + def internal_publish(self, key: str, value: Any) -> None: + self.map[key] = value + LOG.debug(f"{key}: {value}") diff --git a/tests/test_mqtt_publisher.py b/tests/test_mqtt_publisher.py index b5e61db..f8ac595 100644 --- a/tests/test_mqtt_publisher.py +++ b/tests/test_mqtt_publisher.py @@ -1,59 +1,74 @@ +from __future__ import annotations + +from typing import Any, override import unittest -from typing import override from configuration import Configuration, TransportProtocol from publisher.core import MqttCommandListener from publisher.mqtt_publisher import MqttPublisher -USER = 'me@home.da' -VIN = 'vin10000000000000' -DELAY = '42' -MODE = 'periodic' -LOCK_STATE = 'true' -REAR_WINDOW_HEAT_STATE = 'on' +USER = "me@home.da" +VIN = "vin10000000000000" +DELAY = "42" +MODE = "periodic" +LOCK_STATE = "true" +REAR_WINDOW_HEAT_STATE = "on" class TestMqttPublisher(unittest.IsolatedAsyncioTestCase, MqttCommandListener): @override - async def on_mqtt_command_received(self, *, vin: str, topic: str, payload: str) -> None: + async def on_mqtt_global_command_received( + self, *, topic: str, payload: str + ) -> None: + pass + + @override + async def on_mqtt_command_received( + self, *, vin: str, topic: str, payload: str + ) -> None: self.received_vin = vin self.received_payload = payload.strip().lower() @override def setUp(self) -> None: config = Configuration() - config.mqtt_topic = 'saic' - config.saic_user = 'user+a#b*c>d$e' + config.mqtt_topic = "saic" + config.saic_user = "user+a#b*c>d$e" config.mqtt_transport_protocol = TransportProtocol.TCP self.mqtt_client = MqttPublisher(config) self.mqtt_client.command_listener = self - self.received_vin = '' - self.received_payload = '' - self.vehicle_base_topic = f'{self.mqtt_client.configuration.mqtt_topic}/{USER}/vehicles/{VIN}' + self.received_vin = "" + self.received_payload = "" + self.vehicle_base_topic = ( + f"{self.mqtt_client.configuration.mqtt_topic}/{USER}/vehicles/{VIN}" + ) - def test_special_character_username(self): - self.assertEqual('saic/user_a_b_c_d_e', self.mqtt_client.get_mqtt_account_prefix()) + def test_special_character_username(self) -> None: + assert self.mqtt_client.get_mqtt_account_prefix() == "saic/user_a_b_c_d_e" - async def test_update_mode(self): - topic = 'refresh/mode/set' - full_topic = f'{self.vehicle_base_topic}/{topic}' + async def test_update_mode(self) -> None: + topic = "refresh/mode/set" + full_topic = f"{self.vehicle_base_topic}/{topic}" await self.send_message(full_topic, MODE) - self.assertEqual(VIN, self.received_vin) - self.assertEqual(MODE, self.received_payload) + assert self.received_vin == VIN + assert self.received_payload == MODE - async def test_update_lock_state(self): - topic = 'doors/locked/set' - full_topic = f'{self.vehicle_base_topic}/{topic}' + async def test_update_lock_state(self) -> None: + topic = "doors/locked/set" + full_topic = f"{self.vehicle_base_topic}/{topic}" await self.send_message(full_topic, LOCK_STATE) - self.assertEqual(VIN, self.received_vin) - self.assertEqual(LOCK_STATE, self.received_payload) + assert self.received_vin == VIN + assert self.received_payload == LOCK_STATE - async def test_update_rear_window_heat_state(self): - topic = 'climate/rearWindowDefrosterHeating/set' - full_topic = f'{self.vehicle_base_topic}/{topic}' + async def test_update_rear_window_heat_state(self) -> None: + topic = "climate/rearWindowDefrosterHeating/set" + full_topic = f"{self.vehicle_base_topic}/{topic}" await self.send_message(full_topic, REAR_WINDOW_HEAT_STATE) - self.assertEqual(VIN, self.received_vin) - self.assertEqual(REAR_WINDOW_HEAT_STATE, self.received_payload) + assert self.received_vin == VIN + assert self.received_payload == REAR_WINDOW_HEAT_STATE + + async def send_message(self, topic: str, payload: Any) -> None: + await self.mqtt_client.client.on_message("client", topic, payload, 0, {}) - async def send_message(self, topic, payload): - await self.mqtt_client.client.on_message('client', topic, payload, 0, dict()) + async def on_charging_detected(self, vin: str) -> None: + pass diff --git a/tests/test_utils.py b/tests/test_utils.py index 5696109..cf9ccc2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime from unittest import TestCase @@ -8,127 +10,108 @@ class Test(TestCase): - def test_get_update_timestamp_should_return_vehicle_if_closest(self): - base_ts = datetime.datetime.now(tz=datetime.timezone.utc) + def test_get_update_timestamp_should_return_vehicle_if_closest(self) -> None: + base_ts = datetime.datetime.now(tz=datetime.UTC) ts_plus_30_s = base_ts + datetime.timedelta(minutes=1) vehicle_status_resp = VehicleStatusResp( statusTime=int(base_ts.timestamp()), gpsPosition=GpsPosition( gpsStatus=GpsStatus.FIX_3d.value, timeStamp=int(ts_plus_30_s.timestamp()), - ) + ), ) result = get_update_timestamp(vehicle_status_resp) - self.assertEqual( - int(result.timestamp()), - int(base_ts.timestamp()), + assert int(result.timestamp()) == int(base_ts.timestamp()), ( "This test should have selected the vehicle timestamp" ) - self.assertTrue( - result <= datetime.datetime.now(tz=datetime.timezone.utc) - ) + assert result <= datetime.datetime.now(tz=datetime.UTC) - def test_get_update_timestamp_should_return_gps_if_closest(self): - base_ts = datetime.datetime.now(tz=datetime.timezone.utc) + def test_get_update_timestamp_should_return_gps_if_closest(self) -> None: + base_ts = datetime.datetime.now(tz=datetime.UTC) ts_plus_30_s = base_ts + datetime.timedelta(minutes=1) vehicle_status_resp = VehicleStatusResp( statusTime=int(ts_plus_30_s.timestamp()), gpsPosition=GpsPosition( gpsStatus=GpsStatus.FIX_3d.value, timeStamp=int(base_ts.timestamp()), - ) + ), ) result = get_update_timestamp(vehicle_status_resp) - self.assertEqual( - int(result.timestamp()), - int(base_ts.timestamp()), + assert int(result.timestamp()) == int(base_ts.timestamp()), ( "This test should have selected the GPS timestamp" ) - self.assertTrue( - result <= datetime.datetime.now(tz=datetime.timezone.utc) - ) + assert result <= datetime.datetime.now(tz=datetime.UTC) - def test_get_update_timestamp_should_return_now_if_drift_too_much(self): - base_ts = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(minutes=30) + def test_get_update_timestamp_should_return_now_if_drift_too_much(self) -> None: + base_ts = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta( + minutes=30 + ) ts_plus_30_s = base_ts + datetime.timedelta(minutes=1) vehicle_status_resp = VehicleStatusResp( statusTime=int(ts_plus_30_s.timestamp()), gpsPosition=GpsPosition( gpsStatus=GpsStatus.FIX_3d.value, timeStamp=int(base_ts.timestamp()), - ) + ), ) result = get_update_timestamp(vehicle_status_resp) - self.assertNotEqual( - int(result.timestamp()), - int(ts_plus_30_s.timestamp()), + assert int(result.timestamp()) != int(ts_plus_30_s.timestamp()), ( "This test should have NOT selected the vehicle timestamp" ) - self.assertNotEqual( - int(result.timestamp()), - int(base_ts.timestamp()), + assert int(result.timestamp()) != int(base_ts.timestamp()), ( "This test should have NOT selected the GPS timestamp" ) - self.assertTrue( - result <= datetime.datetime.now(tz=datetime.timezone.utc) - ) + assert result <= datetime.datetime.now(tz=datetime.UTC) - def test_get_update_should_return_now_if_no_other_info_is_there(self): - base_ts = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(minutes=30) + def test_get_update_should_return_now_if_no_other_info_is_there(self) -> None: + base_ts = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta( + minutes=30 + ) ts_plus_30_s = base_ts + datetime.timedelta(minutes=1) vehicle_status_resp = VehicleStatusResp( statusTime=None, gpsPosition=GpsPosition( gpsStatus=GpsStatus.FIX_3d.value, timeStamp=None, - ) + ), ) result = get_update_timestamp(vehicle_status_resp) - self.assertNotEqual( - int(result.timestamp()), - int(ts_plus_30_s.timestamp()), + assert int(result.timestamp()) != int(ts_plus_30_s.timestamp()), ( "This test should have NOT selected the vehicle timestamp" ) - self.assertNotEqual( - int(result.timestamp()), - int(base_ts.timestamp()), + assert int(result.timestamp()) != int(base_ts.timestamp()), ( "This test should have NOT selected the GPS timestamp" ) - self.assertTrue( - result <= datetime.datetime.now(tz=datetime.timezone.utc) - ) + assert result <= datetime.datetime.now(tz=datetime.UTC) - def test_get_update_should_return_now_if_no_other_info_is_there_v2(self): + def test_get_update_should_return_now_if_no_other_info_is_there_v2(self) -> None: vehicle_status_resp = VehicleStatusResp( statusTime=None, ) result = get_update_timestamp(vehicle_status_resp) - self.assertIsNotNone( - int(result.timestamp()), + assert int(result.timestamp()) is not None, ( "This test should have returned a timestamp" ) - self.assertTrue( - result <= datetime.datetime.now(tz=datetime.timezone.utc) - ) + assert result <= datetime.datetime.now(tz=datetime.UTC) - def test_get_update_should_return_now_if_no_other_info_is_there_v3(self): + def test_get_update_should_return_now_if_no_other_info_is_there_v3(self) -> None: vehicle_status_resp = VehicleStatusResp( gpsPosition=GpsPosition( gpsStatus=GpsStatus.FIX_3d.value, @@ -138,11 +121,8 @@ def test_get_update_should_return_now_if_no_other_info_is_there_v3(self): result = get_update_timestamp(vehicle_status_resp) - self.assertIsNotNone( - int(result.timestamp()), + assert int(result.timestamp()) is not None, ( "This test should have returned a timestamp" ) - self.assertTrue( - result <= datetime.datetime.now(tz=datetime.timezone.utc) - ) + assert result <= datetime.datetime.now(tz=datetime.UTC) diff --git a/tests/test_vehicle_handler.py b/tests/test_vehicle_handler.py index ab95bcf..c77f565 100644 --- a/tests/test_vehicle_handler.py +++ b/tests/test_vehicle_handler.py @@ -1,225 +1,411 @@ +from __future__ import annotations + +from typing import Any import unittest from unittest.mock import patch from apscheduler.schedulers.blocking import BlockingScheduler +from common_mocks import ( + BMS_CHARGE_STATUS, + CLIMATE_EXTERIOR_TEMPERATURE, + CLIMATE_INTERIOR_TEMPERATURE, + DOORS_BONNET, + DOORS_BOOT, + DOORS_DRIVER, + DOORS_LOCKED, + DOORS_PASSENGER, + DOORS_REAR_LEFT, + DOORS_REAR_RIGHT, + DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE, + DRIVETRAIN_CHARGER_CONNECTED, + DRIVETRAIN_CHARGING, + DRIVETRAIN_CHARGING_CABLE_LOCK, + DRIVETRAIN_CHARGING_TYPE, + DRIVETRAIN_CURRENT, + DRIVETRAIN_HYBRID_ELECTRICAL_RANGE, + DRIVETRAIN_LAST_CHARGE_ENDING_POWER, + DRIVETRAIN_MILEAGE, + DRIVETRAIN_MILEAGE_OF_DAY, + DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE, + DRIVETRAIN_POWER, + DRIVETRAIN_REMAINING_CHARGING_TIME, + DRIVETRAIN_RUNNING, + DRIVETRAIN_SOC_KWH, + DRIVETRAIN_VOLTAGE, + LIGHTS_DIPPED_BEAM, + LIGHTS_MAIN_BEAM, + LIGHTS_SIDE, + LOCATION_ELEVATION, + LOCATION_HEADING, + LOCATION_LATITUDE, + LOCATION_LONGITUDE, + LOCATION_SPEED, + REAL_TOTAL_BATTERY_CAPACITY, + TYRES_FRONT_LEFT_PRESSURE, + TYRES_FRONT_RIGHT_PRESSURE, + TYRES_REAR_LEFT_PRESSURE, + TYRES_REAR_RIGHT_PRESSURE, + VIN, + WINDOWS_DRIVER, + WINDOWS_PASSENGER, + WINDOWS_REAR_LEFT, + WINDOWS_REAR_RIGHT, + WINDOWS_SUN_ROOF, + get_mock_charge_management_data_resp, + get_mock_vehicle_status_resp, +) +from mocks import MessageCapturingConsolePublisher +import pytest from saic_ismart_client_ng import SaicApi -from saic_ismart_client_ng.api.vehicle.schema import VinInfo, VehicleModelConfiguration +from saic_ismart_client_ng.api.vehicle.schema import ( + VehicleModelConfiguration, + VinInfo, +) from saic_ismart_client_ng.model import SaicApiConfiguration -import mqtt_topics from configuration import Configuration from handlers.relogin import ReloginHandler from mqtt_gateway import VehicleHandler -from tests import MessageCapturingConsolePublisher -from tests.common_mocks import * +import mqtt_topics from vehicle import VehicleState - - -def mock_vehicle_status(mocked_vehicle_status): - mocked_vehicle_status.return_value = get_mock_vehicle_status_resp() - - -def mock_charge_status(mocked_charge_status): - mocked_charge_status.return_value = get_moc_charge_management_data_resp() +from vehicle_info import VehicleInfo class TestVehicleHandler(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: config = Configuration() config.anonymized_publishing = False - saicapi = SaicApi(configuration=SaicApiConfiguration( - username='aaa@nowhere.org', - password='xxxxxxxxx' - ), listener=None) - publisher = MessageCapturingConsolePublisher(config) + self.saicapi = SaicApi( + configuration=SaicApiConfiguration( + username="aaa@nowhere.org", + password="xxxxxxxxx", # noqa: S106 + ), + listener=None, + ) + self.publisher = MessageCapturingConsolePublisher(config) vin_info = VinInfo() vin_info.vin = VIN - vin_info.series = 'EH32 S' - vin_info.modelName = 'MG4 Electric' - vin_info.modelYear = 2022 + vin_info.series = "EH32 S" + vin_info.modelName = "MG4 Electric" + vin_info.modelYear = "2022" vin_info.vehicleModelConfiguration = [ - VehicleModelConfiguration('BATTERY', 'BATTERY', '1'), - VehicleModelConfiguration('BType', 'Battery', '1'), + VehicleModelConfiguration("BATTERY", "BATTERY", "1"), + VehicleModelConfiguration("BType", "Battery", "1"), ] - account_prefix = f'/vehicles/{VIN}' + vehicle_info = VehicleInfo(vin_info, None) + account_prefix = f"/vehicles/{VIN}" scheduler = BlockingScheduler() - vehicle_state = VehicleState(publisher, scheduler, account_prefix, vin_info) + vehicle_state = VehicleState( + self.publisher, scheduler, account_prefix, vehicle_info + ) mock_relogin_handler = ReloginHandler( - relogin_relay=30, - api=saicapi, - scheduler=None - ) - self.vehicle_handler = VehicleHandler(config, mock_relogin_handler, saicapi, publisher, vin_info, vehicle_state) - - @patch.object(SaicApi, 'get_vehicle_status') - async def test_update_vehicle_status(self, mocked_vehicle_status): - mock_vehicle_status(mocked_vehicle_status) - await self.vehicle_handler.update_vehicle_status() - - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_RUNNING), DRIVETRAIN_RUNNING) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING), DRIVETRAIN_CHARGING) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE), - DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE), DRIVETRAIN_MILEAGE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE), - CLIMATE_INTERIOR_TEMPERATURE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_EXTERIOR_TEMPERATURE), - CLIMATE_EXTERIOR_TEMPERATURE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE), - 'on') - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_BACK_WINDOW_HEAT), - 'on') - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LOCATION_SPEED), LOCATION_SPEED) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LOCATION_HEADING), LOCATION_HEADING) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LOCATION_LATITUDE), LOCATION_LATITUDE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LOCATION_LONGITUDE), LOCATION_LONGITUDE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LOCATION_ELEVATION), LOCATION_ELEVATION) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_DRIVER), WINDOWS_DRIVER) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_PASSENGER), WINDOWS_PASSENGER) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_REAR_LEFT), WINDOWS_REAR_LEFT) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_REAR_RIGHT), WINDOWS_REAR_RIGHT) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_SUN_ROOF), WINDOWS_SUN_ROOF) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DOORS_LOCKED), DOORS_LOCKED) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DOORS_DRIVER), DOORS_DRIVER) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DOORS_PASSENGER), DOORS_PASSENGER) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DOORS_REAR_LEFT), DOORS_REAR_LEFT) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DOORS_REAR_RIGHT), DOORS_REAR_RIGHT) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DOORS_BONNET), DOORS_BONNET) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DOORS_BOOT), DOORS_BOOT) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.TYRES_FRONT_LEFT_PRESSURE), - TYRES_FRONT_LEFT_PRESSURE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.TYRES_FRONT_RIGHT_PRESSURE), - TYRES_FRONT_RIGHT_PRESSURE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.TYRES_REAR_LEFT_PRESSURE), - TYRES_REAR_LEFT_PRESSURE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.TYRES_REAR_RIGHT_PRESSURE), - TYRES_REAR_RIGHT_PRESSURE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_MAIN_BEAM), LIGHTS_MAIN_BEAM) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_DIPPED_BEAM), LIGHTS_DIPPED_BEAM) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_SIDE), LIGHTS_SIDE) + relogin_relay=30, api=self.saicapi, scheduler=None + ) + self.vehicle_handler = VehicleHandler( + config, + mock_relogin_handler, + self.saicapi, + self.publisher, + vehicle_info, + vehicle_state, + ) + + async def test_update_vehicle_status(self) -> None: + with patch.object( + self.saicapi, "get_vehicle_status" + ) as mock_get_vehicle_status: + mock_get_vehicle_status.return_value = get_mock_vehicle_status_resp() + await self.vehicle_handler.update_vehicle_status() + + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_RUNNING), + DRIVETRAIN_RUNNING, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic( + mqtt_topics.DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE + ), + DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE), + DRIVETRAIN_MILEAGE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE), + CLIMATE_INTERIOR_TEMPERATURE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_EXTERIOR_TEMPERATURE), + CLIMATE_EXTERIOR_TEMPERATURE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE), "on" + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_BACK_WINDOW_HEAT), "on" + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.LOCATION_SPEED), LOCATION_SPEED + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.LOCATION_HEADING), LOCATION_HEADING + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.LOCATION_LATITUDE), + LOCATION_LATITUDE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.LOCATION_LONGITUDE), + LOCATION_LONGITUDE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.LOCATION_ELEVATION), + LOCATION_ELEVATION, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_DRIVER), WINDOWS_DRIVER + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_PASSENGER), + WINDOWS_PASSENGER, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_REAR_LEFT), + WINDOWS_REAR_LEFT, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_REAR_RIGHT), + WINDOWS_REAR_RIGHT, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_SUN_ROOF), WINDOWS_SUN_ROOF + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DOORS_LOCKED), DOORS_LOCKED + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DOORS_DRIVER), DOORS_DRIVER + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DOORS_PASSENGER), DOORS_PASSENGER + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DOORS_REAR_LEFT), DOORS_REAR_LEFT + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DOORS_REAR_RIGHT), DOORS_REAR_RIGHT + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DOORS_BONNET), DOORS_BONNET + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DOORS_BOOT), DOORS_BOOT + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.TYRES_FRONT_LEFT_PRESSURE), + TYRES_FRONT_LEFT_PRESSURE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.TYRES_FRONT_RIGHT_PRESSURE), + TYRES_FRONT_RIGHT_PRESSURE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.TYRES_REAR_LEFT_PRESSURE), + TYRES_REAR_LEFT_PRESSURE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.TYRES_REAR_RIGHT_PRESSURE), + TYRES_REAR_RIGHT_PRESSURE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_MAIN_BEAM), LIGHTS_MAIN_BEAM + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_DIPPED_BEAM), + LIGHTS_DIPPED_BEAM, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_SIDE), LIGHTS_SIDE + ) expected_topics = { - '/vehicles/vin10000000000000/drivetrain/hvBatteryActive', - '/vehicles/vin10000000000000/refresh/lastActivity', - '/vehicles/vin10000000000000/drivetrain/running', - '/vehicles/vin10000000000000/drivetrain/charging', - '/vehicles/vin10000000000000/climate/interiorTemperature', - '/vehicles/vin10000000000000/climate/exteriorTemperature', - '/vehicles/vin10000000000000/drivetrain/auxiliaryBatteryVoltage', - '/vehicles/vin10000000000000/location/heading', - '/vehicles/vin10000000000000/location/latitude', - '/vehicles/vin10000000000000/location/longitude', - '/vehicles/vin10000000000000/location/elevation', - '/vehicles/vin10000000000000/location/position', - '/vehicles/vin10000000000000/location/speed', - '/vehicles/vin10000000000000/windows/driver', - '/vehicles/vin10000000000000/windows/passenger', - '/vehicles/vin10000000000000/windows/rearLeft', - '/vehicles/vin10000000000000/windows/rearRight', - '/vehicles/vin10000000000000/windows/sunRoof', - '/vehicles/vin10000000000000/doors/locked', - '/vehicles/vin10000000000000/doors/driver', - '/vehicles/vin10000000000000/doors/passenger', - '/vehicles/vin10000000000000/doors/rearLeft', - '/vehicles/vin10000000000000/doors/rearRight', - '/vehicles/vin10000000000000/doors/bonnet', - '/vehicles/vin10000000000000/doors/boot', - '/vehicles/vin10000000000000/tyres/frontLeftPressure', - '/vehicles/vin10000000000000/tyres/frontRightPressure', - '/vehicles/vin10000000000000/tyres/rearLeftPressure', - '/vehicles/vin10000000000000/tyres/rearRightPressure', - '/vehicles/vin10000000000000/lights/mainBeam', - '/vehicles/vin10000000000000/lights/dippedBeam', - '/vehicles/vin10000000000000/lights/side', - '/vehicles/vin10000000000000/climate/remoteClimateState', - '/vehicles/vin10000000000000/climate/rearWindowDefrosterHeating', - '/vehicles/vin10000000000000/climate/heatedSeatsFrontLeftLevel', - '/vehicles/vin10000000000000/climate/heatedSeatsFrontRightLevel', - '/vehicles/vin10000000000000/drivetrain/mileage', - '/vehicles/vin10000000000000/refresh/lastVehicleState', + "/vehicles/vin10000000000000/drivetrain/running", + "/vehicles/vin10000000000000/climate/interiorTemperature", + "/vehicles/vin10000000000000/climate/exteriorTemperature", + "/vehicles/vin10000000000000/drivetrain/auxiliaryBatteryVoltage", + "/vehicles/vin10000000000000/location/heading", + "/vehicles/vin10000000000000/location/latitude", + "/vehicles/vin10000000000000/location/longitude", + "/vehicles/vin10000000000000/location/elevation", + "/vehicles/vin10000000000000/location/position", + "/vehicles/vin10000000000000/location/speed", + "/vehicles/vin10000000000000/windows/driver", + "/vehicles/vin10000000000000/windows/passenger", + "/vehicles/vin10000000000000/windows/rearLeft", + "/vehicles/vin10000000000000/windows/rearRight", + "/vehicles/vin10000000000000/windows/sunRoof", + "/vehicles/vin10000000000000/doors/locked", + "/vehicles/vin10000000000000/doors/driver", + "/vehicles/vin10000000000000/doors/passenger", + "/vehicles/vin10000000000000/doors/rearLeft", + "/vehicles/vin10000000000000/doors/rearRight", + "/vehicles/vin10000000000000/doors/bonnet", + "/vehicles/vin10000000000000/doors/boot", + "/vehicles/vin10000000000000/tyres/frontLeftPressure", + "/vehicles/vin10000000000000/tyres/frontRightPressure", + "/vehicles/vin10000000000000/tyres/rearLeftPressure", + "/vehicles/vin10000000000000/tyres/rearRightPressure", + "/vehicles/vin10000000000000/lights/mainBeam", + "/vehicles/vin10000000000000/lights/dippedBeam", + "/vehicles/vin10000000000000/lights/side", + "/vehicles/vin10000000000000/climate/remoteClimateState", + "/vehicles/vin10000000000000/climate/rearWindowDefrosterHeating", + "/vehicles/vin10000000000000/climate/heatedSeatsFrontLeftLevel", + "/vehicles/vin10000000000000/climate/heatedSeatsFrontRightLevel", + "/vehicles/vin10000000000000/drivetrain/mileage", + "/vehicles/vin10000000000000/refresh/lastVehicleState", } - self.assertSetEqual(expected_topics, set(self.vehicle_handler.publisher.map.keys())) - - @patch.object(SaicApi, 'get_vehicle_charging_management_data') - async def test_update_charge_status(self, mocked_charge_status): - mock_charge_status(mocked_charge_status) - await self.vehicle_handler.update_charge_status() - - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CURRENT), DRIVETRAIN_CURRENT) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_VOLTAGE), DRIVETRAIN_VOLTAGE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_POWER), DRIVETRAIN_POWER) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_HYBRID_ELECTRICAL_RANGE), - DRIVETRAIN_HYBRID_ELECTRICAL_RANGE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE_OF_DAY), - DRIVETRAIN_MILEAGE_OF_DAY) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE), - DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_SOC_KWH), DRIVETRAIN_SOC_KWH) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_TYPE), - DRIVETRAIN_CHARGING_TYPE) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGER_CONNECTED), - DRIVETRAIN_CHARGER_CONNECTED) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME), - DRIVETRAIN_REMAINING_CHARGING_TIME) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_LAST_CHARGE_ENDING_POWER), - DRIVETRAIN_LAST_CHARGE_ENDING_POWER) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY), - REAL_TOTAL_BATTERY_CAPACITY) - self.assert_mqtt_topic(TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK), - DRIVETRAIN_CHARGING_CABLE_LOCK) + assert expected_topics == set(self.publisher.map.keys()) + + async def test_update_charge_status(self) -> None: + with patch.object( + self.saicapi, "get_vehicle_charging_management_data" + ) as mock_get_vehicle_charging_management_data: + mock_get_vehicle_charging_management_data.return_value = ( + get_mock_charge_management_data_resp() + ) + await self.vehicle_handler.update_charge_status() + + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING), + DRIVETRAIN_CHARGING, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CURRENT), + DRIVETRAIN_CURRENT, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_VOLTAGE), + DRIVETRAIN_VOLTAGE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_POWER), DRIVETRAIN_POWER + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic( + mqtt_topics.DRIVETRAIN_HYBRID_ELECTRICAL_RANGE + ), + DRIVETRAIN_HYBRID_ELECTRICAL_RANGE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE_OF_DAY), + DRIVETRAIN_MILEAGE_OF_DAY, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic( + mqtt_topics.DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE + ), + DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_SOC_KWH), + DRIVETRAIN_SOC_KWH, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_TYPE), + DRIVETRAIN_CHARGING_TYPE, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGER_CONNECTED), + DRIVETRAIN_CHARGER_CONNECTED, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic( + mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME + ), + DRIVETRAIN_REMAINING_CHARGING_TIME, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic( + mqtt_topics.DRIVETRAIN_LAST_CHARGE_ENDING_POWER + ), + DRIVETRAIN_LAST_CHARGE_ENDING_POWER, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY), + REAL_TOTAL_BATTERY_CAPACITY, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK), + DRIVETRAIN_CHARGING_CABLE_LOCK, + ) + self.assert_mqtt_topic( + TestVehicleHandler.get_topic(mqtt_topics.BMS_CHARGE_STATUS), + BMS_CHARGE_STATUS, + ) expected_topics = { - '/vehicles/vin10000000000000/drivetrain/current', - '/vehicles/vin10000000000000/drivetrain/voltage', - '/vehicles/vin10000000000000/drivetrain/power', - '/vehicles/vin10000000000000/obc/current', - '/vehicles/vin10000000000000/obc/voltage', - '/vehicles/vin10000000000000/drivetrain/hybrid_electrical_range', - '/vehicles/vin10000000000000/drivetrain/mileageOfTheDay', - '/vehicles/vin10000000000000/drivetrain/mileageSinceLastCharge', - '/vehicles/vin10000000000000/drivetrain/chargingType', - '/vehicles/vin10000000000000/drivetrain/chargerConnected', - '/vehicles/vin10000000000000/drivetrain/remainingChargingTime', - '/vehicles/vin10000000000000/refresh/lastChargeState', - '/vehicles/vin10000000000000/drivetrain/totalBatteryCapacity', - '/vehicles/vin10000000000000/drivetrain/soc_kwh', - '/vehicles/vin10000000000000/drivetrain/lastChargeEndingPower', - '/vehicles/vin10000000000000/drivetrain/batteryHeating', - '/vehicles/vin10000000000000/drivetrain/chargingCableLock' + "/vehicles/vin10000000000000/drivetrain/charging", + "/vehicles/vin10000000000000/drivetrain/current", + "/vehicles/vin10000000000000/drivetrain/voltage", + "/vehicles/vin10000000000000/drivetrain/power", + "/vehicles/vin10000000000000/obc/current", + "/vehicles/vin10000000000000/obc/voltage", + "/vehicles/vin10000000000000/drivetrain/hybrid_electrical_range", + "/vehicles/vin10000000000000/drivetrain/mileageOfTheDay", + "/vehicles/vin10000000000000/drivetrain/mileageSinceLastCharge", + "/vehicles/vin10000000000000/drivetrain/chargingType", + "/vehicles/vin10000000000000/drivetrain/chargerConnected", + "/vehicles/vin10000000000000/drivetrain/remainingChargingTime", + "/vehicles/vin10000000000000/refresh/lastChargeState", + "/vehicles/vin10000000000000/drivetrain/totalBatteryCapacity", + "/vehicles/vin10000000000000/drivetrain/soc_kwh", + "/vehicles/vin10000000000000/drivetrain/lastChargeEndingPower", + "/vehicles/vin10000000000000/drivetrain/batteryHeating", + "/vehicles/vin10000000000000/drivetrain/chargingCableLock", + "/vehicles/vin10000000000000/bms/chargeStatus", + "/vehicles/vin10000000000000/refresh/period/charging", } - self.assertSetEqual(expected_topics, set(self.vehicle_handler.publisher.map.keys())) + assert expected_topics == set(self.publisher.map.keys()) # Note: The closer the decorator is to the function definition, the earlier it is in the parameter list - @patch.object(SaicApi, 'get_vehicle_charging_management_data') - @patch.object(SaicApi, 'get_vehicle_status') - async def test_should_not_publish_same_data_twice(self, mocked_vehicle_status, mocked_charge_status): - mock_vehicle_status(mocked_vehicle_status) - mock_charge_status(mocked_charge_status) - publisher_data: dict = self.vehicle_handler.publisher.map + async def test_should_not_publish_same_data_twice(self) -> None: + with patch.object( + self.saicapi, "get_vehicle_charging_management_data" + ) as mock_get_vehicle_charging_management_data: + mock_get_vehicle_charging_management_data.return_value = ( + get_mock_charge_management_data_resp() + ) + with patch.object( + self.saicapi, "get_vehicle_status" + ) as mock_get_vehicle_status: + mock_get_vehicle_status.return_value = get_mock_vehicle_status_resp() - await self.vehicle_handler.update_vehicle_status() - vehicle_mqtt_map = dict(publisher_data) - publisher_data.clear() + await self.vehicle_handler.update_vehicle_status() + vehicle_mqtt_map = dict(self.publisher.map.items()) + self.publisher.map.clear() - await self.vehicle_handler.update_charge_status() - charge_data_mqtt_map = dict(publisher_data) - publisher_data.clear() + await self.vehicle_handler.update_charge_status() + charge_data_mqtt_map = dict(self.publisher.map.items()) + self.publisher.map.clear() - common_data = set(vehicle_mqtt_map.keys()).intersection(set(charge_data_mqtt_map.keys())) + common_data = set(vehicle_mqtt_map.keys()).intersection( + set(charge_data_mqtt_map.keys()) + ) - self.assertTrue( - len(common_data) == 0, - ("Some topics have been published from both car state and BMS state: %s" % str(common_data)) + assert len(common_data) == 0, ( + f"Some topics have been published from both car state and BMS state: {common_data!s}" ) - def assert_mqtt_topic(self, topic: str, value): - mqtt_map = self.vehicle_handler.publisher.map + def assert_mqtt_topic(self, topic: str, value: Any) -> None: + mqtt_map = self.publisher.map if topic in mqtt_map: if isinstance(value, float) or isinstance(mqtt_map[topic], float): - self.assertAlmostEqual(value, mqtt_map[topic], delta=1) + assert value == pytest.approx(mqtt_map[topic], abs=0.1) else: - self.assertEqual(value, mqtt_map[topic]) + assert value == mqtt_map[topic] else: - self.fail(f'MQTT map does not contain topic {topic}') + self.fail(f"MQTT map does not contain topic {topic}") @staticmethod def get_topic(sub_topic: str) -> str: - return f'/vehicles/{VIN}/{sub_topic}' + return f"/vehicles/{VIN}/{sub_topic}" diff --git a/tests/test_vehicle_state.py b/tests/test_vehicle_state.py index 3bec08c..48b9d1f 100644 --- a/tests/test_vehicle_state.py +++ b/tests/test_vehicle_state.py @@ -1,59 +1,116 @@ +from __future__ import annotations + +from typing import Any import unittest from apscheduler.schedulers.blocking import BlockingScheduler +from common_mocks import ( + DRIVETRAIN_RANGE_BMS, + DRIVETRAIN_RANGE_VEHICLE, + DRIVETRAIN_SOC_BMS, + DRIVETRAIN_SOC_VEHICLE, + VIN, + get_mock_charge_management_data_resp, + get_mock_vehicle_status_resp, +) +from mocks import MessageCapturingConsolePublisher +import pytest from saic_ismart_client_ng.api.vehicle.schema import VinInfo -import mqtt_topics from configuration import Configuration -from tests import MessageCapturingConsolePublisher -from tests.common_mocks import VIN, get_mock_vehicle_status_resp, DRIVETRAIN_SOC_BMS, DRIVETRAIN_RANGE_BMS, \ - DRIVETRAIN_SOC_VEHICLE, DRIVETRAIN_RANGE_VEHICLE, get_moc_charge_management_data_resp +import mqtt_topics from vehicle import VehicleState +from vehicle_info import VehicleInfo class TestVehicleState(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: config = Configuration() config.anonymized_publishing = False - publisher = MessageCapturingConsolePublisher(config) + self.publisher = MessageCapturingConsolePublisher(config) vin_info = VinInfo() vin_info.vin = VIN - account_prefix = f'/vehicles/{VIN}' + vehicle_info = VehicleInfo(vin_info, None) + account_prefix = f"/vehicles/{VIN}" scheduler = BlockingScheduler() - self.vehicle_state = VehicleState(publisher, scheduler, account_prefix, vin_info) + self.vehicle_state = VehicleState( + self.publisher, scheduler, account_prefix, vehicle_info + ) + + async def test_update_soc_with_no_bms_data(self) -> None: + vehicle_status_resp = get_mock_vehicle_status_resp() + result = self.vehicle_state.handle_vehicle_status(vehicle_status_resp) - async def test_update_soc_with_no_bms_data(self): - self.vehicle_state.update_data_conflicting_in_vehicle_and_bms(vehicle_status=get_mock_vehicle_status_resp(), - charge_status=None) - self.assert_mqtt_topic(TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_SOC), DRIVETRAIN_SOC_VEHICLE) - self.assert_mqtt_topic(TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_RANGE), DRIVETRAIN_RANGE_VEHICLE) + # Reset topics since we are only asserting the differences + self.publisher.map.clear() + + self.vehicle_state.update_data_conflicting_in_vehicle_and_bms( + vehicle_status=result, charge_status=None + ) + self.assert_mqtt_topic( + TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_SOC), + DRIVETRAIN_SOC_VEHICLE, + ) + self.assert_mqtt_topic( + TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_RANGE), + DRIVETRAIN_RANGE_VEHICLE, + ) + self.assert_mqtt_topic( + TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE), True + ) expected_topics = { - '/vehicles/vin10000000000000/drivetrain/soc', - '/vehicles/vin10000000000000/drivetrain/range', + "/vehicles/vin10000000000000/drivetrain/hvBatteryActive", + "/vehicles/vin10000000000000/refresh/lastActivity", + "/vehicles/vin10000000000000/drivetrain/soc", + "/vehicles/vin10000000000000/drivetrain/range", } - self.assertSetEqual(expected_topics, set(self.vehicle_state.publisher.map.keys())) + assert expected_topics == set(self.publisher.map.keys()) + + async def test_update_soc_with_bms_data(self) -> None: + vehicle_status_resp = get_mock_vehicle_status_resp() + chrg_mgmt_data_resp = get_mock_charge_management_data_resp() + vehicle_status_resp_result = self.vehicle_state.handle_vehicle_status( + vehicle_status_resp + ) + chrg_mgmt_data_resp_result = self.vehicle_state.handle_charge_status( + chrg_mgmt_data_resp + ) + + # Reset topics since we are only asserting the differences + self.publisher.map.clear() - async def test_update_soc_with_bms_data(self): - self.vehicle_state.update_data_conflicting_in_vehicle_and_bms(vehicle_status=get_mock_vehicle_status_resp(), - charge_status=get_moc_charge_management_data_resp()) - self.assert_mqtt_topic(TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_SOC), DRIVETRAIN_SOC_BMS) - self.assert_mqtt_topic(TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_RANGE), DRIVETRAIN_RANGE_BMS) + self.vehicle_state.update_data_conflicting_in_vehicle_and_bms( + vehicle_status=vehicle_status_resp_result, + charge_status=chrg_mgmt_data_resp_result, + ) + self.assert_mqtt_topic( + TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_SOC), DRIVETRAIN_SOC_BMS + ) + self.assert_mqtt_topic( + TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_RANGE), + DRIVETRAIN_RANGE_BMS, + ) + self.assert_mqtt_topic( + TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE), True + ) expected_topics = { - '/vehicles/vin10000000000000/drivetrain/soc', - '/vehicles/vin10000000000000/drivetrain/range', + "/vehicles/vin10000000000000/drivetrain/hvBatteryActive", + "/vehicles/vin10000000000000/refresh/lastActivity", + "/vehicles/vin10000000000000/drivetrain/soc", + "/vehicles/vin10000000000000/drivetrain/range", } - self.assertSetEqual(expected_topics, set(self.vehicle_state.publisher.map.keys())) + assert expected_topics == set(self.publisher.map.keys()) - def assert_mqtt_topic(self, topic: str, value): - mqtt_map = self.vehicle_state.publisher.map + def assert_mqtt_topic(self, topic: str, value: Any) -> None: + mqtt_map = self.publisher.map if topic in mqtt_map: if isinstance(value, float) or isinstance(mqtt_map[topic], float): - self.assertAlmostEqual(value, mqtt_map[topic], delta=1) + assert value == pytest.approx(mqtt_map[topic], abs=0.1) else: - self.assertEqual(value, mqtt_map[topic]) + assert value == mqtt_map[topic] else: - self.fail(f'MQTT map does not contain topic {topic}') + self.fail(f"MQTT map does not contain topic {topic}") @staticmethod def get_topic(sub_topic: str) -> str: - return f'/vehicles/{VIN}/{sub_topic}' + return f"/vehicles/{VIN}/{sub_topic}" diff --git a/utils.py b/utils.py deleted file mode 100644 index 4056d85..0000000 --- a/utils.py +++ /dev/null @@ -1,40 +0,0 @@ -from datetime import datetime, timezone, timedelta - -from saic_ismart_client_ng.api.schema import GpsStatus -from saic_ismart_client_ng.api.vehicle import VehicleStatusResp - - -def value_in_range(value, min_value, max_value, is_max_excl: bool = True) -> bool: - if value is None: - return False - elif is_max_excl: - return min_value <= value < max_value - else: - return min_value <= value <= max_value - - -def is_valid_temperature(value) -> bool: - return value_in_range(value, -127, 127) and value != 87 - - -def get_update_timestamp(vehicle_status: VehicleStatusResp) -> datetime: - vehicle_status_time = datetime.fromtimestamp(vehicle_status.statusTime or 0, tz=timezone.utc) - now_time = datetime.now(tz=timezone.utc) - # Do not use GPS data if it is not available - if vehicle_status.gpsPosition and vehicle_status.gpsPosition.gps_status_decoded in [GpsStatus.FIX_2D, - GpsStatus.FIX_3d]: - gps_time = datetime.fromtimestamp(vehicle_status.gpsPosition.timeStamp or 0, tz=timezone.utc) - else: - gps_time = datetime.fromtimestamp(0, tz=timezone.utc) - vehicle_status_drift = abs(now_time - vehicle_status_time) - gps_time_drift = abs(now_time - gps_time) - reference_drift = min(gps_time_drift, vehicle_status_drift) - reference_time = gps_time if gps_time_drift < vehicle_status_drift else vehicle_status_time - if reference_drift < timedelta(minutes=15): - return reference_time - else: - return now_time - - -def datetime_to_str(dt: datetime) -> str: - return datetime.astimezone(dt, tz=timezone.utc).isoformat() diff --git a/vehicle.py b/vehicle.py deleted file mode 100644 index e46b6a7..0000000 --- a/vehicle.py +++ /dev/null @@ -1,1108 +0,0 @@ -import datetime -import json -import logging -import math -import os -from dataclasses import asdict -from enum import Enum -from typing import Optional - -from apscheduler.job import Job -from apscheduler.schedulers.base import BaseScheduler -from apscheduler.triggers.cron import CronTrigger -from saic_ismart_client_ng.api.message.schema import MessageEntity -from saic_ismart_client_ng.api.schema import GpsStatus -from saic_ismart_client_ng.api.vehicle import VehicleStatusResp -from saic_ismart_client_ng.api.vehicle.schema import VinInfo -from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp, TargetBatteryCode, ChargeCurrentLimitCode, \ - ScheduledChargingMode, ScheduledBatteryHeatingResp -from saic_ismart_client_ng.api.vehicle_charging.schema import ChrgMgmtData - -import mqtt_topics -from integrations.openwb.charging_station import ChargingStation -from exceptions import MqttGatewayException -from publisher.core import Publisher -from utils import value_in_range, is_valid_temperature, datetime_to_str - -DEFAULT_AC_TEMP = 22 -PRESSURE_TO_BAR_FACTOR = 0.04 - -LOG = logging.getLogger(__name__) -LOG.setLevel(level=os.getenv('LOG_LEVEL', 'INFO').upper()) - - -class RefreshMode(Enum): - FORCE = 'force' - OFF = 'off' - PERIODIC = 'periodic' - - @staticmethod - def get(mode: str): - return RefreshMode[mode.upper()] - - -class VehicleState: - def __init__( - self, - publisher: Publisher, - scheduler: BaseScheduler, - account_prefix: str, - vin_info: VinInfo, - charging_station: Optional[ChargingStation] = None, - charge_polling_min_percent: float = 1.0, - total_battery_capacity: Optional[float] = None, - ): - self.publisher = publisher - self.__vin_info = vin_info - self.mqtt_vin_prefix = f'{account_prefix}' - self.charging_station = charging_station - self.last_car_activity = datetime.datetime.min - self.last_successful_refresh = datetime.datetime.min - self.__last_failed_refresh: datetime.datetime | None = None - self.__failed_refresh_counter = 0 - self.__refresh_period_error = 30 - self.last_car_shutdown = datetime.datetime.now() - self.last_car_vehicle_message = datetime.datetime.min - # treat high voltage battery as active, if we don't have any other information - self.hv_battery_active = True - self.is_charging = False - self.refresh_period_active = -1 - self.refresh_period_inactive = -1 - self.refresh_period_after_shutdown = -1 - self.refresh_period_inactive_grace = -1 - self.target_soc: Optional[TargetBatteryCode] = None - self.charge_current_limit: Optional[ChargeCurrentLimitCode] = None - self.refresh_period_charging = 0 - self.charge_polling_min_percent = charge_polling_min_percent - self.refresh_mode = RefreshMode.OFF - self.previous_refresh_mode = RefreshMode.OFF - self.__remote_ac_temp: Optional[int] = None - self.__remote_ac_running: bool = False - self.__remote_heated_seats_front_left_level: int = 0 - self.__remote_heated_seats_front_right_level: int = 0 - self.__scheduler = scheduler - self.__total_battery_capacity = total_battery_capacity - self.__scheduled_battery_heating_enabled = False - self.__scheduled_battery_heating_start = None - self.properties = {} - for c in vin_info.vehicleModelConfiguration: - property_name = c.itemName - property_code = c.itemCode - property_value = c.itemValue - self.properties[property_name] = {'code': property_code, 'value': property_value} - self.properties[property_code] = {'name': property_name, 'value': property_value} - - def set_refresh_period_active(self, seconds: int): - if seconds != self.refresh_period_active: - self.publisher.publish_int(self.get_topic(mqtt_topics.REFRESH_PERIOD_ACTIVE), seconds) - human_readable_period = str(datetime.timedelta(seconds=seconds)) - LOG.info(f'Setting active query interval in vehicle handler for VIN {self.vin} to {human_readable_period}') - self.refresh_period_active = seconds - # Recompute charging refresh period, if active refresh period is changed - self.set_refresh_period_charging(self.refresh_period_charging) - - def set_refresh_period_inactive(self, seconds: int): - if seconds != self.refresh_period_inactive: - self.publisher.publish_int(self.get_topic(mqtt_topics.REFRESH_PERIOD_INACTIVE), seconds) - human_readable_period = str(datetime.timedelta(seconds=seconds)) - LOG.info( - f'Setting inactive query interval in vehicle handler for VIN {self.vin} to {human_readable_period}') - self.refresh_period_inactive = seconds - # Recompute charging refresh period, if inactive refresh period is changed - self.set_refresh_period_charging(self.refresh_period_charging) - - def set_refresh_period_charging(self, seconds: int): - # Do not refresh more than the active period and less than the inactive one - seconds = round(seconds) - seconds = min(max(seconds, self.refresh_period_active), self.refresh_period_inactive) if seconds > 0 else 0 - if seconds != self.refresh_period_charging: - self.publisher.publish_int(self.get_topic(mqtt_topics.REFRESH_PERIOD_CHARGING), seconds) - human_readable_period = str(datetime.timedelta(seconds=seconds)) - LOG.info( - f'Setting charging query interval in vehicle handler for VIN {self.vin} to {human_readable_period}') - self.refresh_period_charging = seconds - - def set_refresh_period_after_shutdown(self, seconds: int): - if seconds != self.refresh_period_after_shutdown: - self.publisher.publish_int(self.get_topic(mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN), seconds) - human_readable_period = str(datetime.timedelta(seconds=seconds)) - LOG.info( - f'Setting after shutdown query interval in vehicle handler for VIN {self.vin} to {human_readable_period}' - ) - self.refresh_period_after_shutdown = seconds - - def set_refresh_period_inactive_grace(self, refresh_period_inactive_grace: int): - if ( - self.refresh_period_inactive_grace == -1 - or self.refresh_period_inactive_grace != refresh_period_inactive_grace - ): - self.publisher.publish_int(self.get_topic(mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE), - refresh_period_inactive_grace) - self.refresh_period_inactive_grace = refresh_period_inactive_grace - - def update_target_soc(self, target_soc: TargetBatteryCode): - if self.target_soc != target_soc and target_soc is not None: - self.publisher.publish_int(self.get_topic(mqtt_topics.DRIVETRAIN_SOC_TARGET), target_soc.percentage) - self.target_soc = target_soc - - def update_charge_current_limit(self, charge_current_limit: ChargeCurrentLimitCode): - if self.charge_current_limit != charge_current_limit and charge_current_limit is not None: - try: - self.publisher.publish_str( - self.get_topic(mqtt_topics.DRIVETRAIN_CHARGECURRENT_LIMIT), - charge_current_limit.limit - ) - self.charge_current_limit = charge_current_limit - except ValueError: - LOG.exception(f'Unhandled charge current limit {charge_current_limit}') - - def update_scheduled_charging( - self, - start_time: datetime.time, - mode: ScheduledChargingMode - ): - scheduled_charging_job_id = f'{self.vin}_scheduled_charging' - existing_job: Job | None = self.__scheduler.get_job(scheduled_charging_job_id) - if mode in [ScheduledChargingMode.UNTIL_CONFIGURED_TIME, ScheduledChargingMode.UNTIL_CONFIGURED_SOC]: - if self.refresh_period_inactive_grace > 0: - # Add a grace period to the start time, so that the car is not woken up too early - dt = (datetime.datetime.now() - .replace(hour=start_time.hour, minute=start_time.minute, second=0, microsecond=0) - + datetime.timedelta(seconds=self.refresh_period_inactive_grace)) - start_time = dt.time() - trigger = CronTrigger.from_crontab(f'{start_time.minute} {start_time.hour} * * *') - if existing_job is not None: - existing_job.reschedule(trigger=trigger) - LOG.info(f'Rescheduled check for charging start for VIN {self.vin} at {start_time}') - else: - self.__scheduler.add_job( - func=self.set_refresh_mode, - args=[RefreshMode.FORCE, 'check for scheduled charging start'], - trigger=trigger, - kwargs={}, - name=scheduled_charging_job_id, - id=scheduled_charging_job_id, - replace_existing=True, - ) - LOG.info(f'Scheduled check for charging start for VIN {self.vin} at {start_time}') - elif existing_job is not None: - existing_job.remove() - LOG.info(f'Removed scheduled check for charging start for VIN {self.vin}') - - def is_complete(self) -> bool: - return self.refresh_period_active != -1 \ - and self.refresh_period_inactive != -1 \ - and self.refresh_period_after_shutdown != -1 \ - and self.refresh_period_inactive_grace != -1 \ - and self.refresh_mode - - def set_is_charging(self, is_charging: bool): - self.is_charging = is_charging - self.set_hv_battery_active(self.is_charging) - self.publisher.publish_bool(self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING), self.is_charging) - - def handle_vehicle_status(self, vehicle_status: VehicleStatusResp) -> None: - vehicle_status_time = datetime.datetime.fromtimestamp(vehicle_status.statusTime or 0, tz=datetime.timezone.utc) - now_time = datetime.datetime.now(tz=datetime.timezone.utc) - vehicle_status_drift = abs(now_time - vehicle_status_time) - if vehicle_status_drift > datetime.timedelta(minutes=15): - raise MqttGatewayException( - f"Vehicle status time drifted too much from current time: {vehicle_status_drift}. Server reported {vehicle_status_time}" - ) - - is_engine_running = vehicle_status.is_engine_running - self.is_charging = vehicle_status.is_charging - basic_vehicle_status = vehicle_status.basicVehicleStatus - remote_climate_status = basic_vehicle_status.remoteClimateStatus or 0 - rear_window_heat_state = basic_vehicle_status.rmtHtdRrWndSt or 0 - - hv_battery_active = ( - self.is_charging - or is_engine_running - or remote_climate_status > 0 - or rear_window_heat_state > 0 - ) - - self.set_hv_battery_active(hv_battery_active) - - self.publisher.publish_bool(self.get_topic(mqtt_topics.DRIVETRAIN_RUNNING), is_engine_running) - self.publisher.publish_bool(self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING), self.is_charging) - interior_temperature = basic_vehicle_status.interiorTemperature - if is_valid_temperature(interior_temperature): - self.publisher.publish_int(self.get_topic(mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE), interior_temperature) - exterior_temperature = basic_vehicle_status.exteriorTemperature - if is_valid_temperature(exterior_temperature): - self.publisher.publish_int(self.get_topic(mqtt_topics.CLIMATE_EXTERIOR_TEMPERATURE), exterior_temperature) - battery_voltage = basic_vehicle_status.batteryVoltage - if value_in_range(battery_voltage, 1, 65535): - self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE), - battery_voltage / 10.0) - - speed = None - gps_position = vehicle_status.gpsPosition - if ( - gps_position - and gps_position.gps_status_decoded in [GpsStatus.FIX_2D, GpsStatus.FIX_3d] - ): - way_point = gps_position.wayPoint - if way_point: - speed = way_point.speed / 10.0 - self.publisher.publish_int(self.get_topic(mqtt_topics.LOCATION_HEADING), way_point.heading) - position = way_point.position - if position: - latitude = position.latitude / 1000000.0 - longitude = position.longitude / 1000000.0 - altitude = position.altitude - valid_altitude = value_in_range(altitude, -500, 8900) - if abs(latitude) <= 90 and abs(longitude) <= 180: - self.publisher.publish_float(self.get_topic(mqtt_topics.LOCATION_LATITUDE), latitude) - self.publisher.publish_float(self.get_topic(mqtt_topics.LOCATION_LONGITUDE), longitude) - position_json = { - 'latitude': latitude, - 'longitude': longitude, - } - if valid_altitude: - position_json['altitude'] = altitude - self.publisher.publish_int(self.get_topic(mqtt_topics.LOCATION_ELEVATION), altitude) - self.publisher.publish_json(self.get_topic(mqtt_topics.LOCATION_POSITION), position_json) - - # Assume speed is 0 if the vehicle is parked and we have no other info - if speed is None and vehicle_status.is_parked: - speed = 0.0 - - if speed is not None: - self.publisher.publish_float(self.get_topic(mqtt_topics.LOCATION_SPEED), speed) - - if basic_vehicle_status.driverWindow is not None: - self.publisher.publish_bool(self.get_topic(mqtt_topics.WINDOWS_DRIVER), basic_vehicle_status.driverWindow) - if basic_vehicle_status.passengerWindow is not None: - self.publisher.publish_bool(self.get_topic(mqtt_topics.WINDOWS_PASSENGER), - basic_vehicle_status.passengerWindow) - if basic_vehicle_status.rearLeftWindow is not None: - self.publisher.publish_bool(self.get_topic(mqtt_topics.WINDOWS_REAR_LEFT), - basic_vehicle_status.rearLeftWindow) - if basic_vehicle_status.rearRightWindow is not None: - self.publisher.publish_bool(self.get_topic(mqtt_topics.WINDOWS_REAR_RIGHT), - basic_vehicle_status.rearRightWindow) - if basic_vehicle_status.sunroofStatus is not None: - self.publisher.publish_bool(self.get_topic(mqtt_topics.WINDOWS_SUN_ROOF), - basic_vehicle_status.sunroofStatus) - - self.publisher.publish_bool(self.get_topic(mqtt_topics.DOORS_LOCKED), basic_vehicle_status.lockStatus) - self.publisher.publish_bool(self.get_topic(mqtt_topics.DOORS_DRIVER), basic_vehicle_status.driverDoor) - self.publisher.publish_bool(self.get_topic(mqtt_topics.DOORS_PASSENGER), basic_vehicle_status.passengerDoor) - self.publisher.publish_bool(self.get_topic(mqtt_topics.DOORS_REAR_LEFT), basic_vehicle_status.rearLeftDoor) - self.publisher.publish_bool(self.get_topic(mqtt_topics.DOORS_REAR_RIGHT), basic_vehicle_status.rearRightDoor) - self.publisher.publish_bool(self.get_topic(mqtt_topics.DOORS_BONNET), basic_vehicle_status.bonnetStatus) - self.publisher.publish_bool(self.get_topic(mqtt_topics.DOORS_BOOT), basic_vehicle_status.bootStatus) - - self.__publish_tyre(basic_vehicle_status.frontLeftTyrePressure, mqtt_topics.TYRES_FRONT_LEFT_PRESSURE) - self.__publish_tyre(basic_vehicle_status.frontRightTyrePressure, mqtt_topics.TYRES_FRONT_RIGHT_PRESSURE) - self.__publish_tyre(basic_vehicle_status.rearLeftTyrePressure, mqtt_topics.TYRES_REAR_LEFT_PRESSURE) - self.__publish_tyre(basic_vehicle_status.rearRightTyrePressure, mqtt_topics.TYRES_REAR_RIGHT_PRESSURE) - - self.publisher.publish_bool(self.get_topic(mqtt_topics.LIGHTS_MAIN_BEAM), basic_vehicle_status.mainBeamStatus) - self.publisher.publish_bool(self.get_topic(mqtt_topics.LIGHTS_DIPPED_BEAM), - basic_vehicle_status.dippedBeamStatus) - self.publisher.publish_bool(self.get_topic(mqtt_topics.LIGHTS_SIDE), basic_vehicle_status.sideLightStatus) - - self.publisher.publish_str(self.get_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE), - VehicleState.to_remote_climate(remote_climate_status)) - self.__remote_ac_running = remote_climate_status == 2 - - self.publisher.publish_str(self.get_topic(mqtt_topics.CLIMATE_BACK_WINDOW_HEAT), - 'off' if rear_window_heat_state == 0 else 'on') - - if value_in_range(basic_vehicle_status.frontLeftSeatHeatLevel, 0, 255): - self.__remote_heated_seats_front_left_level = basic_vehicle_status.frontLeftSeatHeatLevel - self.publisher.publish_int(self.get_topic(mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL), - self.__remote_heated_seats_front_left_level) - - if value_in_range(basic_vehicle_status.frontRightSeatHeatLevel, 0, 255): - self.__remote_heated_seats_front_right_level = basic_vehicle_status.frontRightSeatHeatLevel - self.publisher.publish_int(self.get_topic(mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL), - self.__remote_heated_seats_front_right_level) - - if value_in_range(basic_vehicle_status.mileage, 1, 2147483647): - mileage = basic_vehicle_status.mileage / 10.0 - self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE), mileage) - - # Standard fossil fuels vehicles - if value_in_range(basic_vehicle_status.fuelRange, 1, 65535): - fuel_range = basic_vehicle_status.fuelRange / 10.0 - self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_RANGE), fuel_range) - - if value_in_range(basic_vehicle_status.fuelLevelPrc, 0, 100, is_max_excl=False): - self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE), - basic_vehicle_status.fuelLevelPrc) - - if ( - basic_vehicle_status.currentJourneyId is not None - and basic_vehicle_status.currentJourneyDistance is not None - ): - self.publisher.publish_json(self.get_topic(mqtt_topics.DRIVETRAIN_CURRENT_JOURNEY), { - 'id': basic_vehicle_status.currentJourneyId, - 'distance': round(basic_vehicle_status.currentJourneyDistance / 10.0, 1) - }) - - self.publisher.publish_str(self.get_topic(mqtt_topics.REFRESH_LAST_VEHICLE_STATE), - datetime_to_str(datetime.datetime.now())) - - def __publish_tyre(self, raw_value: int, topic: str): - if value_in_range(raw_value, 1, 255): - bar_value = raw_value * PRESSURE_TO_BAR_FACTOR - self.publisher.publish_float(self.get_topic(topic), round(bar_value, 2)) - - def __publish_electric_range(self, raw_value) -> bool: - if value_in_range(raw_value, 1, 20460): - electric_range = raw_value / 10.0 - self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_RANGE), electric_range) - if ( - self.charging_station - and self.charging_station.range_topic - ): - self.publisher.publish_float(self.charging_station.range_topic, electric_range, True) - return True - return False - - def __publish_soc(self, soc) -> bool: - if value_in_range(soc, 0, 100.0, is_max_excl=False): - self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_SOC), soc) - if ( - self.charging_station - and self.charging_station.soc_topic - ): - self.publisher.publish_float(self.charging_station.soc_topic, soc, True) - return True - return False - - def set_hv_battery_active(self, hv_battery_active: bool): - if ( - not hv_battery_active - and self.hv_battery_active - ): - self.last_car_shutdown = datetime.datetime.now() - - self.hv_battery_active = hv_battery_active - self.publisher.publish_bool(self.get_topic(mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE), hv_battery_active) - - if hv_battery_active: - self.notify_car_activity() - - def notify_car_activity(self): - self.last_car_activity = datetime.datetime.now() - self.publisher.publish_str( - self.get_topic(mqtt_topics.REFRESH_LAST_ACTIVITY), - datetime_to_str(self.last_car_activity) - ) - - def notify_message(self, message: MessageEntity): - if ( - self.last_car_vehicle_message == datetime.datetime.min - or message.message_time > self.last_car_vehicle_message - ): - self.last_car_vehicle_message = message.message_time - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_ID), str(message.messageId)) - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_TYPE), message.messageType) - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_TITLE), message.title) - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_TIME), - datetime_to_str(self.last_car_vehicle_message)) - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_SENDER), message.sender) - if message.content: - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_CONTENT), message.content) - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_STATUS), message.read_status) - if message.vin: - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_LAST_MESSAGE_VIN), message.vin) - self.notify_car_activity() - - def should_refresh(self) -> bool: - match self.refresh_mode: - case RefreshMode.OFF: - return False - case RefreshMode.FORCE: - self.set_refresh_mode( - self.previous_refresh_mode, - 'restoring of previous refresh mode after a FORCE execution' - ) - return True - # RefreshMode.PERIODIC is treated like default - case _: - last_actual_poll = self.last_successful_refresh - if self.last_failed_refresh is not None: - last_actual_poll = max(last_actual_poll, self.last_failed_refresh) - - # Try refreshing even if we last failed as long as the last_car_activity is newer - if self.last_car_activity > last_actual_poll: - return True - - if self.last_failed_refresh is not None: - result = self.last_failed_refresh < datetime.datetime.now() - datetime.timedelta( - seconds=float(self.refresh_period_error) - ) - LOG.debug(f'Gateway failed refresh previously. Should refresh: {result}') - return result - - if self.is_charging and self.refresh_period_charging > 0: - result = self.last_successful_refresh < datetime.datetime.now() - datetime.timedelta( - seconds=float(self.refresh_period_charging) - ) - LOG.debug(f'HV battery is charging. Should refresh: {result}') - return result - - if self.hv_battery_active: - result = self.last_successful_refresh < datetime.datetime.now() - datetime.timedelta( - seconds=float(self.refresh_period_active) - ) - LOG.debug(f'HV battery is active. Should refresh: {result}') - return result - - last_shutdown_plus_refresh = self.last_car_shutdown + datetime.timedelta( - seconds=float(self.refresh_period_inactive_grace) - ) - if last_shutdown_plus_refresh > datetime.datetime.now(): - result = self.last_successful_refresh < datetime.datetime.now() - datetime.timedelta( - seconds=float(self.refresh_period_after_shutdown)) - LOG.debug(f'Refresh grace period after shutdown has not passed. Should refresh: {result}') - return result - - result = self.last_successful_refresh < datetime.datetime.now() - datetime.timedelta( - seconds=float(self.refresh_period_inactive) - ) - LOG.debug( - f'HV battery is inactive and refresh period after shutdown is over. Should refresh: {result}' - ) - return result - - def mark_successful_refresh(self): - self.last_successful_refresh = datetime.datetime.now() - self.last_failed_refresh = None - self.publisher.publish_str(self.get_topic(mqtt_topics.AVAILABLE), 'online') - - def mark_failed_refresh(self): - self.last_failed_refresh = datetime.datetime.now() - self.publisher.publish_str(self.get_topic(mqtt_topics.AVAILABLE), 'offline') - - @property - def refresh_period_error(self): - return self.__refresh_period_error - - @property - def last_failed_refresh(self): - return self.__last_failed_refresh - - @last_failed_refresh.setter - def last_failed_refresh(self, value: datetime.datetime | None): - self.__last_failed_refresh = value - if value is None: - self.__failed_refresh_counter = 0 - self.__refresh_period_error = self.refresh_period_active - elif self.__refresh_period_error < self.refresh_period_inactive: - self.__refresh_period_error = round(min( - self.refresh_period_active * (2 ** self.__failed_refresh_counter), - self.refresh_period_inactive - )) - self.__failed_refresh_counter = self.__failed_refresh_counter + 1 - self.publisher.publish_str( - self.get_topic(mqtt_topics.REFRESH_LAST_ERROR), - datetime_to_str(value) - ) - self.publisher.publish_int(self.get_topic(mqtt_topics.REFRESH_PERIOD_ERROR), self.__refresh_period_error) - - def publish_vehicle_info(self): - LOG.info("Publishing vehicle info to MQTT") - self.publisher.publish_str( - self.get_topic(mqtt_topics.INTERNAL_CONFIGURATION_RAW), - json.dumps([asdict(x) for x in self.__vin_info.vehicleModelConfiguration]) - ) - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_BRAND), self.__vin_info.brandName) - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_MODEL), self.__vin_info.modelName) - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_YEAR), self.__vin_info.modelYear) - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_SERIES), self.__vin_info.series) - if self.__vin_info.colorName: - self.publisher.publish_str(self.get_topic(mqtt_topics.INFO_COLOR), self.__vin_info.colorName) - for c in self.__vin_info.vehicleModelConfiguration: - property_name = c.itemName - property_code = c.itemCode - property_value = c.itemValue - property_code_topic = f'{mqtt_topics.INFO_CONFIGURATION}/{property_code}' - property_name_topic = f'{mqtt_topics.INFO_CONFIGURATION}/{property_name}' - self.publisher.publish_str(self.get_topic(property_code_topic), property_value) - self.publisher.publish_str(self.get_topic(property_name_topic), property_value) - - def configure_missing(self): - if self.refresh_period_active == -1: - self.set_refresh_period_active(30) - if self.refresh_period_after_shutdown == -1: - self.set_refresh_period_after_shutdown(120) - if self.refresh_period_inactive == -1: - # in seconds (Once a day to protect your 12V battery) - self.set_refresh_period_inactive(86400) - if self.refresh_period_inactive_grace == -1: - self.set_refresh_period_inactive_grace(600) - if self.__remote_ac_temp is None: - self.set_ac_temperature(DEFAULT_AC_TEMP) - # Make sure the only refresh mode that is not supported at start is RefreshMode.PERIODIC - if self.refresh_mode in [RefreshMode.OFF, RefreshMode.FORCE]: - self.set_refresh_mode( - RefreshMode.PERIODIC, - f"initial gateway startup from an invalid state {self.refresh_mode}" - ) - - async def configure_by_message(self, *, topic: str, payload: str): - payload = payload.lower() - match topic: - case mqtt_topics.REFRESH_MODE_SET: - try: - refresh_mode = RefreshMode.get(payload) - self.set_refresh_mode(refresh_mode, "MQTT direct set refresh mode command execution") - except KeyError: - raise MqttGatewayException(f'Unsupported payload {payload}') - case mqtt_topics.REFRESH_PERIOD_ACTIVE_SET: - try: - seconds = int(payload) - self.set_refresh_period_active(seconds) - except ValueError: - raise MqttGatewayException(f'Error setting value for payload {payload}') - case mqtt_topics.REFRESH_PERIOD_INACTIVE_SET: - try: - seconds = int(payload) - self.set_refresh_period_inactive(seconds) - except ValueError: - raise MqttGatewayException(f'Error setting value for payload {payload}') - case mqtt_topics.REFRESH_PERIOD_AFTER_SHUTDOWN_SET: - try: - seconds = int(payload) - self.set_refresh_period_after_shutdown(seconds) - except ValueError: - raise MqttGatewayException(f'Error setting value for payload {payload}') - case mqtt_topics.REFRESH_PERIOD_INACTIVE_GRACE_SET: - try: - seconds = int(payload) - self.set_refresh_period_inactive_grace(seconds) - except ValueError: - raise MqttGatewayException(f'Error setting value for payload {payload}') - case _: - raise MqttGatewayException(f'Unsupported topic {topic}') - - def handle_charge_status(self, charge_info_resp: ChrgMgmtDataResp) -> None: - charge_mgmt_data = charge_info_resp.chrgMgmtData - is_valid_current = ( - charge_mgmt_data.bmsPackCrntV != 1 - and value_in_range(charge_mgmt_data.bmsPackCrnt, 0, 65535) - ) - if is_valid_current: - self.publisher.publish_float( - self.get_topic(mqtt_topics.DRIVETRAIN_CURRENT), - round(charge_mgmt_data.decoded_current, 3) - ) - - is_valid_voltage = value_in_range(charge_mgmt_data.bmsPackVol, 0, 65535) - if is_valid_voltage: - self.publisher.publish_float( - self.get_topic(mqtt_topics.DRIVETRAIN_VOLTAGE), - round(charge_mgmt_data.decoded_voltage, 3) - ) - is_valid_power = is_valid_current and is_valid_voltage - if is_valid_power: - self.publisher.publish_float( - self.get_topic(mqtt_topics.DRIVETRAIN_POWER), - round(charge_mgmt_data.decoded_power, 3) - ) - - obc_voltage = charge_mgmt_data.onBdChrgrAltrCrntInptVol - obc_current = charge_mgmt_data.onBdChrgrAltrCrntInptCrnt - if obc_voltage is not None and obc_current is not None: - self.publisher.publish_float( - self.get_topic(mqtt_topics.OBC_CURRENT), - round(obc_current / 5.0, 1) - ) - self.publisher.publish_int( - self.get_topic(mqtt_topics.OBC_VOLTAGE), - 2 * obc_voltage - ) - self.publisher.publish_float( - self.get_topic(mqtt_topics.OBC_POWER_SINGLE_PHASE), - round(2 * obc_voltage * obc_current / 5.0, 1) - ) - self.publisher.publish_float( - self.get_topic(mqtt_topics.OBC_POWER_THREE_PHASE), - round(math.sqrt(3) * 2 * obc_voltage * obc_current / 15.0, 1) - ) - else: - self.publisher.publish_float(self.get_topic(mqtt_topics.OBC_CURRENT), 0.0) - self.publisher.publish_int(self.get_topic(mqtt_topics.OBC_VOLTAGE), 0) - - raw_charge_current_limit = charge_mgmt_data.bmsAltngChrgCrntDspCmd - if ( - raw_charge_current_limit is not None - and raw_charge_current_limit != 0 - ): - try: - self.update_charge_current_limit(ChargeCurrentLimitCode(raw_charge_current_limit)) - except ValueError: - LOG.warning(f'Invalid charge current limit received: {raw_charge_current_limit}') - - raw_target_soc = charge_mgmt_data.bmsOnBdChrgTrgtSOCDspCmd - if raw_target_soc is not None: - try: - self.update_target_soc(TargetBatteryCode(raw_target_soc)) - except ValueError: - LOG.warning(f'Invalid target SOC received: {raw_target_soc}') - - estd_elec_rng = charge_mgmt_data.bmsEstdElecRng - if value_in_range(estd_elec_rng, 0, 2046): - estimated_electrical_range = estd_elec_rng - self.publisher.publish_int( - self.get_topic(mqtt_topics.DRIVETRAIN_HYBRID_ELECTRICAL_RANGE), - estimated_electrical_range - ) - - if charge_mgmt_data.bmsChrgSts is not None: - bms_chrg_sts = charge_mgmt_data.bms_charging_status - self.publisher.publish_str( - self.get_topic(mqtt_topics.BMS_CHARGE_STATUS), - f'UNKNOWN {charge_mgmt_data.bmsChrgSts}' if bms_chrg_sts is None else bms_chrg_sts.name - ) - - if charge_mgmt_data.bmsChrgSpRsn is not None: - charging_stop_reason = charge_mgmt_data.charging_stop_reason - self.publisher.publish_str( - self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_STOP_REASON), - f'UNKNOWN ({charge_mgmt_data.bmsChrgSpRsn})' if charging_stop_reason is None else charging_stop_reason.name - ) - - if charge_mgmt_data.ccuOnbdChrgrPlugOn is not None: - self.publisher.publish_int( - self.get_topic(mqtt_topics.CCU_ONBOARD_PLUG_STATUS), - charge_mgmt_data.ccuOnbdChrgrPlugOn - ) - - if charge_mgmt_data.ccuOffBdChrgrPlugOn is not None: - self.publisher.publish_int( - self.get_topic(mqtt_topics.CCU_OFFBOARD_PLUG_STATUS), - charge_mgmt_data.ccuOffBdChrgrPlugOn - ) - - charge_status = charge_info_resp.rvsChargeStatus - - if value_in_range(charge_status.mileageOfDay, 0, 65535): - mileage_of_the_day = charge_status.mileageOfDay / 10.0 - self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE_OF_DAY), mileage_of_the_day) - - if value_in_range(charge_status.mileageSinceLastCharge, 0, 65535): - mileage_since_last_charge = charge_status.mileageSinceLastCharge / 10.0 - self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE), - mileage_since_last_charge) - - self.publisher.publish_int(self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_TYPE), charge_status.chargingType) - self.publisher.publish_bool(self.get_topic(mqtt_topics.DRIVETRAIN_CHARGER_CONNECTED), - charge_status.chargingGunState) - - if has_scheduled_charging_info(charge_mgmt_data): - try: - start_hour = charge_mgmt_data.bmsReserStHourDspCmd - start_minute = charge_mgmt_data.bmsReserStMintueDspCmd - start_time = datetime.time(hour=start_hour, minute=start_minute) - end_hour = charge_mgmt_data.bmsReserSpHourDspCmd - end_minute = charge_mgmt_data.bmsReserSpMintueDspCmd - mode = ScheduledChargingMode(charge_mgmt_data.bmsReserCtrlDspCmd) - self.publisher.publish_json(self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE), { - 'startTime': "{:02d}:{:02d}".format(start_hour, start_minute), - 'endTime': "{:02d}:{:02d}".format(end_hour, end_minute), - 'mode': mode.name, - }) - self.update_scheduled_charging(start_time, mode) - - except ValueError: - LOG.exception("Error parsing scheduled charging info") - - # Only publish remaining charging time if the car tells us the value is OK - remaining_charging_time = None - if charge_mgmt_data.chrgngRmnngTimeV != 1 and charge_mgmt_data.chrgngRmnngTime is not None: - remaining_charging_time = charge_mgmt_data.chrgngRmnngTime * 60 - self.publisher.publish_int( - self.get_topic(mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME), - remaining_charging_time - ) - else: - self.publisher.publish_int(self.get_topic(mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME), 0) - - charge_status_start_time = charge_status.startTime - if value_in_range(charge_status_start_time, 1, 2147483647): - self.publisher.publish_int( - self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_LAST_START), - charge_status_start_time - ) - - charge_status_end_time = charge_status.endTime - if value_in_range(charge_status_end_time, 1, 2147483647): - self.publisher.publish_int( - self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_LAST_END), - charge_status_end_time - ) - - self.publisher.publish_str(self.get_topic(mqtt_topics.REFRESH_LAST_CHARGE_STATE), - datetime_to_str(datetime.datetime.now())) - - real_total_battery_capacity, battery_capacity_correction_factor = self.get_actual_battery_capacity( - charge_status) - - if real_total_battery_capacity > 0: - self.publisher.publish_float( - self.get_topic(mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY), - real_total_battery_capacity - ) - soc_kwh = (battery_capacity_correction_factor * charge_status.realtimePower) / 10.0 - self.publisher.publish_float(self.get_topic(mqtt_topics.DRIVETRAIN_SOC_KWH), round(soc_kwh, 2)) - - last_charge_ending_power = charge_status.lastChargeEndingPower - if value_in_range(last_charge_ending_power, 0, 65535): - last_charge_ending_power = (battery_capacity_correction_factor * last_charge_ending_power) / 10.0 - self.publisher.publish_float( - self.get_topic(mqtt_topics.DRIVETRAIN_LAST_CHARGE_ENDING_POWER), - round(last_charge_ending_power, 2) - ) - - power_usage_of_day = charge_status.powerUsageOfDay - if value_in_range(power_usage_of_day, 0, 65535): - power_usage_of_day = (battery_capacity_correction_factor * power_usage_of_day) / 10.0 - self.publisher.publish_float( - self.get_topic(mqtt_topics.DRIVETRAIN_POWER_USAGE_OF_DAY), - round(power_usage_of_day, 2) - ) - - power_usage_since_last_charge = charge_status.powerUsageSinceLastCharge - if value_in_range(power_usage_since_last_charge, 0, 65535): - power_usage_since_last_charge = (battery_capacity_correction_factor * power_usage_since_last_charge) / 10.0 - self.publisher.publish_float( - self.get_topic(mqtt_topics.DRIVETRAIN_POWER_USAGE_SINCE_LAST_CHARGE), - round(power_usage_since_last_charge, 2) - ) - - if ( - charge_status.chargingGunState - and is_valid_power - and charge_mgmt_data.decoded_power < -1 - ): - # Only compute a dynamic refresh period if we have detected at least 1kW of power during charging - time_for_1pct = 36.0 * real_total_battery_capacity / abs(charge_mgmt_data.decoded_power) - time_for_min_pct = math.ceil(self.charge_polling_min_percent * time_for_1pct) - # It doesn't make sense to refresh less often than the estimated time for completion - if remaining_charging_time is not None and remaining_charging_time > 0: - computed_refresh_period = min(remaining_charging_time, time_for_min_pct) - else: - computed_refresh_period = time_for_1pct - self.set_refresh_period_charging(computed_refresh_period) - elif not self.is_charging: - # Reset the charging refresh period if we detected we are no longer charging - self.set_refresh_period_charging(0) - else: - # Otherwise let's keep the last computed refresh period - # This avoids falling back to the active refresh period which, being too often, results in a ban from - # the SAIC API - pass - - self.publisher.publish_bool( - self.get_topic(mqtt_topics.DRIVETRAIN_BATTERY_HEATING), - charge_mgmt_data.is_battery_heating - ) - if charge_mgmt_data.bmsPTCHeatResp is not None: - ptc_heat_stop_reason = charge_mgmt_data.heating_stop_reason - self.publisher.publish_str( - self.get_topic(mqtt_topics.DRIVETRAIN_BATTERY_HEATING_STOP_REASON), - f'UNKNOWN ({charge_mgmt_data.bmsPTCHeatResp})' if ptc_heat_stop_reason is None else ptc_heat_stop_reason.name - ) - - self.publisher.publish_bool( - self.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK), - charge_mgmt_data.charging_port_locked - ) - - def update_data_conflicting_in_vehicle_and_bms( - self, - vehicle_status: VehicleStatusResp, - charge_status: Optional[ChrgMgmtDataResp] - ): - # We can read this from either the BMS or the Vehicle Info - electric_range_published = False - soc_published = False - - if charge_status is not None: - electric_range_published = self.__publish_electric_range(charge_status.rvsChargeStatus.fuelRangeElec) - soc_published = self.__publish_soc(charge_status.chrgMgmtData.bmsPackSOCDsp / 10.0) - basic_vehicle_status = vehicle_status.basicVehicleStatus - if not electric_range_published: - electric_range_published = self.__publish_electric_range(basic_vehicle_status.fuelRangeElec) - if not soc_published: - soc_published = self.__publish_soc(basic_vehicle_status.extendedData1) - if not electric_range_published: - logging.warning("Could not extract a valid electric range") - if not soc_published: - logging.warning("Could not extract a valid SoC") - - def handle_scheduled_battery_heating_status(self, scheduled_battery_heating_status: ScheduledBatteryHeatingResp): - if scheduled_battery_heating_status: - is_enabled = scheduled_battery_heating_status.status - if is_enabled: - start_time = scheduled_battery_heating_status.decoded_start_time - else: - start_time = self.__scheduled_battery_heating_start - else: - start_time = self.__scheduled_battery_heating_start - is_enabled = False - - self.update_scheduled_battery_heating( - start_time, - is_enabled - ) - - def update_scheduled_battery_heating(self, start_time: datetime.time, enabled: bool): - changed = False - if self.__scheduled_battery_heating_start != start_time: - self.__scheduled_battery_heating_start = start_time - changed = True - if self.__scheduled_battery_heating_enabled != enabled: - self.__scheduled_battery_heating_enabled = enabled - changed = True - - has_start_time = self.__scheduled_battery_heating_start is not None - computed_mode = 'on' if has_start_time and self.__scheduled_battery_heating_enabled else 'off' - computed_start_time = self.__scheduled_battery_heating_start.strftime('%H:%M') if has_start_time else '00:00' - self.publisher.publish_json(self.get_topic(mqtt_topics.DRIVETRAIN_BATTERY_HEATING_SCHEDULE), { - 'mode': computed_mode, - 'startTime': computed_start_time - }) - return changed - - def get_topic(self, sub_topic: str): - return f'{self.mqtt_vin_prefix}/{sub_topic}' - - @staticmethod - def to_remote_climate(rmt_htd_rr_wnd_st: int) -> str: - match rmt_htd_rr_wnd_st: - case 0: - return 'off' - case 1: - return 'blowingonly' - case 2: - return 'on' - case 5: - return 'front' - - return f'unknown ({rmt_htd_rr_wnd_st})' - - def set_refresh_mode(self, mode: RefreshMode, cause: str): - if ( - mode is not None and - ( - self.refresh_mode is None - or self.refresh_mode != mode - ) - ): - new_mode_value = mode.value - LOG.info(f"Setting refresh mode to {new_mode_value} due to {cause}") - self.publisher.publish_str(self.get_topic(mqtt_topics.REFRESH_MODE), new_mode_value) - # Make sure we never store FORCE as previous refresh mode - if self.refresh_mode != RefreshMode.FORCE: - self.previous_refresh_mode = self.refresh_mode - self.refresh_mode = mode - LOG.debug(f'Refresh mode set to {new_mode_value} due to {cause}') - - @property - def is_ev(self): - if self.series.startswith('ZP22'): - return False - else: - return True - - @property - def has_fossil_fuel(self): - return not self.is_ev - - @property - def has_sunroof(self): - return self.__get_property_value('Sunroof') != '0' - - @property - def has_on_off_heated_seats(self): - return self.__get_property_value('HeatedSeat') == '2' - - @property - def has_level_heated_seats(self): - return self.__get_property_value('HeatedSeat') == '1' - - @property - def has_heated_seats(self): - return self.has_level_heated_seats or self.has_on_off_heated_seats - - @property - def is_heated_seats_running(self): - return (self.__remote_heated_seats_front_right_level + self.__remote_heated_seats_front_left_level) > 0 - - @property - def remote_heated_seats_front_left_level(self): - return self.__remote_heated_seats_front_left_level - - def update_heated_seats_front_left_level(self, level): - if not self.__check_heated_seats_level(level): - return False - changed = self.__remote_heated_seats_front_left_level != level - self.__remote_heated_seats_front_left_level = level - return changed - - @property - def remote_heated_seats_front_right_level(self): - return self.__remote_heated_seats_front_right_level - - def update_heated_seats_front_right_level(self, level): - if not self.__check_heated_seats_level(level): - return False - changed = self.__remote_heated_seats_front_right_level != level - self.__remote_heated_seats_front_right_level = level - return changed - - def __check_heated_seats_level(self, level: int) -> bool: - if not self.has_heated_seats: - return False - if self.has_level_heated_seats and not (0 <= level <= 3): - raise ValueError(f'Invalid heated seat level {level}. Range must be from 0 to 3 inclusive') - if self.has_on_off_heated_seats and not (0 <= level <= 1): - raise ValueError(f'Invalid heated seat level {level}. Range must be from 0 to 1 inclusive') - return True - - @property - def supports_target_soc(self): - return self.__get_property_value('Battery') == '1' - - @property - def vin(self): - return self.__vin_info.vin - - @property - def series(self): - return str(self.__vin_info.series).strip().upper() - - @property - def model(self): - return str(self.__vin_info.modelName).strip().upper() - - def get_actual_battery_capacity(self, charge_status) -> tuple[float, float]: - - real_total_battery_capacity = self.__get_actual_battery_capacity() - if ( - real_total_battery_capacity is not None - and real_total_battery_capacity <= 0 - ): - # Negative or 0 value for real capacity means we don't know that info - real_total_battery_capacity = None - - raw_total_battery_capacity = None - if ( - charge_status.totalBatteryCapacity is not None - and charge_status.totalBatteryCapacity > 0 - ): - raw_total_battery_capacity = charge_status.totalBatteryCapacity / 10.0 - - if raw_total_battery_capacity is not None: - if real_total_battery_capacity is not None: - LOG.debug( - f"Calculating full battery capacity correction factor based on " - f"real={real_total_battery_capacity} and raw={raw_total_battery_capacity}" - ) - return real_total_battery_capacity, real_total_battery_capacity / raw_total_battery_capacity - else: - LOG.debug(f"Setting real battery capacity to raw battery capacity {raw_total_battery_capacity}") - return raw_total_battery_capacity, 1.0 - else: - if real_total_battery_capacity is not None: - LOG.debug(f"Setting raw battery capacity to real battery capacity {real_total_battery_capacity}") - return real_total_battery_capacity, 1.0 - else: - LOG.warning("No battery capacity information available") - return 0, 1.0 - - def __get_actual_battery_capacity(self) -> float | None: - if self.__total_battery_capacity is not None and self.__total_battery_capacity > 0: - return float(self.__total_battery_capacity) - # MG4 high trim level - elif self.series.startswith('EH32 S'): - if self.model.startswith('EH32 X3'): - # MG4 Trophy Extended Range - return 77.0 - elif self.supports_target_soc: - # MG4 high trim level with NMC battery - return 64.0 - else: - # MG4 High trim level with LFP battery - return 51.0 - # MG4 low trim level - # Note: EH32 X/ is used for the 2023 MY with both NMC and LFP batter chem - elif self.series.startswith('EH32 L'): - if self.supports_target_soc: - # MG4 low trim level with NMC battery - return 64.0 - else: - # MG4 low trim level with LFP battery - return 51.0 - # Model: MG5 Electric, variant MG5 SR Comfort - elif self.series.startswith('EP2CP3'): - return 50.3 - # Model: MG5 Electric, variant MG5 MR Luxury - elif self.series.startswith('EP2DP3'): - return 61.1 - # ZS EV Standard 2021 - elif self.series.startswith('ZS EV S'): - return 49.0 - else: - return None - - def __get_property_value(self, property_name: str) -> str | None: - if property_name in self.properties: - pdict = self.properties[property_name] - if pdict is not None and isinstance(pdict, dict) and 'value' in pdict: - return pdict['value'] - return None - - def get_remote_ac_temperature(self) -> int: - return self.__remote_ac_temp or DEFAULT_AC_TEMP - - def set_ac_temperature(self, temp) -> bool: - if temp is None: - LOG.error("Cannot set AC temperature to None") - return False - temp = max(self.get_min_ac_temperature(), min(self.get_max_ac_temperature(), temp)) - if self.__remote_ac_temp != temp: - self.__remote_ac_temp = temp - LOG.info(f"Updating remote AC temperature to {temp}") - self.publisher.publish_int(self.get_topic(mqtt_topics.CLIMATE_REMOTE_TEMPERATURE), temp) - return True - return False - - def get_ac_temperature_idx(self) -> int: - if self.series.startswith('EH32'): - return 3 + self.get_remote_ac_temperature() - self.get_min_ac_temperature() - else: - return 2 + self.get_remote_ac_temperature() - self.get_min_ac_temperature() - - def get_min_ac_temperature(self) -> int: - if self.series.startswith('EH32'): - return 17 - else: - return 16 - - def get_max_ac_temperature(self) -> int: - if self.series.startswith('EH32'): - return 33 - else: - return 28 - - @property - def is_remote_ac_running(self) -> bool: - return self.__remote_ac_running - - -def has_scheduled_charging_info(charge_mgmt_data: ChrgMgmtData): - return charge_mgmt_data.bmsReserStHourDspCmd is not None \ - and charge_mgmt_data.bmsReserStMintueDspCmd is not None \ - and charge_mgmt_data.bmsReserSpHourDspCmd is not None \ - and charge_mgmt_data.bmsReserSpMintueDspCmd is not None