├── .dockerignore ├── .env ├── .github └── workflows │ ├── build_and_test_python.yml │ ├── build_python_mqtt_dev_images.yml │ └── build_python_mqtt_images.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE.txt ├── README.md ├── docker-compose.yml ├── examples ├── charging-stations.json ├── charging-stations.json.sample └── charging-stations.json.sample_openWB_2.0 ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── src ├── configuration │ ├── __init__.py │ ├── argparse_extensions.py │ └── parser.py ├── exceptions.py ├── handlers │ ├── __init__.py │ ├── message.py │ ├── relogin.py │ └── vehicle.py ├── integrations │ ├── __init__.py │ ├── abrp │ │ ├── __init__.py │ │ └── api.py │ ├── home_assistant │ │ ├── __init__.py │ │ └── discovery.py │ ├── openwb │ │ ├── __init__.py │ │ └── charging_station.py │ └── osmand │ │ ├── __init__.py │ │ └── api.py ├── log_config.py ├── main.py ├── mqtt_gateway.py ├── mqtt_topics.py ├── publisher │ ├── __init__.py │ ├── core.py │ ├── log_publisher.py │ └── mqtt_publisher.py ├── saic_api_listener.py ├── status_publisher │ ├── __init__.py │ ├── charge │ │ ├── __init__.py │ │ ├── chrg_mgmt_data.py │ │ ├── chrg_mgmt_data_resp.py │ │ └── rvs_charge_status.py │ ├── message.py │ ├── vehicle │ │ ├── __init__.py │ │ ├── basic_vehicle_status.py │ │ ├── gps_position.py │ │ └── vehicle_status_resp.py │ └── vehicle_info.py ├── utils.py ├── vehicle.py └── vehicle_info.py └── tests ├── __init__.py ├── common_mocks.py ├── mocks └── __init__.py ├── test_mqtt_publisher.py ├── test_utils.py ├── test_vehicle_handler.py └── test_vehicle_state.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # IPython Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # dotenv 81 | .env 82 | 83 | # virtualenv 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | ### VirtualEnv template 93 | # Virtualenv 94 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 95 | [Bb]in 96 | [Ii]nclude 97 | [Ll]ib 98 | [Ll]ib64 99 | [Ll]ocal 100 | [Ss]cripts 101 | pyvenv.cfg 102 | .venv 103 | pip-selfcheck.json 104 | 105 | ### JetBrains template 106 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 107 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 108 | 109 | # User-specific stuff 110 | .idea/**/workspace.xml 111 | .idea/**/tasks.xml 112 | .idea/**/usage.statistics.xml 113 | .idea/**/dictionaries 114 | .idea/**/shelf 115 | 116 | # AWS User-specific 117 | .idea/**/aws.xml 118 | 119 | # Generated files 120 | .idea/**/contentModel.xml 121 | 122 | # Sensitive or high-churn files 123 | .idea/**/dataSources/ 124 | .idea/**/dataSources.ids 125 | .idea/**/dataSources.local.xml 126 | .idea/**/sqlDataSources.xml 127 | .idea/**/dynamic.xml 128 | .idea/**/uiDesigner.xml 129 | .idea/**/dbnavigator.xml 130 | 131 | # Gradle 132 | .idea/**/gradle.xml 133 | .idea/**/libraries 134 | 135 | # Gradle and Maven with auto-import 136 | # When using Gradle or Maven with auto-import, you should exclude module files, 137 | # since they will be recreated, and may cause churn. Uncomment if using 138 | # auto-import. 139 | # .idea/artifacts 140 | # .idea/compiler.xml 141 | # .idea/jarRepositories.xml 142 | # .idea/modules.xml 143 | # .idea/*.iml 144 | # .idea/modules 145 | # *.iml 146 | # *.ipr 147 | 148 | # CMake 149 | cmake-build-*/ 150 | 151 | # Mongo Explorer plugin 152 | .idea/**/mongoSettings.xml 153 | 154 | # File-based project format 155 | *.iws 156 | 157 | # IntelliJ 158 | out/ 159 | 160 | # mpeltonen/sbt-idea plugin 161 | .idea_modules/ 162 | 163 | # JIRA plugin 164 | atlassian-ide-plugin.xml 165 | 166 | # Cursive Clojure plugin 167 | .idea/replstate.xml 168 | 169 | # SonarLint plugin 170 | .idea/sonarlint/ 171 | 172 | # Crashlytics plugin (for Android Studio and IntelliJ) 173 | com_crashlytics_export_strings.xml 174 | crashlytics.properties 175 | crashlytics-build.properties 176 | fabric.properties 177 | 178 | # Editor-based Rest Client 179 | .idea/httpRequests 180 | 181 | # Android studio 3.1+ serialized cache file 182 | .idea/caches/build_file_checksums.ser 183 | 184 | # idea folder, uncomment if you don't need it 185 | .idea 186 | 187 | # Test results 188 | junit/ 189 | 190 | .github/ -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | MQTT_BROKER_URI=tcp://localhost:1833 2 | MQTT_USERNAME=mqtt_user 3 | MQTT_PASSWORD=secret 4 | SAIC_USERNAME=me@home.da 5 | SAIC_PWD=123456 6 | -------------------------------------------------------------------------------- /.github/workflows/build_and_test_python.yml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | types: [ opened, synchronize, reopened ] 9 | branches: 10 | - '*' 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [ "3.12", "3.13" ] 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | cache: 'pip' 29 | 30 | - name: Install Poetry 31 | uses: snok/install-poetry@v1 32 | with: 33 | virtualenvs-in-project: true 34 | 35 | - name: Load cached venv 36 | id: cached-poetry-dependencies 37 | uses: actions/cache@v4 38 | with: 39 | path: .venv 40 | key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }} 41 | 42 | - name: Install dependencies 43 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 44 | run: | 45 | poetry install --no-interaction --no-root 46 | 47 | - name: Install library 48 | run: poetry install --no-interaction 49 | 50 | - name: Run tests with coverage 51 | run: | 52 | poetry run pytest tests --cov --junit-xml=junit/test-results-${{ matrix.python-version }}.xml 53 | 54 | - name: Lint with Ruff 55 | run: | 56 | poetry run ruff check . --output-format=github 57 | continue-on-error: true 58 | 59 | - name: Surface failing tests 60 | uses: pmeier/pytest-results-action@main 61 | with: 62 | title: Test results (Python ${{ matrix.python-version }}) 63 | path: junit/test-results-${{ matrix.python-version }}.xml 64 | 65 | # (Optional) Add a summary of the results at the top of the report 66 | summary: true 67 | # (Optional) Select which results should be included in the report. 68 | # Follows the same syntax as `pytest -r` 69 | display-options: fEX 70 | 71 | # (Optional) Fail the workflow if no JUnit XML was found. 72 | fail-on-empty: true 73 | if: ${{ always() }} -------------------------------------------------------------------------------- /.github/workflows/build_python_mqtt_dev_images.yml: -------------------------------------------------------------------------------- 1 | name: 'build dev images' 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+-rc[0-9]+" 7 | 8 | # TODO: only request needed permissions 9 | permissions: write-all 10 | 11 | jobs: 12 | docker: 13 | runs-on: ubuntu-latest 14 | env: 15 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 16 | DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} 17 | # support dockerhub organizations. If none is present, use the dockerhub username 18 | DOCKERHUB_ORGANIZATION: ${{ secrets.DOCKERHUB_ORGANIZATION == null && secrets.DOCKERHUB_USERNAME || secrets.DOCKERHUB_ORGANIZATION }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set env 22 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 23 | 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v3 26 | 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v3 29 | 30 | - name: Login to Docker Hub 31 | uses: docker/login-action@v3 32 | if: env.DOCKERHUB_USERNAME != null 33 | with: 34 | username: ${{ env.DOCKERHUB_USERNAME }} 35 | password: ${{ env.DOCKERHUB_PASSWORD }} 36 | 37 | - name: Login to GitHub Container Registry 38 | uses: docker/login-action@v3 39 | with: 40 | registry: ghcr.io 41 | username: ${{ github.actor }} 42 | password: ${{ github.token }} 43 | 44 | - id: lowercase-repository 45 | name: lowercase repository 46 | uses: ASzc/change-string-case-action@v6 47 | with: 48 | string: ${{ github.repository }} 49 | 50 | - name: Build and push (GHCR only) 51 | uses: docker/build-push-action@v5 52 | if: env.DOCKERHUB_ORGANIZATION == null 53 | with: 54 | context: . 55 | platforms: linux/amd64,linux/arm64,linux/arm/v7 56 | push: true 57 | cache-from: type=gha 58 | cache-to: type=gha,mode=max 59 | tags: | 60 | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:dev 61 | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:${{ env.RELEASE_VERSION }} 62 | 63 | - name: Build and push (GHCR + DockerHub) 64 | uses: docker/build-push-action@v5 65 | if: env.DOCKERHUB_ORGANIZATION != null 66 | with: 67 | context: . 68 | platforms: linux/amd64,linux/arm64,linux/arm/v7 69 | push: true 70 | cache-from: type=gha 71 | cache-to: type=gha,mode=max 72 | tags: | 73 | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:dev 74 | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:${{ env.RELEASE_VERSION }} 75 | ${{ secrets.DOCKERHUB_ORGANIZATION != null && format('{0}/saic-python-mqtt-gateway:dev', secrets.DOCKERHUB_ORGANIZATION)}} 76 | ${{ secrets.DOCKERHUB_ORGANIZATION != null && format('{0}/saic-python-mqtt-gateway:{1}', secrets.DOCKERHUB_ORGANIZATION, env.RELEASE_VERSION)}} 77 | -------------------------------------------------------------------------------- /.github/workflows/build_python_mqtt_images.yml: -------------------------------------------------------------------------------- 1 | name: 'build stable images' 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+" 7 | 8 | # TODO: only request needed permissions 9 | permissions: write-all 10 | 11 | jobs: 12 | docker: 13 | runs-on: ubuntu-latest 14 | env: 15 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 16 | DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} 17 | # support dockerhub organizations. If none is present, use the dockerhub username 18 | DOCKERHUB_ORGANIZATION: ${{ secrets.DOCKERHUB_ORGANIZATION == null && secrets.DOCKERHUB_USERNAME || secrets.DOCKERHUB_ORGANIZATION }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set env 22 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 23 | 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v3 26 | 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v3 29 | 30 | - name: Login to Docker Hub 31 | uses: docker/login-action@v3 32 | if: env.DOCKERHUB_USERNAME != null 33 | with: 34 | username: ${{ env.DOCKERHUB_USERNAME }} 35 | password: ${{ env.DOCKERHUB_PASSWORD }} 36 | 37 | - name: Login to GitHub Container Registry 38 | uses: docker/login-action@v3 39 | with: 40 | registry: ghcr.io 41 | username: ${{ github.actor }} 42 | password: ${{ github.token }} 43 | 44 | - id: lowercase-repository 45 | name: lowercase repository 46 | uses: ASzc/change-string-case-action@v6 47 | with: 48 | string: ${{ github.repository }} 49 | 50 | - name: Build and push (GHCR only) 51 | uses: docker/build-push-action@v5 52 | if: env.DOCKERHUB_ORGANIZATION == null 53 | with: 54 | context: . 55 | platforms: linux/amd64,linux/arm64,linux/arm/v7 56 | push: true 57 | tags: | 58 | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:latest 59 | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:${{ env.RELEASE_VERSION }} 60 | 61 | - name: Build and push (GHCR + DockerHub) 62 | uses: docker/build-push-action@v5 63 | if: env.DOCKERHUB_ORGANIZATION != null 64 | with: 65 | context: . 66 | platforms: linux/amd64,linux/arm64,linux/arm/v7 67 | push: true 68 | tags: | 69 | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:latest 70 | ghcr.io/${{ steps.lowercase-repository.outputs.lowercase }}/saic-mqtt-gateway:${{ env.RELEASE_VERSION }} 71 | ${{ secrets.DOCKERHUB_ORGANIZATION != null && format('{0}/saic-python-mqtt-gateway:latest', secrets.DOCKERHUB_ORGANIZATION)}} 72 | ${{ secrets.DOCKERHUB_ORGANIZATION != null && format('{0}/saic-python-mqtt-gateway:{1}', secrets.DOCKERHUB_ORGANIZATION, env.RELEASE_VERSION)}} 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # IPython Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # dotenv 81 | .env 82 | 83 | # virtualenv 84 | venv/ 85 | ENV/ 86 | 87 | # Spyder project settings 88 | .spyderproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | ### VirtualEnv template 93 | # Virtualenv 94 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 95 | [Bb]in 96 | [Ii]nclude 97 | [Ll]ib 98 | [Ll]ib64 99 | [Ll]ocal 100 | [Ss]cripts 101 | pyvenv.cfg 102 | .venv 103 | pip-selfcheck.json 104 | 105 | ### JetBrains template 106 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 107 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 108 | 109 | # User-specific stuff 110 | .idea/**/workspace.xml 111 | .idea/**/tasks.xml 112 | .idea/**/usage.statistics.xml 113 | .idea/**/dictionaries 114 | .idea/**/shelf 115 | 116 | # AWS User-specific 117 | .idea/**/aws.xml 118 | 119 | # Generated files 120 | .idea/**/contentModel.xml 121 | 122 | # Sensitive or high-churn files 123 | .idea/**/dataSources/ 124 | .idea/**/dataSources.ids 125 | .idea/**/dataSources.local.xml 126 | .idea/**/sqlDataSources.xml 127 | .idea/**/dynamic.xml 128 | .idea/**/uiDesigner.xml 129 | .idea/**/dbnavigator.xml 130 | 131 | # Gradle 132 | .idea/**/gradle.xml 133 | .idea/**/libraries 134 | 135 | # Gradle and Maven with auto-import 136 | # When using Gradle or Maven with auto-import, you should exclude module files, 137 | # since they will be recreated, and may cause churn. Uncomment if using 138 | # auto-import. 139 | # .idea/artifacts 140 | # .idea/compiler.xml 141 | # .idea/jarRepositories.xml 142 | # .idea/modules.xml 143 | # .idea/*.iml 144 | # .idea/modules 145 | # *.iml 146 | # *.ipr 147 | 148 | # CMake 149 | cmake-build-*/ 150 | 151 | # Mongo Explorer plugin 152 | .idea/**/mongoSettings.xml 153 | 154 | # File-based project format 155 | *.iws 156 | 157 | # IntelliJ 158 | out/ 159 | 160 | # mpeltonen/sbt-idea plugin 161 | .idea_modules/ 162 | 163 | # JIRA plugin 164 | atlassian-ide-plugin.xml 165 | 166 | # Cursive Clojure plugin 167 | .idea/replstate.xml 168 | 169 | # SonarLint plugin 170 | .idea/sonarlint/ 171 | 172 | # Crashlytics plugin (for Android Studio and IntelliJ) 173 | com_crashlytics_export_strings.xml 174 | crashlytics.properties 175 | crashlytics-build.properties 176 | fabric.properties 177 | 178 | # Editor-based Rest Client 179 | .idea/httpRequests 180 | 181 | # Android studio 3.1+ serialized cache file 182 | .idea/caches/build_file_checksums.ser 183 | 184 | # idea folder, uncomment if you don't need it 185 | .idea 186 | 187 | # Test results 188 | junit/ 189 | .run/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 0.9.5 4 | 5 | ## What's Changed 6 | * Extend ZS EV battery capacity to high trim and long range models by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/339 7 | 8 | ## 0.9.4 9 | 10 | ## What's Changed 11 | * Introduce armv7 support instead of armv6 by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/336 12 | 13 | ## 0.9.3 14 | 15 | ## What's Changed 16 | * Restore phone login by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/334 17 | 18 | ## 0.9.2 19 | 20 | ### What's Changed 21 | * Migrated Last Charge SoC kWh sensor to 'measurement' state_class 22 | 23 | ## 0.9.1 24 | 25 | ### What's Changed 26 | * Fixed a bug in message processing when no messages where found on SAIC servers 27 | 28 | ## 0.9.0 29 | 30 | ### What's Changed 31 | * Compatibiltity with the latest mobile app release 32 | * #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 33 | * add random delay before pushing discovery by @krombel in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/295 34 | * #296: Detect charging from BMS instead of car state by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/298 35 | * Mark 'Last Charge SoC kWh' in HA as a TOTAL_INCRESING sensor so that it can be used in Energy Dashboard by @nanomad 36 | * Expose journey ID to Home Assistant by @nanomad 37 | * Internally mark the car as "shutdown" only after the car state changes. 38 | This avoids looking for a "charging started" event as soon as the charging is completed by @nanomad 39 | * Keep retrying initial login by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/319 40 | * Publish window status only if mileage is reported properly by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/320 41 | 42 | ### New Contributors 43 | * @krombel made their first contribution in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/294 44 | 45 | **Full Changelog**: https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/compare/0.7.1...0.9.0 46 | 47 | ## 0.7.1 48 | 49 | ## What's changed 50 | * #287: Gracefully handle cases where the messages api returns no data by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/290 51 | 52 | ## 0.7.0 53 | 54 | ## What's Changed 55 | * Fixed many stability issues with the login process 56 | * Fixed battery capacity calculation for newer MG4 variants 57 | * Updated README.md with SAIC API Endpoints section by @karter16 in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/260 58 | * Add suffix to battery heating stop reason topic to allow discriminate it from battery heating topic by @pakozm in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/253 59 | * Gracefully handle scenarios where we don't know the real battery capcity of the car by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/261 60 | * Initial support for fossil fuel cars like MG3 Hybrid by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/262 61 | * Move relogin logic to the MQTT Gateway instead of the API library by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/265 62 | * [WIP] Expose Find by car functionality by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/266 63 | * Fix #73: Allow running the gateway without an MQTT connection by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/267 64 | * Minor updates by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/268 65 | * #283: Publish HA discovery once per car by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/284 66 | * #279: Restore the original normalization rule by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/285 67 | 68 | ## New Contributors 69 | * @karter16 made their first contribution in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/260 70 | * @pakozm made their first contribution in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/253 71 | 72 | **Full Changelog**: https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/compare/0.6.3...0.7.0 73 | 74 | ## 0.6.3 75 | 76 | ### What's changed 77 | 78 | * Feat: try to recover SoC% from vehicle state if BMS is not responding by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/247 79 | * Fix tests for drivetrain soc from vehicle state by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/248 80 | 81 | ## 0.6.2 82 | 83 | ### Fixed 84 | 85 | * Fix charge_status.bmsPackCrntV check for ABRP integration by @nanomad in #234 86 | * Handle unreliable 3d fix information, fall back to reported altitude by @nanomad in #235 87 | * Make message handling more robust by @nanomad in #237 88 | * Process MQTT messages after we receivied a list of cars by @nanomad in #238 89 | * ABRP-1537: Drop future timestamps when pushing data to ABRP by @nanomad in #242 90 | 91 | ### What's changed 92 | 93 | * Try to extract remaining electric range from either car or BMS by @nanomad in #236 94 | 95 | ## 0.6.1 96 | 97 | ### Fixed 98 | 99 | * Charging power and current showing 0 on models different from MG4 100 | * HA: Heading sensor is now recorded with a unit of measure 101 | 102 | ### What's Changed 103 | 104 | * HA: Allow overriding the default "Unavailable" behaviour via HA_SHOW_UNAVAILABLE by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/220 105 | * mplement missing hash function for HaCustomAvailabilityEntry by @nanomad in #222 106 | * Add IGNORE_BMS_CURRENT_VALIDATION and decode API responses by @nanomad in #225 107 | * Drop IGNORE_BMS_CURRENT_VALIDATION experiment, just mark current as invalid if bmsPackCrntV is 1 (None or 0 means valid) by @nanomad in #226 108 | * Add Unit of measure to heading sensor. by @nanomad in #228 109 | 110 | ## 0.6.0 111 | 112 | ### Import upgrade notes 113 | 114 | Please note that 0.6.0 will more aggresively mark the vehicle as offline on Home Assitant in order to avoid providing false 115 | information to the user. When the vehicle is "offline" no commands can be sent to it, except for a force refresh. 116 | 117 | ### Added 118 | 119 | * Support for openWB software version 2.0 by @tosate in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/172 120 | * Expose power usage stats by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/178 121 | * Added support for cable lock and unlock by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/188 122 | * Exponential backoff during polling failures by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/193 123 | * Expose last charge start and end times by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/201 124 | * Add sensors for current journey and OBC data by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/205 125 | * Side lights detection by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/210 126 | 127 | ### Fixed 128 | 129 | * Battery capacity for MG ZS EV Standard 2021 by @tosate in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/160 130 | * Add battery capacity for MG5 Maximum Range Luxury by @sfudeus in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/166 131 | * Fix initial remote ac temp value by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/175 132 | * Apply battery_capacity_correction_factor to lastChargeEndingPower by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/176 133 | * Detect DRIVETRAIN_HV_BATTERY_ACTIVE when rear window heater is on by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/177 134 | * Read electric estimated range from BMS and Car State as it gets reset during parking by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/181 135 | * Compute charging refresh period based on charging power. by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/183 136 | * Re-introduce tests and run them every push by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/189 137 | * Run CI on push and PR by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/190 138 | * Mark car not available if polling fails by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/191 139 | * Temporary fix: require just a vehicle state refresh to mark vehicle loop as completed by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/192 140 | * Assume speed is 0.0 if we have no GPS data by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/196 141 | * Fix Charging finished sensor by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/197 142 | * Do not send invalid data to ABRP and MQTT by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/199 143 | * Fix test data by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/200 144 | * Data validation by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/204 145 | * During charging, do not easily fall back on the active refresh period by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/206 146 | * Fix BMS and Journey sensors by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/207 147 | * Fix currentJourneyDistance scale factor by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/208 148 | * GPS and Charging detection fixes by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/209 149 | * Remove special characters from MQTT topics. by @tosate in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/214 150 | * Avoid processing vehicle status updates if the server clock has drifted too much by @nanomad in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/211 151 | 152 | ### New Contributors 153 | * @tosate made their first contribution in https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/pull/160 154 | 155 | **Full Changelog**: https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/compare/0.5.15...0.6.0 156 | 157 | ## 0.5.15 158 | 159 | ### Added 160 | 161 | * Battery capacity for MG4 Trophy Extended Range 162 | * Battery capacity for MG5 SR Comfort 163 | 164 | ## 0.5.10 165 | 166 | ### Added 167 | 168 | * MQTT: Add support for scheduled battery heating. Payload is JSON with startTime and mode (on or off) 169 | * HA: Expose scheduled battery heating 170 | * HA: Expose some switches as sensors as well to ease automations 171 | 172 | ## 0.5.9 173 | 174 | ### Added 175 | 176 | * MQTT: Add support for battery heating. True means ON, False means OFF 177 | * HA: Expose battery heating as an ON-OFF switch 178 | 179 | ## 0.5.8 180 | 181 | ### Added 182 | 183 | * MQTT: Add support for heated seats control on both front left and front right seats. Values range from 0-3 on some 184 | models, 0-1 on others. 0 means OFF 185 | * HA: Expose heated seats control as either a select with 4 states (OFF, LOW, MEDIUM, HIGH) or as a ON-OFF switch 186 | depending on the reported car feature set 187 | 188 | ## 0.5.7 189 | 190 | ### Fixed 191 | 192 | * Align some vehicle control commands to their actual behavior on the official app 193 | * Door closing command should be more reliable now 194 | 195 | ### Added 196 | 197 | * The new option `SAIC_PHONE_COUNTRY_CODE` can be used to specify the country code for the phone number used to login 198 | 199 | ## 0.5.2 200 | 201 | ### Fixed 202 | 203 | * Gateway was not logging-in properly after a logout 204 | 205 | ### Changed 206 | 207 | * Config option `SAIC_REST_URI` now points to the new production API endpoint by default 208 | 209 | ### Added 210 | 211 | * Config option `SAIC_REGION` is used to select the new API region 212 | * Config option `SAIC_TENANT_ID` is used to select the new API tenant 213 | 214 | Both values default to the EU instance production values 215 | 216 | ### Removed 217 | 218 | * Drop config option `SAIC_URI` as it is no longer relevant 219 | 220 | ## 0.5.1 221 | 222 | ### Fixed 223 | 224 | * Typo in check_for_new_messages() fixed 225 | 226 | ### Added 227 | 228 | * Configurable messages-request-interval 229 | 230 | ## 0.5.0 231 | 232 | ### Changed 233 | 234 | * Switch to saic-python-client-ng library (New SAIC API) 235 | * blowing only command fixed 236 | 237 | ## 0.4.7 238 | 239 | ### Changed 240 | 241 | * Whenever a chargingValue is received that is different from the last received value, a forced refresh is performed 242 | * The socTopic is an optional field in the charging station configuration 243 | 244 | ## 0.4.6 245 | 246 | ### Fixed 247 | 248 | * Detection of battery type 249 | * Remove special characters from username to generate valid MQTT topics 250 | * Setting ha_discovery_enabled to False had no effect 251 | * Docker image based on python:3.11-slim 252 | * Force refresh by charging station only if charging value has changed 253 | * MQTT connection error logging 254 | * Front window heating enables "Blowing only" 255 | 256 | ## 0.4.5 257 | 258 | ### Fixed 259 | 260 | * Binary string decoding issue fixed in saic-python-client 1.6.5 261 | 262 | ## 0.4.4 263 | 264 | ### Fixed 265 | 266 | * Error message decoding issue fixed in saic-python-client 1.6.4 267 | 268 | ## 0.4.3 269 | 270 | ### Fixed 271 | 272 | * Previous fix corrects dispatcher message size for V2 messages. Now it is also fixed for V1 messages. 273 | 274 | ## 0.4.2 275 | 276 | ### Fixed 277 | 278 | * Previous fix works only for messages without application data. Those are typically error messages that are provided 279 | with wrong dispatcher message size 280 | 281 | ## 0.4.1 282 | 283 | ### Fixed 284 | 285 | * Calculate dispatcher message size and use the calculated value if it differs from the provided API value 286 | 287 | ## 0.4.0 288 | 289 | ### Added 290 | 291 | * Control charge current limit 292 | * Dynamic refresh period during charging 293 | * Force polling around scheduled charging start 294 | * Further A/C enhancements 295 | * Generic charging station integration (OPENWB_LP_MAP argument is deprecated now) 296 | * TLS support 297 | 298 | ## 0.3.0 299 | 300 | ### Added 301 | 302 | * Keep polling for a configurable amount of time after the vehicle has been shutdown 303 | * Battery (SoC) target load 304 | * Start/Stop charging 305 | * Enhanced A/C control 306 | * Turn off message requests when refresh mode is off 307 | * Home Assistant auto-discovery 308 | 309 | ### Fixed 310 | 311 | * Vehicle and charging status updates stop after a while 312 | * Inconsistent topic name for battery management data (BMS) removed 313 | 314 | ## 0.2.4 315 | 316 | ### Added 317 | 318 | * docker support for architecture linux/arm/v7 319 | 320 | ## 0.2.3 321 | 322 | ### Added 323 | 324 | * Using new saic-ismart-client (version 1.3.0) 325 | * Feature: transmit ABRP data even if we have no GPS data 326 | 327 | ### Fixed 328 | 329 | * empty environment variables are ignored 330 | * Driving detection fixed 331 | 332 | ## 0.2.2 333 | 334 | Vehicle control commands are finally working 335 | 336 | ### Added 337 | 338 | * Turn front window defroster heating on or off 339 | * Turn A/C on or off 340 | * Configurable re-login delay 341 | * Using new saic-ismart-client (version 1.2.6) 342 | * Environment variable to configure log level 343 | 344 | ### Fixed 345 | 346 | * environment variable overwrites the predefined default value 347 | 348 | ## 0.2.1 349 | 350 | ### Added 351 | 352 | * MQTT commands documented in README.md 353 | 354 | ### Changed 355 | 356 | * Wait 15 seconds (average SMS delivery time) for the vehicle to wake up 357 | * Using new saic-ismart-client (version 1.1.7) 358 | * Improved parsing for configuration value mappings 359 | 360 | ### Fixed 361 | 362 | * Make force command more reliable 363 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG POETRY_VERSION=2.1.3 2 | ARG PYTHON_VERSION=3.12 3 | 4 | FROM nanomad/poetry:${POETRY_VERSION}-python-${PYTHON_VERSION} AS builder 5 | 6 | WORKDIR /usr/src/app 7 | 8 | # --- Reproduce the environment --- 9 | # You can comment the following two lines if you prefer to manually install 10 | # the dependencies from inside the container. 11 | COPY pyproject.toml poetry.lock /usr/src/app/ 12 | 13 | # Install the dependencies and clear the cache afterwards. 14 | # This may save some MBs. 15 | RUN poetry install --no-root && rm -rf $POETRY_CACHE_DIR 16 | 17 | # Now let's build the runtime image from the builder. 18 | # We'll just copy the env and the PATH reference. 19 | FROM python:${PYTHON_VERSION}-slim AS runtime 20 | 21 | WORKDIR /usr/src/app 22 | 23 | ENV VIRTUAL_ENV=/usr/src/app/.venv 24 | ENV PATH="/usr/src/app/.venv/bin:$PATH" 25 | 26 | COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV} 27 | COPY src/ . 28 | COPY examples/ . 29 | 30 | CMD [ "python", "./main.py"] -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 ReverseEngineeringDE 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | saic-mqtt-gateway: 3 | image: "saicismartapi/saic-python-mqtt-gateway:latest" 4 | build: 5 | context: . 6 | container_name: "saic-mqtt-gateway" 7 | environment: 8 | - MQTT_URI=${MQTT_BROKER_URI} 9 | - MQTT_USER=${MQTT_USERNAME} 10 | - MQTT_PASSWORD=${MQTT_PWD} 11 | - SAIC_USER=${SAIC_USERNAME} 12 | - SAIC_PASSWORD=${SAIC_PWD} 13 | volumes: 14 | - ./charging-stations.json:/usr/src/app/charging-stations.json 15 | -------------------------------------------------------------------------------- /examples/charging-stations.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /examples/charging-stations.json.sample: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "chargeStateTopic": "openWB/lp/1/boolChargeStat", 4 | "chargingValue": "1", 5 | "socTopic": "openWB/set/lp/1/%Soc", 6 | "chargerConnectedTopic": "openWB/lp/1/boolPlugStat", 7 | "chargerConnectedValue": "1", 8 | "vin": "vin1" 9 | }, 10 | { 11 | "chargeStateTopic": "openWB/lp/2/boolChargeStat", 12 | "chargingValue": "1", 13 | "socTopic": "openWB/set/lp/2/%Soc", 14 | "chargerConnectedTopic": "openWB/lp/2/boolPlugStat", 15 | "chargerConnectedValue": "1", 16 | "vin": "vin2" 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /examples/charging-stations.json.sample_openWB_2.0: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "chargeStateTopic": "openWB/chargepoint/2/get/charge_state", 4 | "chargingValue": "true", 5 | "socTopic": "openWB/set/vehicle/2/get/soc", 6 | "rangeTopic": "openWB/set/vehicle/2/get/range", 7 | "chargerConnectedTopic": "openWB/chargepoint/2/get/plug_state", 8 | "chargerConnectedValue": "true", 9 | "vin": "vin1" 10 | }, 11 | { 12 | "chargeStateTopic": "openWB/chargepoint/3/get/charge_state", 13 | "chargingValue": "true", 14 | "socTopic": "openWB/set/vehicle/3/get/soc", 15 | "rangeTopic": "openWB/set/vehicle/3/get/range", 16 | "chargerConnectedTopic": "openWB/chargepoint/3/get/plug_state", 17 | "chargerConnectedValue": "true", 18 | "vin": "vin2" 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "saic-python-mqtt-gateway" 3 | version = "0.9.5" 4 | description = "A service that queries the data from an MG iSMART account and publishes the data over MQTT and to other sources" 5 | authors = [ 6 | { name = "Giovanni Condello", email = "saic-python-client@nanomad.net" } 7 | ] 8 | license = "MIT" 9 | readme = "README.md" 10 | 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | ] 16 | 17 | requires-python = '>=3.12,<4.0' 18 | dependencies = [ 19 | "saic-ismart-client-ng (>=0.9.1,<0.10.0)", 20 | 'httpx (>=0.28.1,<0.29.0)', 21 | 'gmqtt (>=0.7.0,<0.8.0)', 22 | 'inflection (>=0.5.1,<0.6.0)', 23 | 'apscheduler (>=3.11.0,<4.0.0)', 24 | ] 25 | 26 | [project.urls] 27 | Homepage = "https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway" 28 | Issues = "https://github.com/SAIC-iSmart-API/saic-python-mqtt-gateway/issues" 29 | 30 | [tool.poetry] 31 | package-mode = false 32 | requires-poetry = '>=2.0,<3.0' 33 | 34 | [tool.poetry.group.dev.dependencies] 35 | pytest = "^8.2.2" 36 | mock = "^5.1.0" 37 | coverage = "^7.5.4" 38 | ruff = "^0.11.12" 39 | pytest-cov = "^6.0.0" 40 | pytest-asyncio = "^0.25.2" 41 | pytest-mock = "^3.14.0" 42 | mypy = "^1.15.0" 43 | pylint = "^3.3.6" 44 | 45 | [tool.poetry.dependencies] 46 | saic-ismart-client-ng = { develop = true } 47 | 48 | [build-system] 49 | requires = ["poetry-core>=2.0.0"] 50 | build-backend = "poetry.core.masonry.api" 51 | 52 | [tool.pytest.ini_options] 53 | norecursedirs = ".git build dist" 54 | testpaths = "tests" 55 | pythonpath = [ 56 | "src", 57 | "tests" 58 | ] 59 | mock_use_standalone_module = true 60 | addopts = [ 61 | "--import-mode=importlib", 62 | ] 63 | asyncio_default_fixture_loop_scope = "function" 64 | 65 | [tool.coverage.run] 66 | omit = [ 67 | "tests/*", 68 | ] 69 | branch = true 70 | command_line = "-m pytest" 71 | 72 | [tool.coverage.report] 73 | # Regexes for lines to exclude from consideration 74 | exclude_lines = [ 75 | # Have to re-enable the standard pragma 76 | 'pragma: no cover', 77 | # Don't complain about missing debug-only code: 78 | 'def __repr__', 79 | 'if self\.debug', 80 | # Don't complain if tests don't hit defensive assertion code: 81 | 'raise AssertionError', 82 | 'raise NotImplementedError', 83 | # Don't complain if non-runnable code isn't run: 84 | 'if 0:', 85 | 'if __name__ == .__main__.:', 86 | ] 87 | ignore_errors = true 88 | 89 | [tool.ruff] 90 | include = [ 91 | "src/**/*.py", 92 | "tests/**/*.py", 93 | "**/pyproject.toml" 94 | ] 95 | [tool.ruff.lint] 96 | select = ["ALL"] 97 | 98 | ignore = [ 99 | "ANN401", # Opinioated warning on disallowing dynamically typed expressions 100 | "D203", # Conflicts with other rules 101 | "D213", # Conflicts with other rules 102 | "EM101", # raw-string-in-exception 103 | 104 | "D105", # Missing docstring in magic method 105 | "D107", # Missing docstring in `__init__` 106 | "E501", # line too long 107 | 108 | "FBT", # flake8-boolean-trap 109 | 110 | "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable 111 | 112 | # Used to map JSON responses 113 | "N815", 114 | # Conflicts with the Ruff formatter 115 | "COM812", 116 | # We use Exception istead of Error 117 | "N818", 118 | # Remove later 119 | "D100", # Missing docstring in public module 120 | "D101", # Missing docstring in public class 121 | "D102", # Missing docstring in public method 122 | "D103", # Missing docstring in public function 123 | "D104", # Missing docstring in public package 124 | "D106", # Missing docstring in public nested class 125 | "TD", # Todos 126 | "A", # bultins 127 | "DTZ", # use tz need to test it first 128 | "TRY", # tryceratops 129 | "FIX002", # Line contains TODO, consider resolving the issue 130 | "BLE001", # Do not catch blind exception: `Exception`, 131 | "PLR0913", # Too many arguments in function definition 132 | "ERA001", # Commented-out code 133 | "PLR0912", # Logging statement uses f-string 134 | "G004", # Logging statement uses f-string 135 | ] 136 | 137 | [tool.ruff.lint.flake8-pytest-style] 138 | fixture-parentheses = false 139 | 140 | 141 | [tool.ruff.lint.isort] 142 | combine-as-imports = true 143 | force-sort-within-sections = true 144 | required-imports = ["from __future__ import annotations"] 145 | 146 | 147 | [tool.ruff.lint.per-file-ignores] 148 | "tests/**" = [ 149 | "D100", # Missing docstring in public module 150 | "D103", # Missing docstring in public function 151 | "D104", # Missing docstring in public package 152 | "N802", # Function name {name} should be lowercase 153 | "N816", # Variable {name} in global scope should not be mixedCase 154 | "S101", # Use of assert detected 155 | "SLF001", # Private member accessed: {access} 156 | "T201", # print found 157 | ] 158 | 159 | [tool.ruff.lint.mccabe] 160 | max-complexity = 13 161 | 162 | [tool.ruff.lint.pylint] 163 | max-args = 10 164 | 165 | [tool.mypy] 166 | files = ["./src", "./tests"] 167 | python_version = 3.12 168 | show_error_codes = true 169 | strict_equality = true 170 | warn_incomplete_stub = true 171 | warn_redundant_casts = true 172 | warn_unused_configs = true 173 | warn_unused_ignores = true 174 | check_untyped_defs = true 175 | disallow_incomplete_defs = true 176 | disallow_subclassing_any = true 177 | disallow_untyped_calls = true 178 | disallow_untyped_decorators = true 179 | disallow_untyped_defs = true 180 | no_implicit_optional = true 181 | warn_return_any = true 182 | warn_unreachable = true 183 | strict = true 184 | 185 | [[tool.mypy.overrides]] 186 | module = ["apscheduler.*"] 187 | ignore_missing_imports = true 188 | 189 | [[tool.mypy.overrides]] 190 | module = ["gmqtt.*"] 191 | ignore_missing_imports = true 192 | follow_untyped_imports = true 193 | 194 | [[tool.mypy.overrides]] 195 | module = ["publisher.mqtt_publisher"] 196 | disallow_untyped_calls = false 197 | 198 | [[tool.mypy.overrides]] 199 | module = ["configuration.argparse_extensions"] 200 | disable_error_code = ["arg-type"] 201 | 202 | [tool.pylint.MAIN] 203 | py-version = "3.11" 204 | ignore = ["tests"] 205 | fail-on = ["I"] 206 | 207 | [tool.pylint.BASIC] 208 | good-names = ["i", "j", "k", "ex", "_", "T", "x", "y", "id", "tg"] 209 | 210 | [tool.pylint."MESSAGES CONTROL"] 211 | # Reasons disabled: 212 | # format - handled by black 213 | # duplicate-code - unavoidable 214 | # cyclic-import - doesn't test if both import on load 215 | # abstract-class-little-used - prevents from setting right foundation 216 | # too-many-* - are not enforced for the sake of readability 217 | # too-few-* - same as too-many-* 218 | # abstract-method - with intro of async there are always methods missing 219 | # inconsistent-return-statements - doesn't handle raise 220 | # too-many-ancestors - it's too strict. 221 | # wrong-import-order - isort guards this 222 | # --- 223 | # Pylint CodeStyle plugin 224 | # consider-using-namedtuple-or-dataclass - too opinionated 225 | # consider-using-assignment-expr - decision to use := better left to devs 226 | disable = [ 227 | "format", 228 | "cyclic-import", 229 | "duplicate-code", 230 | "too-many-arguments", 231 | "too-many-instance-attributes", 232 | "too-many-locals", 233 | "too-many-ancestors", 234 | "too-few-public-methods", 235 | "invalid-name", 236 | # Remove later 237 | "missing-function-docstring", 238 | "missing-module-docstring", 239 | "missing-class-docstring", 240 | "broad-exception-caught", 241 | "logging-fstring-interpolation", 242 | "fixme" 243 | ] 244 | enable = ["useless-suppression", "use-symbolic-message-instead"] 245 | 246 | [tool.pylint.REPORTS] 247 | score = false 248 | 249 | [tool.pylint.FORMAT] 250 | expected-line-ending-format = "LF" 251 | 252 | [tool.pylint.EXCEPTIONS] 253 | overgeneral-exceptions = ["builtins.BaseException", "builtins.Exception"] -------------------------------------------------------------------------------- /src/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from integrations.openwb.charging_station import ChargingStation 8 | 9 | 10 | class TransportProtocol(Enum): 11 | def __init__(self, transport_mechanism: str, with_tls: bool) -> None: 12 | self.transport_mechanism = transport_mechanism 13 | self.with_tls = with_tls 14 | 15 | TCP = "tcp", False 16 | WS = "websockets", False 17 | TLS = "tcp", True 18 | 19 | 20 | class Configuration: 21 | def __init__(self) -> None: 22 | self.saic_user: str | None = None 23 | self.saic_password: str | None = None 24 | self.__saic_phone_country_code: str | None = None 25 | self.saic_rest_uri: str = "https://gateway-mg-eu.soimt.com/api.app/v1/" 26 | self.saic_region: str = "eu" 27 | self.saic_tenant_id: str = "459771" 28 | self.saic_relogin_delay: int = 15 * 60 # in seconds 29 | self.saic_read_timeout: float = 10.0 # in seconds 30 | self.battery_capacity_map: dict[str, float] = {} 31 | self.mqtt_host: str | None = None 32 | self.mqtt_port: int = 1883 33 | self.mqtt_transport_protocol: TransportProtocol = TransportProtocol.TCP 34 | self.tls_server_cert_path: str | None = None 35 | self.mqtt_user: str | None = None 36 | self.mqtt_password: str | None = None 37 | self.mqtt_client_id: str = "saic-python-mqtt-gateway" 38 | self.mqtt_topic: str = "saic" 39 | self.mqtt_allow_dots_in_topic: bool = True 40 | self.charging_stations_by_vin: dict[str, ChargingStation] = {} 41 | self.anonymized_publishing: bool = False 42 | self.messages_request_interval: int = 60 # in seconds 43 | self.ha_discovery_enabled: bool = True 44 | self.ha_discovery_prefix: str = "homeassistant" 45 | self.ha_show_unavailable: bool = True 46 | self.charge_dynamic_polling_min_percentage: float = 1.0 47 | self.publish_raw_api_data: bool = False 48 | 49 | # ABRP Integration 50 | self.abrp_token_map: dict[str, str] = {} 51 | self.abrp_api_key: str | None = None 52 | self.publish_raw_abrp_data: bool = False 53 | 54 | # OsmAnd Integration 55 | self.osmand_device_id_map: dict[str, str] = {} 56 | self.osmand_server_uri: str | None = None 57 | self.publish_raw_osmand_data: bool = False 58 | 59 | @property 60 | def is_mqtt_enabled(self) -> bool: 61 | return self.mqtt_host is not None and len(str(self.mqtt_host)) > 0 62 | 63 | @property 64 | def username_is_email(self) -> bool: 65 | return self.saic_user is not None and "@" in self.saic_user 66 | 67 | @property 68 | def ha_lwt_topic(self) -> str: 69 | return f"{self.ha_discovery_prefix}/status" 70 | 71 | @property 72 | def saic_phone_country_code(self) -> str | None: 73 | return None if self.username_is_email else self.__saic_phone_country_code 74 | 75 | @saic_phone_country_code.setter 76 | def saic_phone_country_code(self, country_code: str | None) -> None: 77 | self.__saic_phone_country_code = country_code 78 | -------------------------------------------------------------------------------- /src/configuration/argparse_extensions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | from argparse import ArgumentParser, Namespace 5 | import os 6 | from typing import TYPE_CHECKING, Any, override 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Callable, Sequence 10 | 11 | 12 | class EnvDefault(argparse.Action): 13 | def __init__( 14 | self, 15 | envvar: str, 16 | required: bool = True, 17 | default: str | None = None, 18 | **kwargs: dict[str, Any], 19 | ) -> None: 20 | if os.environ.get(envvar): 21 | default = os.environ[envvar] 22 | if required and default: 23 | required = False 24 | super().__init__(default=default, required=required, **kwargs) 25 | 26 | @override 27 | def __call__( 28 | self, 29 | parser: ArgumentParser, 30 | namespace: Namespace, 31 | values: str | Sequence[str] | None, 32 | option_string: str | None = None, 33 | ) -> None: 34 | setattr(namespace, self.dest, values) 35 | 36 | 37 | def cfg_value_to_dict( 38 | cfg_value: str, result_map: dict[str, Any], value_type: Callable[[str], Any] = str 39 | ) -> None: 40 | map_entries = cfg_value.split(",") if "," in cfg_value else [cfg_value] 41 | 42 | for entry in map_entries: 43 | if "=" in entry: 44 | key_value_pair = entry.split("=") 45 | key = key_value_pair[0] 46 | value = key_value_pair[1] 47 | result_map[key] = value_type(value) 48 | 49 | 50 | def check_positive(value: str) -> int: 51 | ivalue = int(value) 52 | if ivalue <= 0: 53 | msg = f"{ivalue} is an invalid positive int value" 54 | raise argparse.ArgumentTypeError(msg) 55 | return ivalue 56 | 57 | 58 | def check_positive_float(value: str) -> float: 59 | fvalue = float(value) 60 | if fvalue <= 0: 61 | msg = f"{fvalue} is an invalid positive float value" 62 | raise argparse.ArgumentTypeError(msg) 63 | return fvalue 64 | 65 | 66 | def check_bool(value: str) -> bool: 67 | return str(value).lower() in ["true", "1", "yes", "y"] 68 | -------------------------------------------------------------------------------- /src/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class MqttGatewayException(Exception): 5 | def __init__(self, msg: str) -> None: 6 | self.message = msg 7 | 8 | def __str__(self) -> str: 9 | return self.message 10 | -------------------------------------------------------------------------------- /src/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAIC-iSmart-API/saic-python-mqtt-gateway/84f96c7c0040ac77be40d4beb2a8cedc9356829d/src/handlers/__init__.py -------------------------------------------------------------------------------- /src/handlers/message.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import logging 5 | from typing import TYPE_CHECKING 6 | 7 | from saic_ismart_client_ng.exceptions import SaicApiException, SaicLogoutException 8 | 9 | from vehicle import RefreshMode 10 | 11 | if TYPE_CHECKING: 12 | from saic_ismart_client_ng import SaicApi 13 | from saic_ismart_client_ng.api.message.schema import MessageEntity 14 | 15 | from handlers.relogin import ReloginHandler 16 | from handlers.vehicle import VehicleHandlerLocator 17 | 18 | LOG = logging.getLogger(__name__) 19 | 20 | 21 | class MessageHandler: 22 | def __init__( 23 | self, 24 | gateway: VehicleHandlerLocator, 25 | relogin_handler: ReloginHandler, 26 | saicapi: SaicApi, 27 | ) -> None: 28 | self.gateway = gateway 29 | self.saicapi = saicapi 30 | self.relogin_handler = relogin_handler 31 | self.last_message_ts = datetime.datetime.min 32 | self.last_message_id: str | int | None = None 33 | 34 | async def check_for_new_messages(self) -> None: 35 | if self.__should_poll(): 36 | try: 37 | LOG.debug("Checking for new messages") 38 | await self.__polling() 39 | except Exception as e: 40 | LOG.exception("MessageHandler poll loop failed", exc_info=e) 41 | 42 | async def __polling(self) -> None: 43 | try: 44 | all_messages = await self.__get_all_alarm_messages() 45 | LOG.info(f"{len(all_messages)} messages received") 46 | 47 | new_messages = [m for m in all_messages if m.read_status != "read"] 48 | for message in new_messages: 49 | LOG.info(message.details) 50 | await self.__read_message(message) 51 | 52 | latest_message = self.__get_latest_message(all_messages) 53 | if ( 54 | latest_message is not None 55 | and latest_message.messageId != self.last_message_id 56 | and latest_message.message_time > self.last_message_ts 57 | ): 58 | self.last_message_id = latest_message.messageId 59 | self.last_message_ts = latest_message.message_time 60 | LOG.info( 61 | f"{latest_message.title} detected at {latest_message.message_time}" 62 | ) 63 | if (vin := latest_message.vin) and ( 64 | vehicle_handler := self.gateway.get_vehicle_handler(vin) 65 | ): 66 | vehicle_handler.vehicle_state.notify_message(latest_message) 67 | 68 | # Delete vehicle start messages unless they are the latest 69 | vehicle_start_messages = [ 70 | m 71 | for m in all_messages 72 | if m.messageType == "323" and m.messageId != self.last_message_id 73 | ] 74 | for vehicle_start_message in vehicle_start_messages: 75 | await self.__delete_message(vehicle_start_message) 76 | except SaicLogoutException as e: 77 | LOG.error("API Client was logged out, waiting for a new login", exc_info=e) 78 | self.relogin_handler.relogin() 79 | except SaicApiException as e: 80 | LOG.exception( 81 | "MessageHandler poll loop failed during SAIC API Call", exc_info=e 82 | ) 83 | except Exception as e: 84 | LOG.exception("MessageHandler poll loop failed unexpectedly", exc_info=e) 85 | 86 | async def __get_all_alarm_messages(self) -> list[MessageEntity]: 87 | idx = 1 88 | all_messages = [] 89 | while True: 90 | try: 91 | message_list = await self.saicapi.get_alarm_list( 92 | page_num=idx, page_size=1 93 | ) 94 | if ( 95 | message_list is not None 96 | and message_list.messages 97 | and len(message_list.messages) > 0 98 | ): 99 | all_messages.extend(message_list.messages) 100 | else: 101 | return all_messages 102 | oldest_message = self.__get_oldest_message(all_messages) 103 | if ( 104 | oldest_message is not None 105 | and oldest_message.message_time < self.last_message_ts 106 | ): 107 | return all_messages 108 | except SaicLogoutException as e: 109 | raise e 110 | except Exception as e: 111 | LOG.exception( 112 | "Error while fetching a message from the SAIC API, please open the app and clear them, " 113 | "then report this as a bug.", 114 | exc_info=e, 115 | ) 116 | finally: 117 | idx = idx + 1 118 | 119 | async def __delete_message(self, message: MessageEntity) -> None: 120 | try: 121 | message_id = message.messageId 122 | if message_id is not None: 123 | await self.saicapi.delete_message(message_id=message_id) 124 | LOG.info(f"{message.title} message with ID {message_id} deleted") 125 | else: 126 | LOG.warning("Could not delete message '%s' as it has no ID", message) 127 | except Exception as e: 128 | LOG.exception("Could not delete message from server", exc_info=e) 129 | 130 | async def __read_message(self, message: MessageEntity) -> None: 131 | try: 132 | message_id = message.messageId 133 | if message_id is not None: 134 | await self.saicapi.read_message(message_id=message_id) 135 | LOG.info(f"{message.title} message with ID {message_id} marked as read") 136 | else: 137 | LOG.warning( 138 | "Could not mark message '%s' as read as it has not ID", message 139 | ) 140 | except Exception as e: 141 | LOG.exception("Could not mark message as read from server", exc_info=e) 142 | 143 | def __should_poll(self) -> bool: 144 | vehicle_handlers = self.gateway.vehicle_handlers or {} 145 | refresh_modes = [ 146 | vh.vehicle_state.refresh_mode 147 | for vh in vehicle_handlers.values() 148 | if vh.vehicle_state is not None 149 | ] 150 | # We do not poll if we have no cars or all cars have RefreshMode.OFF 151 | if len(refresh_modes) == 0 or all( 152 | mode == RefreshMode.OFF for mode in refresh_modes 153 | ): 154 | LOG.debug("Not checking for new messages as all cars have RefreshMode.OFF") 155 | return False 156 | if self.relogin_handler.relogin_in_progress: 157 | LOG.warning( 158 | "Not checking for new messages as we are waiting to log back in" 159 | ) 160 | return False 161 | return True 162 | 163 | @staticmethod 164 | def __get_latest_message( 165 | vehicle_start_messages: list[MessageEntity], 166 | ) -> MessageEntity | None: 167 | if len(vehicle_start_messages) == 0: 168 | return None 169 | return max(vehicle_start_messages, key=lambda m: m.message_time) 170 | 171 | @staticmethod 172 | def __get_oldest_message( 173 | vehicle_start_messages: list[MessageEntity], 174 | ) -> MessageEntity | None: 175 | if len(vehicle_start_messages) == 0: 176 | return None 177 | return min(vehicle_start_messages, key=lambda m: m.message_time) 178 | -------------------------------------------------------------------------------- /src/handlers/relogin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime, timedelta 4 | import logging 5 | from typing import TYPE_CHECKING 6 | 7 | if TYPE_CHECKING: 8 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 9 | from saic_ismart_client_ng import SaicApi 10 | 11 | LOG = logging.getLogger(__name__) 12 | JOB_ID = "relogin_task" 13 | 14 | 15 | class ReloginHandler: 16 | def __init__( 17 | self, *, relogin_relay: int, api: SaicApi, scheduler: AsyncIOScheduler 18 | ) -> None: 19 | self.__relogin_relay = relogin_relay 20 | self.__scheduler = scheduler 21 | self.__api = api 22 | self.__login_task = None 23 | 24 | @property 25 | def relogin_in_progress(self) -> bool: 26 | return self.__login_task is not None 27 | 28 | def relogin(self) -> None: 29 | if self.__login_task is None: 30 | LOG.warning( 31 | f"API Client got logged out, logging back in {self.__relogin_relay} seconds" 32 | ) 33 | self.__login_task = self.__scheduler.add_job( 34 | func=self.login, 35 | trigger="date", 36 | run_date=datetime.now() + timedelta(seconds=self.__relogin_relay), 37 | id=JOB_ID, 38 | name="Re-login the API client after a set delay", 39 | max_instances=1, 40 | ) 41 | 42 | async def login(self) -> None: 43 | try: 44 | LOG.info("Logging in to SAIC API") 45 | login_response_message = await self.__api.login() 46 | LOG.info("Logged in as %s", login_response_message.account) 47 | except Exception as e: 48 | LOG.exception("Could not login to the SAIC API due to an error", exc_info=e) 49 | raise e 50 | finally: 51 | if self.__scheduler.get_job(JOB_ID) is not None: 52 | self.__scheduler.remove_job(JOB_ID) 53 | self.__login_task = None 54 | -------------------------------------------------------------------------------- /src/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class IntegrationException(Exception): 5 | def __init__(self, integration: str, msg: str) -> None: 6 | self.message = f"{integration}: {msg}" 7 | 8 | def __str__(self) -> str: 9 | return self.message 10 | -------------------------------------------------------------------------------- /src/integrations/abrp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAIC-iSmart-API/saic-python-mqtt-gateway/84f96c7c0040ac77be40d4beb2a8cedc9356829d/src/integrations/abrp/__init__.py -------------------------------------------------------------------------------- /src/integrations/abrp/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | import json 5 | import logging 6 | from typing import TYPE_CHECKING, Any 7 | 8 | import httpx 9 | from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus 10 | 11 | from integrations import IntegrationException 12 | from utils import get_update_timestamp, value_in_range 13 | 14 | if TYPE_CHECKING: 15 | from saic_ismart_client_ng.api.vehicle import VehicleStatusResp 16 | from saic_ismart_client_ng.api.vehicle.schema import BasicVehicleStatus 17 | from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp 18 | from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus 19 | 20 | LOG = logging.getLogger(__name__) 21 | 22 | 23 | class AbrpApiException(IntegrationException): 24 | def __init__(self, msg: str) -> None: 25 | super().__init__(__name__, msg) 26 | 27 | 28 | class AbrpApiListener(ABC): 29 | @abstractmethod 30 | async def on_request( 31 | self, 32 | path: str, 33 | body: str | None = None, 34 | headers: dict[str, str] | None = None, 35 | ) -> None: 36 | pass 37 | 38 | @abstractmethod 39 | async def on_response( 40 | self, 41 | path: str, 42 | body: str | None = None, 43 | headers: dict[str, str] | None = None, 44 | ) -> None: 45 | pass 46 | 47 | 48 | class AbrpApi: 49 | def __init__( 50 | self, 51 | abrp_api_key: str | None, 52 | abrp_user_token: str | None, 53 | listener: AbrpApiListener | None = None, 54 | ) -> None: 55 | self.abrp_api_key = abrp_api_key 56 | self.abrp_user_token = abrp_user_token 57 | self.__listener = listener 58 | self.__base_uri = "https://api.iternio.com/1/" 59 | self.client = httpx.AsyncClient( 60 | event_hooks={ 61 | "request": [self.invoke_request_listener], 62 | "response": [self.invoke_response_listener], 63 | } 64 | ) 65 | 66 | async def update_abrp( 67 | self, 68 | vehicle_status: VehicleStatusResp | None, 69 | charge_info: ChrgMgmtDataResp | None, 70 | ) -> tuple[bool, str]: 71 | charge_mgmt_data = None if charge_info is None else charge_info.chrgMgmtData 72 | charge_status = None if charge_info is None else charge_info.rvsChargeStatus 73 | 74 | if ( 75 | self.abrp_api_key is not None 76 | and self.abrp_user_token is not None 77 | and vehicle_status is not None 78 | and charge_mgmt_data is not None 79 | ): 80 | # Request 81 | tlm_send_url = f"{self.__base_uri}tlm/send" 82 | data: dict[str, Any] = { 83 | # Guess the timestamp from either the API, GPS info or current machine time 84 | "utc": int(get_update_timestamp(vehicle_status).timestamp()), 85 | } 86 | if (soc := charge_mgmt_data.bmsPackSOCDsp) is not None: 87 | data.update( 88 | { 89 | "soc": (soc / 10.0), 90 | } 91 | ) 92 | 93 | # Skip invalid current values reported by the API 94 | decoded_current = charge_mgmt_data.decoded_current 95 | is_valid_current = ( 96 | charge_mgmt_data.bmsPackCrntV != 1 97 | and (raw_current := charge_mgmt_data.bmsPackCrnt) is not None 98 | and value_in_range(raw_current, 0, 65535) 99 | and decoded_current is not None 100 | ) 101 | if is_valid_current: 102 | is_charging = ( 103 | charge_status is not None 104 | and charge_status.chargingGunState 105 | and decoded_current is not None 106 | and decoded_current < 0.0 107 | ) 108 | data.update( 109 | { 110 | "power": charge_mgmt_data.decoded_power, 111 | "voltage": charge_mgmt_data.decoded_voltage, 112 | "current": decoded_current, 113 | "is_charging": is_charging, 114 | } 115 | ) 116 | 117 | basic_vehicle_status = vehicle_status.basicVehicleStatus 118 | if basic_vehicle_status is not None: 119 | data.update(self.__extract_basic_vehicle_status(basic_vehicle_status)) 120 | 121 | # Extract electric range if available 122 | data.update( 123 | self.__extract_electric_range(basic_vehicle_status, charge_status) 124 | ) 125 | 126 | gps_position = vehicle_status.gpsPosition 127 | if gps_position is not None: 128 | data.update(self.__extract_gps_position(gps_position)) 129 | 130 | headers = {"Authorization": f"APIKEY {self.abrp_api_key}"} 131 | 132 | try: 133 | response = await self.client.post( 134 | url=tlm_send_url, 135 | headers=headers, 136 | params={"token": self.abrp_user_token, "tlm": json.dumps(data)}, 137 | ) 138 | await response.aread() 139 | return True, response.text 140 | except httpx.ConnectError as ece: 141 | msg = f"Connection error: {ece}" 142 | raise AbrpApiException(msg) from ece 143 | except httpx.TimeoutException as et: 144 | msg = f"Timeout error {et}" 145 | raise AbrpApiException(msg) from et 146 | except httpx.RequestError as e: 147 | msg = f"{e}" 148 | raise AbrpApiException(msg) from e 149 | except httpx.HTTPError as ehttp: 150 | msg = f"HTTP error {ehttp}" 151 | raise AbrpApiException(msg) from ehttp 152 | else: 153 | return False, "ABRP request skipped because of missing configuration" 154 | 155 | @staticmethod 156 | def __extract_basic_vehicle_status( 157 | basic_vehicle_status: BasicVehicleStatus, 158 | ) -> dict[str, Any]: 159 | data: dict[str, Any] = { 160 | "is_parked": basic_vehicle_status.is_parked, 161 | } 162 | 163 | exterior_temperature = basic_vehicle_status.exteriorTemperature 164 | if exterior_temperature is not None and value_in_range( 165 | exterior_temperature, -127, 127 166 | ): 167 | data["ext_temp"] = exterior_temperature 168 | mileage = basic_vehicle_status.mileage 169 | # Skip invalid range readings 170 | if mileage is not None and value_in_range(mileage, 1, 2147483647): 171 | # Data must be reported in km 172 | data["odometer"] = mileage / 10.0 173 | 174 | if basic_vehicle_status.is_parked: 175 | # We assume the vehicle is stationary, we will update it later from GPS if available 176 | data["speed"] = 0.0 177 | 178 | return data 179 | 180 | @staticmethod 181 | def __extract_gps_position(gps_position: GpsPosition) -> dict[str, Any]: 182 | data: dict[str, Any] = {} 183 | 184 | # Do not use GPS data if it is not available 185 | if gps_position.gps_status_decoded not in [GpsStatus.FIX_2D, GpsStatus.FIX_3d]: 186 | return data 187 | 188 | way_point = gps_position.wayPoint 189 | if way_point is None: 190 | return data 191 | 192 | speed = way_point.speed 193 | if speed is not None and value_in_range(speed, -999, 4500): 194 | data["speed"] = speed / 10 195 | 196 | heading = way_point.heading 197 | if heading is not None and value_in_range(heading, 0, 360): 198 | data["heading"] = heading 199 | 200 | position = way_point.position 201 | if position is None: 202 | return data 203 | 204 | altitude = position.altitude 205 | if altitude is not None and value_in_range(altitude, -500, 8900): 206 | data["elevation"] = altitude 207 | 208 | if (raw_lat := position.latitude) is not None and ( 209 | raw_lon := position.longitude 210 | ) is not None: 211 | lat_degrees = raw_lat / 1000000.0 212 | lon_degrees = raw_lon / 1000000.0 213 | 214 | if abs(lat_degrees) <= 90 and abs(lon_degrees) <= 180: 215 | data.update( 216 | { 217 | "lat": lat_degrees, 218 | "lon": lon_degrees, 219 | } 220 | ) 221 | 222 | return data 223 | 224 | def __extract_electric_range( 225 | self, 226 | basic_vehicle_status: BasicVehicleStatus | None, 227 | charge_status: RvsChargeStatus | None, 228 | ) -> dict[str, Any]: 229 | data = {} 230 | 231 | range_elec_vehicle = 0.0 232 | if ( 233 | basic_vehicle_status 234 | and (fuel_range := basic_vehicle_status.fuelRangeElec) is not None 235 | ): 236 | range_elec_vehicle = self.__parse_electric_range(raw_value=fuel_range) 237 | 238 | range_elec_bms = 0.0 239 | if charge_status and (fuel_range := charge_status.fuelRangeElec) is not None: 240 | range_elec_bms = self.__parse_electric_range(raw_value=fuel_range) 241 | 242 | range_elec = max(range_elec_vehicle, range_elec_bms) 243 | if range_elec > 0: 244 | data["est_battery_range"] = range_elec 245 | 246 | return data 247 | 248 | @staticmethod 249 | def __parse_electric_range(raw_value: int) -> float: 250 | if value_in_range(raw_value, 1, 20460): 251 | return float(raw_value) / 10.0 252 | return 0.0 253 | 254 | async def invoke_request_listener(self, request: httpx.Request) -> None: 255 | if not self.__listener: 256 | return 257 | try: 258 | body = None 259 | if request.content: 260 | try: 261 | body = request.content.decode("utf-8") 262 | except Exception as e: 263 | LOG.warning(f"Error decoding request content: {e}") 264 | 265 | await self.__listener.on_request( 266 | path=str(request.url).replace(self.__base_uri, "/"), 267 | body=body, 268 | headers=dict(request.headers), 269 | ) 270 | except Exception as e: 271 | LOG.warning(f"Error invoking request listener: {e}", exc_info=e) 272 | 273 | async def invoke_response_listener(self, response: httpx.Response) -> None: 274 | if not self.__listener: 275 | return 276 | try: 277 | body = await response.aread() 278 | decoded_body: str | None = None 279 | if body: 280 | try: 281 | decoded_body = body.decode("utf-8") 282 | except Exception as e: 283 | LOG.warning(f"Error decoding request content: {e}") 284 | 285 | await self.__listener.on_response( 286 | path=str(response.url).replace(self.__base_uri, "/"), 287 | body=decoded_body, 288 | headers=dict(response.headers), 289 | ) 290 | except Exception as e: 291 | LOG.warning(f"Error invoking request listener: {e}", exc_info=e) 292 | -------------------------------------------------------------------------------- /src/integrations/home_assistant/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAIC-iSmart-API/saic-python-mqtt-gateway/84f96c7c0040ac77be40d4beb2a8cedc9356829d/src/integrations/home_assistant/__init__.py -------------------------------------------------------------------------------- /src/integrations/openwb/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAIC-iSmart-API/saic-python-mqtt-gateway/84f96c7c0040ac77be40d4beb2a8cedc9356829d/src/integrations/openwb/__init__.py -------------------------------------------------------------------------------- /src/integrations/openwb/charging_station.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class ChargingStation: 5 | def __init__( 6 | self, 7 | vin: str, 8 | charge_state_topic: str, 9 | charging_value: str, 10 | soc_topic: str | None = None, 11 | ) -> None: 12 | self.vin: str = vin 13 | self.charge_state_topic: str = charge_state_topic 14 | self.charging_value: str = charging_value 15 | self.soc_topic: str | None = soc_topic 16 | self.range_topic: str | None = None 17 | self.connected_topic: str | None = None 18 | self.connected_value: str | None = None 19 | -------------------------------------------------------------------------------- /src/integrations/osmand/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAIC-iSmart-API/saic-python-mqtt-gateway/84f96c7c0040ac77be40d4beb2a8cedc9356829d/src/integrations/osmand/__init__.py -------------------------------------------------------------------------------- /src/integrations/osmand/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | import logging 5 | from typing import TYPE_CHECKING, Any 6 | 7 | import httpx 8 | from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus 9 | 10 | from integrations import IntegrationException 11 | from utils import get_update_timestamp, value_in_range 12 | 13 | if TYPE_CHECKING: 14 | from saic_ismart_client_ng.api.vehicle import VehicleStatusResp 15 | from saic_ismart_client_ng.api.vehicle.schema import BasicVehicleStatus 16 | from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp 17 | from saic_ismart_client_ng.api.vehicle_charging.schema import RvsChargeStatus 18 | 19 | LOG = logging.getLogger(__name__) 20 | 21 | 22 | class OsmAndApiException(IntegrationException): 23 | def __init__(self, msg: str) -> None: 24 | super().__init__(__name__, msg) 25 | 26 | 27 | class OsmAndApiListener(ABC): 28 | @abstractmethod 29 | async def on_request( 30 | self, 31 | path: str, 32 | body: str | None = None, 33 | headers: dict[str, str] | None = None, 34 | ) -> None: 35 | pass 36 | 37 | @abstractmethod 38 | async def on_response( 39 | self, 40 | path: str, 41 | body: str | None = None, 42 | headers: dict[str, str] | None = None, 43 | ) -> None: 44 | pass 45 | 46 | 47 | class OsmAndApi: 48 | def __init__( 49 | self, 50 | *, 51 | server_uri: str, 52 | device_id: str, 53 | listener: OsmAndApiListener | None = None, 54 | ) -> None: 55 | self.__device_id = device_id 56 | self.__listener = listener 57 | self.__server_uri = server_uri 58 | self.client = httpx.AsyncClient( 59 | event_hooks={ 60 | "request": [self.invoke_request_listener], 61 | "response": [self.invoke_response_listener], 62 | } 63 | ) 64 | 65 | async def update_osmand( 66 | self, 67 | vehicle_status: VehicleStatusResp | None, 68 | charge_info: ChrgMgmtDataResp | None, 69 | ) -> tuple[bool, str]: 70 | charge_mgmt_data = None if charge_info is None else charge_info.chrgMgmtData 71 | charge_status = None if charge_info is None else charge_info.rvsChargeStatus 72 | 73 | if vehicle_status is not None: 74 | # Request 75 | data: dict[str, Any] = { 76 | "id": self.__device_id, 77 | # Guess the timestamp from either the API, GPS info or current machine time 78 | "timestamp": int(get_update_timestamp(vehicle_status).timestamp()), 79 | } 80 | 81 | basic_vehicle_status = vehicle_status.basicVehicleStatus 82 | if basic_vehicle_status is not None: 83 | data.update(self.__extract_basic_vehicle_status(basic_vehicle_status)) 84 | 85 | gps_position = vehicle_status.gpsPosition 86 | if gps_position is not None: 87 | data.update(self.__extract_gps_position(gps_position)) 88 | 89 | if charge_mgmt_data is not None: 90 | if (soc := charge_mgmt_data.bmsPackSOCDsp) is not None: 91 | data.update({"soc": (soc / 10.0)}) 92 | 93 | # Skip invalid current values reported by the API 94 | is_valid_current = ( 95 | charge_mgmt_data.bmsPackCrntV != 1 96 | and (raw_current := charge_mgmt_data.bmsPackCrnt) is not None 97 | and value_in_range(raw_current, 0, 65535) 98 | ) 99 | if is_valid_current: 100 | is_charging = ( 101 | charge_status is not None 102 | and charge_status.chargingGunState 103 | and is_valid_current 104 | and charge_mgmt_data.decoded_current is not None 105 | and charge_mgmt_data.decoded_current < 0.0 106 | ) 107 | data.update( 108 | { 109 | "power": charge_mgmt_data.decoded_power, 110 | "voltage": charge_mgmt_data.decoded_voltage, 111 | "current": charge_mgmt_data.decoded_current, 112 | "is_charging": is_charging, 113 | } 114 | ) 115 | 116 | # Extract electric range if available 117 | data.update( 118 | self.__extract_electric_range(basic_vehicle_status, charge_status) 119 | ) 120 | 121 | try: 122 | response = await self.client.post(url=self.__server_uri, params=data) 123 | await response.aread() 124 | return True, response.text 125 | except httpx.ConnectError as ece: 126 | msg = f"Connection error: {ece}" 127 | raise OsmAndApiException(msg) from ece 128 | except httpx.TimeoutException as et: 129 | msg = f"Timeout error {et}" 130 | raise OsmAndApiException(msg) from et 131 | except httpx.RequestError as e: 132 | msg = f"{e}" 133 | raise OsmAndApiException(msg) from e 134 | except httpx.HTTPError as ehttp: 135 | msg = f"HTTP error {ehttp}" 136 | raise OsmAndApiException(msg) from ehttp 137 | else: 138 | return False, "OsmAnd request skipped because of missing configuration" 139 | 140 | @staticmethod 141 | def __extract_basic_vehicle_status( 142 | basic_vehicle_status: BasicVehicleStatus, 143 | ) -> dict[str, Any]: 144 | data: dict[str, Any] = { 145 | "is_parked": basic_vehicle_status.is_parked, 146 | } 147 | 148 | exterior_temperature = basic_vehicle_status.exteriorTemperature 149 | if exterior_temperature is not None and value_in_range( 150 | exterior_temperature, -127, 127 151 | ): 152 | data["ext_temp"] = exterior_temperature 153 | mileage = basic_vehicle_status.mileage 154 | # Skip invalid range readings 155 | if mileage is not None and value_in_range(mileage, 1, 2147483647): 156 | # Data must be reported in meters 157 | data["odometer"] = 100.0 * mileage 158 | 159 | if basic_vehicle_status.is_parked: 160 | # We assume the vehicle is stationary, we will update it later from GPS if available 161 | data["speed"] = (0.0,) 162 | 163 | return data 164 | 165 | @staticmethod 166 | def __extract_gps_position(gps_position: GpsPosition) -> dict[str, Any]: 167 | data: dict[str, Any] = {} 168 | 169 | # Do not use GPS data if it is not available 170 | if gps_position.gps_status_decoded not in [GpsStatus.FIX_2D, GpsStatus.FIX_3d]: 171 | return data 172 | 173 | way_point = gps_position.wayPoint 174 | if way_point is None: 175 | return data 176 | 177 | speed = way_point.speed 178 | if speed is not None and value_in_range(speed, -999, 4500): 179 | data["speed"] = speed / 10 180 | 181 | heading = way_point.heading 182 | if heading is not None and value_in_range(heading, 0, 360): 183 | data["heading"] = heading 184 | 185 | position = way_point.position 186 | if position is None: 187 | return data 188 | 189 | altitude = position.altitude 190 | if altitude is not None and value_in_range(altitude, -500, 8900): 191 | data["altitude"] = altitude 192 | 193 | if (raw_lat := position.latitude) is not None and ( 194 | raw_lon := position.longitude 195 | ) is not None: 196 | lat_degrees = raw_lat / 1000000.0 197 | lon_degrees = raw_lon / 1000000.0 198 | 199 | if abs(lat_degrees) <= 90 and abs(lon_degrees) <= 180: 200 | data.update( 201 | { 202 | "hdop": way_point.hdop, 203 | "lat": lat_degrees, 204 | "lon": lon_degrees, 205 | } 206 | ) 207 | 208 | return data 209 | 210 | def __extract_electric_range( 211 | self, 212 | basic_vehicle_status: BasicVehicleStatus | None, 213 | charge_status: RvsChargeStatus | None, 214 | ) -> dict[str, Any]: 215 | data = {} 216 | 217 | range_elec_vehicle = 0.0 218 | if ( 219 | basic_vehicle_status 220 | and (fuel_range := basic_vehicle_status.fuelRangeElec) is not None 221 | ): 222 | range_elec_vehicle = self.__parse_electric_range(raw_value=fuel_range) 223 | 224 | range_elec_bms = 0.0 225 | if charge_status and (fuel_range := charge_status.fuelRangeElec) is not None: 226 | range_elec_bms = self.__parse_electric_range(raw_value=fuel_range) 227 | 228 | range_elec = max(range_elec_vehicle, range_elec_bms) 229 | if range_elec > 0: 230 | data["est_battery_range"] = range_elec 231 | 232 | return data 233 | 234 | @staticmethod 235 | def __parse_electric_range(raw_value: int) -> float: 236 | if value_in_range(raw_value, 1, 20460): 237 | return float(raw_value) / 10.0 238 | return 0.0 239 | 240 | async def invoke_request_listener(self, request: httpx.Request) -> None: 241 | if not self.__listener: 242 | return 243 | try: 244 | body = None 245 | if request.content: 246 | try: 247 | body = request.content.decode("utf-8") 248 | except Exception as e: 249 | LOG.warning(f"Error decoding request content: {e}") 250 | 251 | await self.__listener.on_request( 252 | path=str(request.url).replace(self.__server_uri, "/"), 253 | body=body, 254 | headers=dict(request.headers), 255 | ) 256 | except Exception as e: 257 | LOG.warning(f"Error invoking request listener: {e}", exc_info=e) 258 | 259 | async def invoke_response_listener(self, response: httpx.Response) -> None: 260 | if not self.__listener: 261 | return 262 | try: 263 | body = await response.aread() 264 | decoded_body: str | None = None 265 | if body: 266 | try: 267 | decoded_body = body.decode("utf-8") 268 | except Exception as e: 269 | LOG.warning(f"Error decoding request content: {e}") 270 | 271 | await self.__listener.on_response( 272 | path=str(response.url).replace(self.__server_uri, "/"), 273 | body=decoded_body, 274 | headers=dict(response.headers), 275 | ) 276 | except Exception as e: 277 | LOG.warning(f"Error invoking request listener: {e}", exc_info=e) 278 | -------------------------------------------------------------------------------- /src/log_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import logging.config 5 | import os 6 | from typing import Any 7 | 8 | MODULES_DEFAULT_LOG_LEVEL = { 9 | "asyncio": "WARNING", 10 | "gmqtt": "WARNING", 11 | "httpcore": "WARNING", 12 | "httpx": "WARNING", 13 | "saic_ismart_client_ng": "WARNING", 14 | "tzlocal": "WARNING", 15 | } 16 | 17 | MODULES_REPLACE_ENV_PREFIX = {"gmqtt": "MQTT"} 18 | 19 | 20 | def get_default_log_level() -> str: 21 | return os.getenv("LOG_LEVEL", "INFO").upper() 22 | 23 | 24 | def debug_log_enabled() -> bool: 25 | return get_default_log_level() == "DEBUG" 26 | 27 | 28 | # Function to fetch module-specific log levels from environment 29 | def get_module_log_level(module_name: str) -> str | None: 30 | default_log_level = MODULES_DEFAULT_LOG_LEVEL.get(module_name) 31 | env_prefix = MODULES_REPLACE_ENV_PREFIX.get( 32 | module_name, module_name.upper().replace(".", "_") 33 | ) 34 | return os.getenv(f"{env_prefix}_LOG_LEVEL", default_log_level) 35 | 36 | 37 | def setup_logging() -> None: 38 | logger = logging.getLogger(__name__) 39 | # Read the default log level from the environment 40 | default_log_level = get_default_log_level() 41 | 42 | logging_config = { 43 | "version": 1, 44 | "disable_existing_loggers": False, 45 | "formatters": { 46 | "standard": { 47 | "format": "%(asctime)s [%(levelname)s]: %(message)s - %(name)s", 48 | }, 49 | }, 50 | "handlers": { 51 | "console": { 52 | "level": "DEBUG", 53 | "class": "logging.StreamHandler", 54 | "formatter": "standard", 55 | }, 56 | }, 57 | # Catch-all logger with a default level 58 | "root": { 59 | "handlers": ["console"], 60 | "level": default_log_level, 61 | }, 62 | } 63 | 64 | # Dynamically add loggers based on modules loaded 65 | loaded_modules = list(logging.Logger.manager.loggerDict.keys()) 66 | logger.debug("Loaded modules: %s", loaded_modules) 67 | modules_override: dict[str, Any] = {} 68 | for module_name in loaded_modules: 69 | module_log_level = get_module_log_level(module_name) 70 | 71 | if module_log_level is not None: 72 | logger.debug(f"Loaded module {module_name} log level: {module_log_level}") 73 | modules_override[module_name] = { 74 | "level": module_log_level.upper(), 75 | "propagate": True, 76 | } 77 | logging_config.update({"loggers": modules_override}) 78 | 79 | # Apply the logging configuration 80 | logging.config.dictConfig(logging_config) 81 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import faulthandler 5 | import signal 6 | import sys 7 | 8 | from configuration.parser import process_arguments 9 | from mqtt_gateway import MqttGateway 10 | 11 | if __name__ == "__main__": 12 | # Keep this at the top! 13 | from log_config import debug_log_enabled, setup_logging 14 | 15 | setup_logging() 16 | 17 | # Enable fault handler to get a thread dump on SIGQUIT 18 | faulthandler.enable(file=sys.stderr, all_threads=True) 19 | if hasattr(faulthandler, "register") and hasattr(signal, "SIGQUIT"): 20 | faulthandler.register(signal.SIGQUIT, chain=False) 21 | configuration = process_arguments() 22 | 23 | mqtt_gateway = MqttGateway(configuration) 24 | asyncio.run(mqtt_gateway.run(), debug=debug_log_enabled()) 25 | -------------------------------------------------------------------------------- /src/mqtt_gateway.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from asyncio import Task 5 | import logging 6 | from random import uniform 7 | from typing import TYPE_CHECKING, Any, override 8 | 9 | import apscheduler.schedulers.asyncio 10 | from saic_ismart_client_ng import SaicApi 11 | from saic_ismart_client_ng.api.vehicle.alarm import AlarmType 12 | from saic_ismart_client_ng.model import SaicApiConfiguration 13 | 14 | from exceptions import MqttGatewayException 15 | from handlers.message import MessageHandler 16 | from handlers.relogin import ReloginHandler 17 | from handlers.vehicle import VehicleHandler, VehicleHandlerLocator 18 | import mqtt_topics 19 | from publisher.core import MqttCommandListener, Publisher 20 | from publisher.log_publisher import ConsolePublisher 21 | from publisher.mqtt_publisher import MqttPublisher 22 | from saic_api_listener import MqttGatewaySaicApiListener 23 | from vehicle import VehicleState 24 | from vehicle_info import VehicleInfo 25 | 26 | if TYPE_CHECKING: 27 | from saic_ismart_client_ng.api.vehicle import VinInfo 28 | 29 | from configuration import Configuration 30 | from integrations.openwb.charging_station import ChargingStation 31 | 32 | MSG_CMD_SUCCESSFUL = "Success" 33 | 34 | LOG = logging.getLogger(__name__) 35 | 36 | 37 | class MqttGateway(MqttCommandListener, VehicleHandlerLocator): 38 | def __init__(self, config: Configuration) -> None: 39 | self.configuration = config 40 | self.__vehicle_handlers: dict[str, VehicleHandler] = {} 41 | self.publisher = self.__select_publisher() 42 | self.publisher.command_listener = self 43 | if config.publish_raw_api_data: 44 | listener = MqttGatewaySaicApiListener(self.publisher) 45 | else: 46 | listener = None 47 | 48 | if not self.configuration.saic_user or not self.configuration.saic_password: 49 | raise MqttGatewayException("Please configure saic username and password") 50 | 51 | self.saic_api = SaicApi( 52 | configuration=SaicApiConfiguration( 53 | username=self.configuration.saic_user, 54 | password=self.configuration.saic_password, 55 | username_is_email=config.username_is_email, 56 | phone_country_code=config.saic_phone_country_code, 57 | base_uri=self.configuration.saic_rest_uri, 58 | region=self.configuration.saic_region, 59 | tenant_id=self.configuration.saic_tenant_id, 60 | read_timeout=self.configuration.saic_read_timeout, 61 | ), 62 | listener=listener, 63 | ) 64 | self.__scheduler = apscheduler.schedulers.asyncio.AsyncIOScheduler() 65 | self.__relogin_handler = ReloginHandler( 66 | relogin_relay=self.configuration.saic_relogin_delay, 67 | api=self.saic_api, 68 | scheduler=self.__scheduler, 69 | ) 70 | 71 | def __select_publisher(self) -> Publisher: 72 | if self.configuration.is_mqtt_enabled: 73 | return MqttPublisher(self.configuration) 74 | LOG.warning("MQTT support disabled") 75 | return ConsolePublisher(self.configuration) 76 | 77 | async def run(self) -> None: 78 | message_request_interval = self.configuration.messages_request_interval 79 | await self.__do_initial_login(message_request_interval) 80 | 81 | LOG.info("Fetching vehicle list") 82 | vin_list = await self.saic_api.vehicle_list() 83 | 84 | alarm_switches = list(AlarmType) 85 | 86 | for vin_info in vin_list.vinList: 87 | await self.setup_vehicle(alarm_switches, vin_info) 88 | message_handler = MessageHandler( 89 | gateway=self, relogin_handler=self.__relogin_handler, saicapi=self.saic_api 90 | ) 91 | self.__scheduler.add_job( 92 | func=message_handler.check_for_new_messages, 93 | trigger="interval", 94 | seconds=message_request_interval, 95 | id="message_handler", 96 | name="Check for new messages", 97 | max_instances=1, 98 | ) 99 | LOG.info("Connecting to MQTT Broker") 100 | await self.publisher.connect() 101 | 102 | LOG.info("Starting scheduler") 103 | self.__scheduler.start() 104 | 105 | LOG.info("Entering main loop") 106 | await self.__main_loop() 107 | 108 | async def __do_initial_login(self, message_request_interval: int) -> None: 109 | while True: 110 | try: 111 | await self.__relogin_handler.login() 112 | break 113 | except Exception as e: 114 | LOG.exception( 115 | "Could not complete initial login to the SAIC API, retrying in %d seconds", 116 | message_request_interval, 117 | exc_info=e, 118 | ) 119 | await asyncio.sleep(message_request_interval) 120 | 121 | async def setup_vehicle( 122 | self, alarm_switches: list[AlarmType], original_vin_info: VinInfo 123 | ) -> None: 124 | if not original_vin_info.vin: 125 | LOG.error("Skipping vehicle setup due to no vin: %s", original_vin_info) 126 | return 127 | 128 | total_battery_capacity = self.configuration.battery_capacity_map.get( 129 | original_vin_info.vin, None 130 | ) 131 | 132 | vin_info = VehicleInfo(original_vin_info, total_battery_capacity) 133 | 134 | try: 135 | LOG.info( 136 | f"Registering for {[x.name for x in alarm_switches]} messages. vin={vin_info.vin}" 137 | ) 138 | await self.saic_api.set_alarm_switches( 139 | alarm_switches=alarm_switches, vin=vin_info.vin 140 | ) 141 | LOG.info( 142 | f"Registered for {[x.name for x in alarm_switches]} messages. vin={vin_info.vin}" 143 | ) 144 | except Exception as e: 145 | LOG.exception( 146 | f"Failed to register for {[x.name for x in alarm_switches]} messages. vin={vin_info.vin}", 147 | exc_info=e, 148 | ) 149 | raise SystemExit("Failed to register for API messages") from e 150 | account_prefix = ( 151 | f"{self.configuration.saic_user}/{mqtt_topics.VEHICLES}/{vin_info.vin}" 152 | ) 153 | charging_station = self.get_charging_station(vin_info.vin) 154 | if charging_station and charging_station.soc_topic: 155 | LOG.debug( 156 | "SoC of %s for charging station will be published over MQTT topic: %s", 157 | vin_info.vin, 158 | charging_station.soc_topic, 159 | ) 160 | if charging_station and charging_station.range_topic: 161 | LOG.debug( 162 | "Range of %s for charging station will be published over MQTT topic: %s", 163 | vin_info.vin, 164 | charging_station.range_topic, 165 | ) 166 | vehicle_state = VehicleState( 167 | self.publisher, 168 | self.__scheduler, 169 | account_prefix, 170 | vin_info, 171 | charging_station, 172 | charge_polling_min_percent=self.configuration.charge_dynamic_polling_min_percentage, 173 | ) 174 | vehicle_handler = VehicleHandler( 175 | self.configuration, 176 | self.__relogin_handler, 177 | self.saic_api, 178 | self.publisher, # Gateway pointer 179 | vin_info, 180 | vehicle_state, 181 | ) 182 | self.vehicle_handlers[vin_info.vin] = vehicle_handler 183 | 184 | @override 185 | def get_vehicle_handler(self, vin: str) -> VehicleHandler | None: 186 | if vin in self.vehicle_handlers: 187 | return self.vehicle_handlers[vin] 188 | LOG.error(f"No vehicle handler found for VIN {vin}") 189 | return None 190 | 191 | @property 192 | @override 193 | def vehicle_handlers(self) -> dict[str, VehicleHandler]: 194 | return self.__vehicle_handlers 195 | 196 | @override 197 | async def on_mqtt_command_received( 198 | self, *, vin: str, topic: str, payload: str 199 | ) -> None: 200 | vehicle_handler = self.get_vehicle_handler(vin) 201 | if vehicle_handler: 202 | await vehicle_handler.handle_mqtt_command(topic=topic, payload=payload) 203 | else: 204 | LOG.debug(f"Command for unknown vin {vin} received") 205 | 206 | @override 207 | async def on_charging_detected(self, vin: str) -> None: 208 | vehicle_handler = self.get_vehicle_handler(vin) 209 | if vehicle_handler: 210 | # just make sure that we don't set the is_charging flag too early 211 | # and that it is immediately overwritten by a running vehicle state request 212 | await asyncio.sleep(delay=3.0) 213 | vehicle_handler.vehicle_state.set_is_charging(True) 214 | else: 215 | LOG.debug(f"Charging detected for unknown vin {vin}") 216 | 217 | @override 218 | async def on_mqtt_global_command_received( 219 | self, *, topic: str, payload: str 220 | ) -> None: 221 | match topic: 222 | case self.configuration.ha_lwt_topic: 223 | if payload == "online": 224 | for vin, vh in self.vehicle_handlers.items(): 225 | # wait randomly between 0.1 and 10 seconds before sending discovery 226 | await asyncio.sleep(uniform(0.1, 10.0)) # noqa: S311 227 | LOG.debug(f"Send HomeAssistant discovery for car {vin}") 228 | vh.publish_ha_discovery_messages(force=True) 229 | case _: 230 | LOG.warning(f"Received unknown global command {topic}: {payload}") 231 | 232 | def get_charging_station(self, vin: str) -> ChargingStation | None: 233 | if vin in self.configuration.charging_stations_by_vin: 234 | return self.configuration.charging_stations_by_vin[vin] 235 | return None 236 | 237 | async def __main_loop(self) -> None: 238 | tasks = [] 239 | for key, vh in self.vehicle_handlers.items(): 240 | LOG.info(f"Starting process for car {key}") 241 | task = asyncio.create_task( 242 | vh.handle_vehicle(), name=f"handle_vehicle_{key}" 243 | ) 244 | tasks.append(task) 245 | 246 | await self.__shutdown_handler(tasks) 247 | 248 | @staticmethod 249 | async def __shutdown_handler(tasks: list[Task[Any]]) -> None: 250 | while True: 251 | done, pending = await asyncio.wait( 252 | tasks, return_when=asyncio.FIRST_COMPLETED 253 | ) 254 | for task in done: 255 | task_name = task.get_name() 256 | if task.cancelled(): 257 | LOG.debug( 258 | f"{task_name!r} task was cancelled, this is only supposed if the application is " 259 | "shutting down" 260 | ) 261 | else: 262 | exception = task.exception() 263 | if exception is not None: 264 | LOG.exception( 265 | f"{task_name!r} task crashed with an exception", 266 | exc_info=exception, 267 | ) 268 | raise SystemExit(-1) 269 | LOG.warning( 270 | f"{task_name!r} task terminated cleanly with result={task.result()}" 271 | ) 272 | if len(pending) == 0: 273 | break 274 | LOG.warning( 275 | f"There are still {len(pending)} tasks... waiting for them to complete" 276 | ) 277 | -------------------------------------------------------------------------------- /src/mqtt_topics.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | SET_SUFFIX = "set" 4 | RESULT_SUFFIX = "result" 5 | 6 | AVAILABLE = "available" 7 | 8 | CLIMATE = "climate" 9 | CLIMATE_BACK_WINDOW_HEAT = CLIMATE + "/rearWindowDefrosterHeating" 10 | CLIMATE_BACK_WINDOW_HEAT_SET = CLIMATE_BACK_WINDOW_HEAT + "/" + SET_SUFFIX 11 | CLIMATE_FRONT_WINDOW_HEAT = CLIMATE + "/frontWindowDefrosterHeating" 12 | CLIMATE_FRONT_WINDOW_HEAT_SET = CLIMATE_FRONT_WINDOW_HEAT + "/" + SET_SUFFIX 13 | CLIMATE_EXTERIOR_TEMPERATURE = CLIMATE + "/exteriorTemperature" 14 | CLIMATE_INTERIOR_TEMPERATURE = CLIMATE + "/interiorTemperature" 15 | CLIMATE_REMOTE_CLIMATE_STATE = CLIMATE + "/remoteClimateState" 16 | CLIMATE_REMOTE_CLIMATE_STATE_SET = CLIMATE_REMOTE_CLIMATE_STATE + "/" + SET_SUFFIX 17 | CLIMATE_REMOTE_TEMPERATURE = CLIMATE + "/remoteTemperature" 18 | CLIMATE_REMOTE_TEMPERATURE_SET = CLIMATE_REMOTE_TEMPERATURE + "/" + SET_SUFFIX 19 | CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL = CLIMATE + "/heatedSeatsFrontLeftLevel" 20 | CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL_SET = ( 21 | CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL + "/" + SET_SUFFIX 22 | ) 23 | CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL = CLIMATE + "/heatedSeatsFrontRightLevel" 24 | CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL_SET = ( 25 | CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL + "/" + SET_SUFFIX 26 | ) 27 | 28 | WINDOWS = "windows" 29 | WINDOWS_DRIVER = WINDOWS + "/driver" 30 | WINDOWS_PASSENGER = WINDOWS + "/passenger" 31 | WINDOWS_REAR_LEFT = WINDOWS + "/rearLeft" 32 | WINDOWS_REAR_RIGHT = WINDOWS + "/rearRight" 33 | WINDOWS_SUN_ROOF = WINDOWS + "/sunRoof" 34 | 35 | DOORS = "doors" 36 | DOORS_BONNET = DOORS + "/bonnet" 37 | DOORS_BOOT = DOORS + "/boot" 38 | DOORS_BOOT_SET = DOORS_BOOT + "/" + SET_SUFFIX 39 | DOORS_DRIVER = DOORS + "/driver" 40 | DOORS_LOCKED = DOORS + "/locked" 41 | DOORS_LOCKED_SET = DOORS_LOCKED + "/" + SET_SUFFIX 42 | DOORS_PASSENGER = DOORS + "/passenger" 43 | DOORS_REAR_LEFT = DOORS + "/rearLeft" 44 | DOORS_REAR_RIGHT = DOORS + "/rearRight" 45 | 46 | LIGHTS = "lights" 47 | LIGHTS_MAIN_BEAM = LIGHTS + "/mainBeam" 48 | LIGHTS_DIPPED_BEAM = LIGHTS + "/dippedBeam" 49 | LIGHTS_SIDE = LIGHTS + "/side" 50 | 51 | DRIVETRAIN = "drivetrain" 52 | DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE = DRIVETRAIN + "/auxiliaryBatteryVoltage" 53 | DRIVETRAIN_CHARGER_CONNECTED = DRIVETRAIN + "/chargerConnected" 54 | DRIVETRAIN_CHARGING = DRIVETRAIN + "/charging" 55 | DRIVETRAIN_CHARGING_SET = DRIVETRAIN_CHARGING + "/" + SET_SUFFIX 56 | DRIVETRAIN_CHARGING_STOP_REASON = DRIVETRAIN + "/chargingStopReason" 57 | DRIVETRAIN_CHARGING_LAST_START = DRIVETRAIN_CHARGING + "/lastStart" 58 | DRIVETRAIN_CHARGING_LAST_END = DRIVETRAIN_CHARGING + "/lastEnd" 59 | DRIVETRAIN_BATTERY_HEATING = DRIVETRAIN + "/batteryHeating" 60 | DRIVETRAIN_BATTERY_HEATING_SET = DRIVETRAIN_BATTERY_HEATING + "/" + SET_SUFFIX 61 | DRIVETRAIN_BATTERY_HEATING_STOP_REASON = DRIVETRAIN + "/batteryHeatingStopReason" 62 | DRIVETRAIN_CHARGING_SCHEDULE = DRIVETRAIN + "/chargingSchedule" 63 | DRIVETRAIN_CHARGING_SCHEDULE_SET = DRIVETRAIN_CHARGING_SCHEDULE + "/" + SET_SUFFIX 64 | DRIVETRAIN_BATTERY_HEATING_SCHEDULE = DRIVETRAIN + "/batteryHeatingSchedule" 65 | DRIVETRAIN_BATTERY_HEATING_SCHEDULE_SET = ( 66 | DRIVETRAIN_BATTERY_HEATING_SCHEDULE + "/" + SET_SUFFIX 67 | ) 68 | DRIVETRAIN_CHARGING_TYPE = DRIVETRAIN + "/chargingType" 69 | DRIVETRAIN_CURRENT = DRIVETRAIN + "/current" 70 | DRIVETRAIN_HV_BATTERY_ACTIVE = DRIVETRAIN + "/hvBatteryActive" 71 | DRIVETRAIN_HV_BATTERY_ACTIVE_SET = DRIVETRAIN_HV_BATTERY_ACTIVE + "/" + SET_SUFFIX 72 | DRIVETRAIN_MILEAGE = DRIVETRAIN + "/mileage" 73 | DRIVETRAIN_MILEAGE_OF_DAY = DRIVETRAIN + "/mileageOfTheDay" 74 | DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE = DRIVETRAIN + "/mileageSinceLastCharge" 75 | DRIVETRAIN_POWER = DRIVETRAIN + "/power" 76 | DRIVETRAIN_POWER_USAGE_OF_DAY = DRIVETRAIN + "/powerUsageOfDay" 77 | DRIVETRAIN_POWER_USAGE_SINCE_LAST_CHARGE = DRIVETRAIN + "/powerUsageSinceLastCharge" 78 | DRIVETRAIN_RANGE = DRIVETRAIN + "/range" 79 | DRIVETRAIN_RUNNING = DRIVETRAIN + "/running" 80 | DRIVETRAIN_REMAINING_CHARGING_TIME = DRIVETRAIN + "/remainingChargingTime" 81 | DRIVETRAIN_HYBRID_ELECTRICAL_RANGE = DRIVETRAIN + "/hybrid_electrical_range" 82 | DRIVETRAIN_SOC = DRIVETRAIN + "/soc" 83 | DRIVETRAIN_SOC_TARGET = DRIVETRAIN + "/socTarget" 84 | DRIVETRAIN_SOC_TARGET_SET = DRIVETRAIN_SOC_TARGET + "/" + SET_SUFFIX 85 | DRIVETRAIN_CHARGECURRENT_LIMIT = DRIVETRAIN + "/chargeCurrentLimit" 86 | DRIVETRAIN_CHARGECURRENT_LIMIT_SET = DRIVETRAIN_CHARGECURRENT_LIMIT + "/" + SET_SUFFIX 87 | DRIVETRAIN_SOC_KWH = DRIVETRAIN + "/soc_kwh" 88 | DRIVETRAIN_LAST_CHARGE_ENDING_POWER = DRIVETRAIN + "/lastChargeEndingPower" 89 | DRIVETRAIN_TOTAL_BATTERY_CAPACITY = DRIVETRAIN + "/totalBatteryCapacity" 90 | DRIVETRAIN_VOLTAGE = DRIVETRAIN + "/voltage" 91 | DRIVETRAIN_CHARGING_CABLE_LOCK = DRIVETRAIN + "/chargingCableLock" 92 | DRIVETRAIN_CHARGING_CABLE_LOCK_SET = DRIVETRAIN_CHARGING_CABLE_LOCK + "/" + SET_SUFFIX 93 | DRIVETRAIN_CURRENT_JOURNEY = DRIVETRAIN + "/currentJourney" 94 | DRIVETRAIN_FOSSIL_FUEL = DRIVETRAIN + "/fossilFuel" 95 | DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE = DRIVETRAIN_FOSSIL_FUEL + "/percentage" 96 | DRIVETRAIN_FOSSIL_FUEL_RANGE = DRIVETRAIN_FOSSIL_FUEL + "/range" 97 | 98 | OBC = "obc" 99 | OBC_CURRENT = OBC + "/current" 100 | OBC_VOLTAGE = OBC + "/voltage" 101 | OBC_POWER_SINGLE_PHASE = OBC + "/powerSinglePhase" 102 | OBC_POWER_THREE_PHASE = OBC + "/powerThreePhase" 103 | 104 | CCU = "ccu" 105 | CCU_ONBOARD_PLUG_STATUS = CCU + "/onboardChargerPlugStatus" 106 | CCU_OFFBOARD_PLUG_STATUS = CCU + "/offboardChargerPlugStatus" 107 | 108 | BMS = "bms" 109 | BMS_CHARGE_STATUS = BMS + "/chargeStatus" 110 | 111 | INFO = "info" 112 | INFO_BRAND = INFO + "/brand" 113 | INFO_MODEL = INFO + "/model" 114 | INFO_YEAR = INFO + "/year" 115 | INFO_SERIES = INFO + "/series" 116 | INFO_COLOR = INFO + "/color" 117 | INFO_CONFIGURATION = INFO + "/configuration" 118 | INFO_LAST_MESSAGE = INFO + "/lastMessage" 119 | INFO_LAST_MESSAGE_ID = INFO_LAST_MESSAGE + "/messageId" 120 | INFO_LAST_MESSAGE_TYPE = INFO_LAST_MESSAGE + "/messageType" 121 | INFO_LAST_MESSAGE_TITLE = INFO_LAST_MESSAGE + "/title" 122 | INFO_LAST_MESSAGE_TIME = INFO_LAST_MESSAGE + "/messageTime" 123 | INFO_LAST_MESSAGE_SENDER = INFO_LAST_MESSAGE + "/sender" 124 | INFO_LAST_MESSAGE_CONTENT = INFO_LAST_MESSAGE + "/content" 125 | INFO_LAST_MESSAGE_STATUS = INFO_LAST_MESSAGE + "/status" 126 | INFO_LAST_MESSAGE_VIN = INFO_LAST_MESSAGE + "/vin" 127 | 128 | INTERNAL = "_internal" 129 | INTERNAL_API = INTERNAL + "/api" 130 | INTERNAL_LWT = INTERNAL + "/lwt" 131 | INTERNAL_ABRP = INTERNAL + "/abrp" 132 | INTERNAL_OSMAND = INTERNAL + "/osmand" 133 | INTERNAL_CONFIGURATION_RAW = INTERNAL + "/configuration/raw" 134 | 135 | LOCATION = "location" 136 | LOCATION_POSITION = LOCATION + "/position" 137 | LOCATION_HEADING = LOCATION + "/heading" 138 | LOCATION_LATITUDE = LOCATION + "/latitude" 139 | LOCATION_LONGITUDE = LOCATION + "/longitude" 140 | LOCATION_SPEED = LOCATION + "/speed" 141 | LOCATION_ELEVATION = LOCATION + "/elevation" 142 | LOCATION_FIND_MY_CAR = LOCATION + "/findMyCar" 143 | LOCATION_FIND_MY_CAR_SET = LOCATION_FIND_MY_CAR + "/" + SET_SUFFIX 144 | 145 | REFRESH = "refresh" 146 | REFRESH_LAST_ACTIVITY = REFRESH + "/lastActivity" 147 | REFRESH_LAST_CHARGE_STATE = REFRESH + "/lastChargeState" 148 | REFRESH_LAST_VEHICLE_STATE = REFRESH + "/lastVehicleState" 149 | REFRESH_LAST_ERROR = REFRESH + "/lastError" 150 | REFRESH_MODE = REFRESH + "/mode" 151 | REFRESH_MODE_SET = REFRESH_MODE + "/" + SET_SUFFIX 152 | REFRESH_PERIOD = REFRESH + "/period" 153 | REFRESH_PERIOD_ACTIVE = REFRESH_PERIOD + "/active" 154 | REFRESH_PERIOD_ACTIVE_SET = REFRESH_PERIOD_ACTIVE + "/" + SET_SUFFIX 155 | REFRESH_PERIOD_CHARGING = REFRESH_PERIOD + "/charging" 156 | REFRESH_PERIOD_INACTIVE = REFRESH_PERIOD + "/inActive" 157 | REFRESH_PERIOD_INACTIVE_SET = REFRESH_PERIOD_INACTIVE + "/" + SET_SUFFIX 158 | REFRESH_PERIOD_AFTER_SHUTDOWN = REFRESH_PERIOD + "/afterShutdown" 159 | REFRESH_PERIOD_AFTER_SHUTDOWN_SET = REFRESH_PERIOD_AFTER_SHUTDOWN + "/" + SET_SUFFIX 160 | REFRESH_PERIOD_INACTIVE_GRACE = REFRESH_PERIOD + "/inActiveGrace" 161 | REFRESH_PERIOD_INACTIVE_GRACE_SET = REFRESH_PERIOD_INACTIVE_GRACE + "/" + SET_SUFFIX 162 | REFRESH_PERIOD_ERROR = REFRESH_PERIOD + "/error" 163 | 164 | TYRES = "tyres" 165 | TYRES_FRONT_LEFT_PRESSURE = TYRES + "/frontLeftPressure" 166 | TYRES_FRONT_RIGHT_PRESSURE = TYRES + "/frontRightPressure" 167 | TYRES_REAR_LEFT_PRESSURE = TYRES + "/rearLeftPressure" 168 | TYRES_REAR_RIGHT_PRESSURE = TYRES + "/rearRightPressure" 169 | 170 | VEHICLES = "vehicles" 171 | -------------------------------------------------------------------------------- /src/publisher/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAIC-iSmart-API/saic-python-mqtt-gateway/84f96c7c0040ac77be40d4beb2a8cedc9356829d/src/publisher/__init__.py -------------------------------------------------------------------------------- /src/publisher/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | import json 5 | import re 6 | from typing import TYPE_CHECKING, Any, TypeVar 7 | 8 | import mqtt_topics 9 | 10 | if TYPE_CHECKING: 11 | from configuration import Configuration 12 | 13 | T = TypeVar("T") 14 | 15 | 16 | class MqttCommandListener(ABC): 17 | @abstractmethod 18 | async def on_mqtt_command_received( 19 | self, *, vin: str, topic: str, payload: str 20 | ) -> None: 21 | raise NotImplementedError("Should have implemented this") 22 | 23 | @abstractmethod 24 | async def on_charging_detected(self, vin: str) -> None: 25 | raise NotImplementedError("Should have implemented this") 26 | 27 | @abstractmethod 28 | async def on_mqtt_global_command_received( 29 | self, *, topic: str, payload: str 30 | ) -> None: 31 | raise NotImplementedError("Should have implemented this") 32 | 33 | 34 | class Publisher(ABC): 35 | def __init__(self, config: Configuration) -> None: 36 | self.__configuration = config 37 | self.__command_listener: MqttCommandListener | None = None 38 | if config.mqtt_allow_dots_in_topic: 39 | self.__invalid_mqtt_chars = re.compile(r"[+#*$>]") 40 | else: 41 | self.__invalid_mqtt_chars = re.compile(r"[+#*$>.]") 42 | self.__topic_root = self.__remove_special_mqtt_characters(config.mqtt_topic) 43 | 44 | @abstractmethod 45 | async def connect(self) -> None: 46 | pass 47 | 48 | @abstractmethod 49 | def is_connected(self) -> bool: 50 | raise NotImplementedError 51 | 52 | @abstractmethod 53 | def publish_json( 54 | self, key: str, data: dict[str, Any], no_prefix: bool = False 55 | ) -> None: 56 | raise NotImplementedError 57 | 58 | @abstractmethod 59 | def publish_str(self, key: str, value: str, no_prefix: bool = False) -> None: 60 | raise NotImplementedError 61 | 62 | @abstractmethod 63 | def publish_int(self, key: str, value: int, no_prefix: bool = False) -> None: 64 | raise NotImplementedError 65 | 66 | @abstractmethod 67 | def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: 68 | raise NotImplementedError 69 | 70 | @abstractmethod 71 | def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: 72 | raise NotImplementedError 73 | 74 | def get_mqtt_account_prefix(self) -> str: 75 | return self.__remove_special_mqtt_characters( 76 | f"{self.__topic_root}/{self.configuration.saic_user}" 77 | ) 78 | 79 | def get_topic(self, key: str, no_prefix: bool) -> str: 80 | topic = key if no_prefix else f"{self.__topic_root}/{key}" 81 | return self.__remove_special_mqtt_characters(topic) 82 | 83 | def __remove_special_mqtt_characters(self, input_str: str) -> str: 84 | return self.__invalid_mqtt_chars.sub("_", input_str) 85 | 86 | def __remove_byte_strings(self, data: dict[str, Any]) -> dict[str, Any]: 87 | for key in data: # noqa: PLC0206 88 | if isinstance(data[key], bytes): 89 | data[key] = str(data[key]) 90 | elif isinstance(data[key], dict): 91 | data[key] = self.__remove_byte_strings(data[key]) 92 | elif isinstance(data[key], list): 93 | for item in data[key]: 94 | if isinstance(item, dict): 95 | self.__remove_byte_strings(item) 96 | return data 97 | 98 | def __anonymize(self, data: T) -> T: 99 | if isinstance(data, dict): 100 | for key in data: 101 | if isinstance(data[key], str): 102 | match key: 103 | case "password": 104 | data[key] = "******" 105 | case ( 106 | "uid" 107 | | "email" 108 | | "user_name" 109 | | "account" 110 | | "ping" 111 | | "token" 112 | | "access_token" 113 | | "refreshToken" 114 | | "refresh_token" 115 | | "vin" 116 | ): 117 | data[key] = Publisher.anonymize_str(data[key]) 118 | case "deviceId": 119 | data[key] = self.anonymize_device_id(data[key]) 120 | case ( 121 | "seconds" 122 | | "bindTime" 123 | | "eventCreationTime" 124 | | "latitude" 125 | | "longitude" 126 | ): 127 | data[key] = Publisher.anonymize_int(data[key]) 128 | case ( 129 | "eventID" 130 | | "event-id" 131 | | "event_id" 132 | | "eventId" 133 | | "event_id" 134 | | "eventID" 135 | | "lastKeySeen" 136 | ): 137 | data[key] = 9999 138 | case "content": 139 | data[key] = re.sub( 140 | "\\(\\*\\*\\*...\\)", "(***XXX)", data[key] 141 | ) 142 | elif isinstance(data[key], dict): 143 | data[key] = self.__anonymize(data[key]) 144 | elif isinstance(data[key], list | set | tuple): 145 | data[key] = [self.__anonymize(item) for item in data[key]] 146 | return data 147 | 148 | def keepalive(self) -> None: 149 | self.publish_str(mqtt_topics.INTERNAL_LWT, "online", False) 150 | 151 | @staticmethod 152 | def anonymize_str(value: str) -> str: 153 | r = re.sub("[a-zA-Z]", "X", value) 154 | return re.sub("[1-9]", "9", r) 155 | 156 | def anonymize_device_id(self, device_id: str) -> str: 157 | elements = device_id.split("###") 158 | return f"{self.anonymize_str(elements[0])}###{self.anonymize_str(elements[1])}" 159 | 160 | @staticmethod 161 | def anonymize_int(value: int) -> int: 162 | return int(value / 100000 * 100000) 163 | 164 | def dict_to_anonymized_json(self, data: dict[str, Any]) -> str: 165 | no_binary_strings = self.__remove_byte_strings(data) 166 | if self.configuration.anonymized_publishing: 167 | result = self.__anonymize(no_binary_strings) 168 | else: 169 | result = no_binary_strings 170 | return json.dumps(result, indent=2) 171 | 172 | @property 173 | def configuration(self) -> Configuration: 174 | return self.__configuration 175 | 176 | @property 177 | def command_listener(self) -> MqttCommandListener | None: 178 | return self.__command_listener 179 | 180 | @command_listener.setter 181 | def command_listener(self, listener: MqttCommandListener) -> None: 182 | self.__command_listener = listener 183 | -------------------------------------------------------------------------------- /src/publisher/log_publisher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Any, override 5 | 6 | from publisher.core import Publisher 7 | 8 | LOG = logging.getLogger(__name__) 9 | LOG.setLevel(level="DEBUG") 10 | 11 | 12 | class ConsolePublisher(Publisher): 13 | @override 14 | async def connect(self) -> None: 15 | pass 16 | 17 | @override 18 | def is_connected(self) -> bool: 19 | return True 20 | 21 | @override 22 | def publish_json( 23 | self, key: str, data: dict[str, Any], no_prefix: bool = False 24 | ) -> None: 25 | anonymized_json = self.dict_to_anonymized_json(data) 26 | self.internal_publish(key, anonymized_json) 27 | 28 | @override 29 | def publish_str(self, key: str, value: str, no_prefix: bool = False) -> None: 30 | self.internal_publish(key, value) 31 | 32 | @override 33 | def publish_int(self, key: str, value: int, no_prefix: bool = False) -> None: 34 | self.internal_publish(key, value) 35 | 36 | @override 37 | def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: 38 | self.internal_publish(key, value) 39 | 40 | @override 41 | def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: 42 | self.internal_publish(key, value) 43 | 44 | def internal_publish(self, key: str, value: Any) -> None: 45 | LOG.debug(f"{key}: {value}") 46 | -------------------------------------------------------------------------------- /src/publisher/mqtt_publisher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import ssl 5 | from typing import TYPE_CHECKING, Any, Final, cast, override 6 | 7 | import gmqtt 8 | 9 | import mqtt_topics 10 | from publisher.core import Publisher 11 | 12 | if TYPE_CHECKING: 13 | from configuration import Configuration 14 | from integrations.openwb.charging_station import ChargingStation 15 | 16 | LOG = logging.getLogger(__name__) 17 | 18 | 19 | class MqttPublisher(Publisher): 20 | def __init__(self, configuration: Configuration) -> None: 21 | super().__init__(configuration) 22 | self.publisher_id = configuration.mqtt_client_id 23 | self.host = self.configuration.mqtt_host 24 | self.port = self.configuration.mqtt_port 25 | self.transport_protocol = self.configuration.mqtt_transport_protocol 26 | self.vin_by_charge_state_topic: dict[str, str] = {} 27 | self.last_charge_state_by_vin: dict[str, str] = {} 28 | self.vin_by_charger_connected_topic: dict[str, str] = {} 29 | 30 | mqtt_client = gmqtt.Client( 31 | client_id=str(self.publisher_id), 32 | transport=self.transport_protocol.transport_mechanism, 33 | will_message=gmqtt.Message( 34 | topic=self.get_topic(mqtt_topics.INTERNAL_LWT, False), 35 | payload="offline", 36 | retain=True, 37 | ), 38 | ) 39 | mqtt_client.on_connect = self.__on_connect 40 | mqtt_client.on_message = self.__on_message 41 | self.client: Final[gmqtt.Client] = mqtt_client 42 | 43 | @override 44 | async def connect(self) -> None: 45 | if self.configuration.mqtt_user is not None: 46 | if self.configuration.mqtt_password is not None: 47 | self.client.set_auth_credentials( 48 | username=self.configuration.mqtt_user, 49 | password=self.configuration.mqtt_password, 50 | ) 51 | else: 52 | self.client.set_auth_credentials(username=self.configuration.mqtt_user) 53 | if self.transport_protocol.with_tls: 54 | cert_uri = self.configuration.tls_server_cert_path 55 | LOG.debug( 56 | f"Configuring network encryption and authentication options for MQTT using {cert_uri}" 57 | ) 58 | ssl_context = ssl.SSLContext() 59 | ssl_context.load_verify_locations(cafile=cert_uri) 60 | ssl_context.check_hostname = False 61 | else: 62 | ssl_context = None 63 | await self.client.connect( 64 | host=self.host, 65 | port=self.port, 66 | version=gmqtt.constants.MQTTv311, 67 | ssl=ssl_context, 68 | ) 69 | 70 | def __on_connect( 71 | self, _client: Any, _flags: Any, rc: int, _properties: Any 72 | ) -> None: 73 | if rc == gmqtt.constants.CONNACK_ACCEPTED: 74 | LOG.info("Connected to MQTT broker") 75 | mqtt_account_prefix = self.get_mqtt_account_prefix() 76 | self.client.subscribe( 77 | f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/{mqtt_topics.SET_SUFFIX}" 78 | ) 79 | self.client.subscribe( 80 | f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/+/+/+/{mqtt_topics.SET_SUFFIX}" 81 | ) 82 | self.client.subscribe( 83 | f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_MODE}/{mqtt_topics.SET_SUFFIX}" 84 | ) 85 | self.client.subscribe( 86 | f"{mqtt_account_prefix}/{mqtt_topics.VEHICLES}/+/{mqtt_topics.REFRESH_PERIOD}/+/{mqtt_topics.SET_SUFFIX}" 87 | ) 88 | for ( 89 | charging_station 90 | ) in self.configuration.charging_stations_by_vin.values(): 91 | LOG.debug( 92 | f"Subscribing to MQTT topic {charging_station.charge_state_topic}" 93 | ) 94 | self.vin_by_charge_state_topic[charging_station.charge_state_topic] = ( 95 | charging_station.vin 96 | ) 97 | self.client.subscribe(charging_station.charge_state_topic) 98 | if charging_station.connected_topic: 99 | LOG.debug( 100 | f"Subscribing to MQTT topic {charging_station.connected_topic}" 101 | ) 102 | self.vin_by_charger_connected_topic[ 103 | charging_station.connected_topic 104 | ] = charging_station.vin 105 | self.client.subscribe(charging_station.connected_topic) 106 | if self.configuration.ha_discovery_enabled: 107 | # enable dynamic discovery pushing in case ha reconnects 108 | self.client.subscribe(self.configuration.ha_lwt_topic) 109 | self.keepalive() 110 | else: 111 | if rc == gmqtt.constants.CONNACK_REFUSED_BAD_USERNAME_PASSWORD: 112 | LOG.error( 113 | f"MQTT connection error: bad username or password. Return code {rc}" 114 | ) 115 | elif rc == gmqtt.constants.CONNACK_REFUSED_PROTOCOL_VERSION: 116 | LOG.error( 117 | f"MQTT connection error: refused protocol version. Return code {rc}" 118 | ) 119 | else: 120 | LOG.error(f"MQTT connection error.Return code {rc}") 121 | msg = f"Unable to connect to MQTT broker. Return code: {rc}" 122 | raise SystemExit(msg) 123 | 124 | async def __on_message( 125 | self, _client: Any, topic: str, payload: Any, _qos: Any, _properties: Any 126 | ) -> None: 127 | try: 128 | if isinstance(payload, bytes): 129 | payload = payload.decode("utf-8") 130 | else: 131 | payload = str(payload) 132 | await self.__on_message_real(topic=topic, payload=payload) 133 | except Exception as e: 134 | LOG.exception(f"Error while processing MQTT message: {e}") 135 | 136 | async def __on_message_real(self, *, topic: str, payload: str) -> None: 137 | if topic in self.vin_by_charge_state_topic: 138 | LOG.debug(f"Received message over topic {topic} with payload {payload}") 139 | vin = self.vin_by_charge_state_topic[topic] 140 | charging_station = self.configuration.charging_stations_by_vin[vin] 141 | if self.should_force_refresh(payload, charging_station): 142 | LOG.info( 143 | f"Vehicle with vin {vin} is charging. Setting refresh mode to force" 144 | ) 145 | if self.command_listener is not None: 146 | await self.command_listener.on_charging_detected(vin) 147 | elif topic in self.vin_by_charger_connected_topic: 148 | LOG.debug(f"Received message over topic {topic} with payload {payload}") 149 | vin = self.vin_by_charger_connected_topic[topic] 150 | charging_station = self.configuration.charging_stations_by_vin[vin] 151 | if payload == charging_station.connected_value: 152 | LOG.debug( 153 | f"Vehicle with vin {vin} is connected to its charging station" 154 | ) 155 | else: 156 | LOG.debug( 157 | f"Vehicle with vin {vin} is disconnected from its charging station" 158 | ) 159 | elif topic == self.configuration.ha_lwt_topic: 160 | if self.command_listener is not None: 161 | await self.command_listener.on_mqtt_global_command_received( 162 | topic=topic, payload=payload 163 | ) 164 | else: 165 | vin = self.get_vin_from_topic(topic) 166 | if self.command_listener is not None: 167 | await self.command_listener.on_mqtt_command_received( 168 | vin=vin, topic=topic, payload=payload 169 | ) 170 | 171 | def __publish(self, topic: str, payload: Any) -> None: 172 | self.client.publish(topic, payload, retain=True) 173 | 174 | @override 175 | def is_connected(self) -> bool: 176 | return cast("bool", self.client.is_connected) 177 | 178 | @override 179 | def publish_json( 180 | self, key: str, data: dict[str, Any], no_prefix: bool = False 181 | ) -> None: 182 | payload = self.dict_to_anonymized_json(data) 183 | self.__publish(topic=self.get_topic(key, no_prefix), payload=payload) 184 | 185 | @override 186 | def publish_str(self, key: str, value: str, no_prefix: bool = False) -> None: 187 | self.__publish(topic=self.get_topic(key, no_prefix), payload=value) 188 | 189 | @override 190 | def publish_int(self, key: str, value: int, no_prefix: bool = False) -> None: 191 | self.__publish(topic=self.get_topic(key, no_prefix), payload=value) 192 | 193 | @override 194 | def publish_bool(self, key: str, value: bool, no_prefix: bool = False) -> None: 195 | self.__publish(topic=self.get_topic(key, no_prefix), payload=value) 196 | 197 | @override 198 | def publish_float(self, key: str, value: float, no_prefix: bool = False) -> None: 199 | self.__publish(topic=self.get_topic(key, no_prefix), payload=value) 200 | 201 | def get_vin_from_topic(self, topic: str) -> str: 202 | global_topic_removed = topic[len(self.configuration.mqtt_topic) + 1 :] 203 | elements = global_topic_removed.split("/") 204 | return elements[2] 205 | 206 | def should_force_refresh( 207 | self, current_charging_value: str, charging_station: ChargingStation 208 | ) -> bool: 209 | vin = charging_station.vin 210 | last_charging_value: str | None = None 211 | if vin in self.last_charge_state_by_vin: 212 | last_charging_value = self.last_charge_state_by_vin[vin] 213 | self.last_charge_state_by_vin[vin] = current_charging_value 214 | 215 | if last_charging_value: 216 | if last_charging_value == current_charging_value: 217 | LOG.debug( 218 | "Last charging value equals current charging value. No refresh needed." 219 | ) 220 | return False 221 | LOG.info( 222 | f"Charging value has changed from {last_charging_value} to {current_charging_value}." 223 | ) 224 | return True 225 | return True 226 | -------------------------------------------------------------------------------- /src/saic_api_listener.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | from typing import TYPE_CHECKING, Any, override 6 | from urllib.parse import parse_qs, urlparse 7 | 8 | from saic_ismart_client_ng.listener import SaicApiListener 9 | 10 | from integrations.abrp.api import AbrpApiListener 11 | from integrations.osmand.api import OsmAndApiListener 12 | from mqtt_topics import INTERNAL_ABRP, INTERNAL_API, INTERNAL_OSMAND 13 | 14 | if TYPE_CHECKING: 15 | from publisher.core import Publisher 16 | 17 | LOG = logging.getLogger(__name__) 18 | 19 | 20 | class MqttGatewayListenerApiListener: 21 | def __init__(self, publisher: Publisher, topic_prefix: str) -> None: 22 | self.__publisher = publisher 23 | self.__topic_prefix = topic_prefix 24 | 25 | async def publish_request( 26 | self, 27 | path: str, 28 | body: str | None = None, 29 | headers: dict[str, str] | None = None, 30 | ) -> None: 31 | parsed_url = urlparse(path) 32 | query_string = parse_qs(parsed_url.query) 33 | if body: 34 | try: 35 | body = json.loads(body) 36 | except Exception as e: 37 | LOG.debug("Could not parse body as JSON", exc_info=e) 38 | json_message = { 39 | "path": parsed_url.path, 40 | "query": query_string, 41 | "body": body, 42 | "headers": headers, 43 | } 44 | topic = parsed_url.path.strip("/") 45 | self.__internal_publish( 46 | key=self.__topic_prefix + "/" + topic + "/request", data=json_message 47 | ) 48 | 49 | async def publish_response( 50 | self, 51 | path: str, 52 | body: str | None = None, 53 | headers: dict[str, str] | None = None, 54 | ) -> None: 55 | parsed_url = urlparse(path) 56 | query_string = parse_qs(parsed_url.query) 57 | if body: 58 | try: 59 | body = json.loads(body) 60 | except Exception as e: 61 | LOG.debug("Could not parse body as JSON", exc_info=e) 62 | json_message = { 63 | "path": parsed_url.path, 64 | "query": query_string, 65 | "body": body, 66 | "headers": headers, 67 | } 68 | topic = parsed_url.path.strip("/") 69 | self.__internal_publish( 70 | key=self.__topic_prefix + "/" + topic + "/response", data=json_message 71 | ) 72 | 73 | def __internal_publish(self, *, key: str, data: dict[str, Any]) -> None: 74 | if self.__publisher and self.__publisher.is_connected(): 75 | self.__publisher.publish_json(key=key, data=data) 76 | else: 77 | LOG.info( 78 | f"Not publishing API response to MQTT since publisher is not connected. {data}" 79 | ) 80 | 81 | 82 | class MqttGatewayOsmAndListener(OsmAndApiListener, MqttGatewayListenerApiListener): 83 | def __init__(self, publisher: Publisher) -> None: 84 | super().__init__(publisher, INTERNAL_OSMAND) 85 | 86 | @override 87 | async def on_request( 88 | self, 89 | path: str, 90 | body: str | None = None, 91 | headers: dict[str, str] | None = None, 92 | ) -> None: 93 | await self.publish_request(path, body, headers) 94 | 95 | @override 96 | async def on_response( 97 | self, 98 | path: str, 99 | body: str | None = None, 100 | headers: dict[str, str] | None = None, 101 | ) -> None: 102 | await self.publish_response(path, body, headers) 103 | 104 | 105 | class MqttGatewayAbrpListener(AbrpApiListener, MqttGatewayListenerApiListener): 106 | def __init__(self, publisher: Publisher) -> None: 107 | super().__init__(publisher, INTERNAL_ABRP) 108 | 109 | @override 110 | async def on_request( 111 | self, 112 | path: str, 113 | body: str | None = None, 114 | headers: dict[str, str] | None = None, 115 | ) -> None: 116 | await self.publish_request(path, body, headers) 117 | 118 | @override 119 | async def on_response( 120 | self, 121 | path: str, 122 | body: str | None = None, 123 | headers: dict[str, str] | None = None, 124 | ) -> None: 125 | await self.publish_response(path, body, headers) 126 | 127 | 128 | class MqttGatewaySaicApiListener(SaicApiListener, MqttGatewayListenerApiListener): 129 | def __init__(self, publisher: Publisher) -> None: 130 | super().__init__(publisher, INTERNAL_API) 131 | 132 | @override 133 | async def on_request( 134 | self, 135 | path: str, 136 | body: str | None = None, 137 | headers: dict[str, str] | None = None, 138 | ) -> None: 139 | await self.publish_request(path, body, headers) 140 | 141 | @override 142 | async def on_response( 143 | self, 144 | path: str, 145 | body: str | None = None, 146 | headers: dict[str, str] | None = None, 147 | ) -> None: 148 | await self.publish_response(path, body, headers) 149 | -------------------------------------------------------------------------------- /src/status_publisher/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from typing import TYPE_CHECKING, Any, Final, TypeVar 5 | 6 | from utils import datetime_to_str 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Callable 10 | 11 | from publisher.core import Publisher 12 | from vehicle_info import VehicleInfo 13 | 14 | T = TypeVar("T") 15 | Publishable = TypeVar("Publishable", str, int, float, bool, dict[str, Any], datetime) 16 | 17 | 18 | class VehicleDataPublisher: 19 | def __init__( 20 | self, vin: VehicleInfo, publisher: Publisher, mqtt_vehicle_prefix: str 21 | ) -> None: 22 | self._vehicle_info: Final[VehicleInfo] = vin 23 | self.__publisher: Final[Publisher] = publisher 24 | self.__mqtt_vehicle_prefix: Final[str] = mqtt_vehicle_prefix 25 | 26 | def _publish( 27 | self, 28 | *, 29 | topic: str, 30 | value: Publishable | None, 31 | validator: Callable[[Publishable], bool] = lambda _: True, 32 | no_prefix: bool = False, 33 | ) -> tuple[bool, Publishable | None]: 34 | if value is None or not validator(value): 35 | return False, None 36 | actual_topic = topic if no_prefix else self.__get_topic(topic) 37 | published = self._publish_directly(topic=actual_topic, value=value) 38 | return published, value 39 | 40 | def _transform_and_publish( 41 | self, 42 | *, 43 | topic: str, 44 | value: T | None, 45 | validator: Callable[[T], bool] = lambda _: True, 46 | transform: Callable[[T], Publishable], 47 | no_prefix: bool = False, 48 | ) -> tuple[bool, Publishable | None]: 49 | if value is None or not validator(value): 50 | return False, None 51 | actual_topic = topic if no_prefix else self.__get_topic(topic) 52 | transformed_value = transform(value) 53 | published = self._publish_directly(topic=actual_topic, value=transformed_value) 54 | return published, transformed_value 55 | 56 | def _publish_directly(self, *, topic: str, value: Publishable) -> bool: 57 | published = False 58 | if isinstance(value, bool): 59 | self.__publisher.publish_bool(topic, value) 60 | published = True 61 | elif isinstance(value, int): 62 | self.__publisher.publish_int(topic, value) 63 | published = True 64 | elif isinstance(value, float): 65 | self.__publisher.publish_float(topic, value) 66 | published = True 67 | elif isinstance(value, str): 68 | self.__publisher.publish_str(topic, value) 69 | published = True 70 | elif isinstance(value, dict): 71 | self.__publisher.publish_json(topic, value) 72 | published = True 73 | elif isinstance(value, datetime): 74 | self.__publisher.publish_str(topic, datetime_to_str(value)) 75 | published = True 76 | return published 77 | 78 | def __get_topic(self, sub_topic: str) -> str: 79 | return f"{self.__mqtt_vehicle_prefix}/{sub_topic}" 80 | -------------------------------------------------------------------------------- /src/status_publisher/charge/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAIC-iSmart-API/saic-python-mqtt-gateway/84f96c7c0040ac77be40d4beb2a8cedc9356829d/src/status_publisher/charge/__init__.py -------------------------------------------------------------------------------- /src/status_publisher/charge/chrg_mgmt_data.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | import datetime 5 | import logging 6 | import math 7 | 8 | from saic_ismart_client_ng.api.vehicle_charging import ( 9 | ChargeCurrentLimitCode, 10 | ChrgMgmtData, 11 | ScheduledChargingMode, 12 | TargetBatteryCode, 13 | ) 14 | 15 | import mqtt_topics 16 | from status_publisher import VehicleDataPublisher 17 | from utils import int_to_bool, value_in_range 18 | 19 | LOG = logging.getLogger(__name__) 20 | 21 | 22 | @dataclasses.dataclass(kw_only=True, frozen=True) 23 | class ScheduledCharging: 24 | start_time: datetime.time 25 | mode: ScheduledChargingMode 26 | 27 | 28 | @dataclasses.dataclass(kw_only=True, frozen=True) 29 | class ChrgMgmtDataProcessingResult: 30 | charge_current_limit: ChargeCurrentLimitCode | None 31 | target_soc: TargetBatteryCode | None 32 | scheduled_charging: ScheduledCharging | None 33 | is_charging: bool 34 | remaining_charging_time: int | None 35 | power: float | None 36 | raw_soc: int | None 37 | 38 | 39 | class ChrgMgmtDataPublisher(VehicleDataPublisher): 40 | def on_chrg_mgmt_data( 41 | self, charge_mgmt_data: ChrgMgmtData 42 | ) -> ChrgMgmtDataProcessingResult: 43 | is_valid_raw_current = ( 44 | charge_mgmt_data.bmsPackCrntV != 1 45 | and charge_mgmt_data.bmsPackCrnt is not None 46 | and value_in_range(charge_mgmt_data.bmsPackCrnt, 0, 65535) 47 | and charge_mgmt_data.decoded_current is not None 48 | ) 49 | is_valid_current, _ = self._transform_and_publish( 50 | topic=mqtt_topics.DRIVETRAIN_CURRENT, 51 | value=charge_mgmt_data.decoded_current, 52 | validator=lambda _: is_valid_raw_current, 53 | transform=lambda x: round(x, 3), 54 | ) 55 | 56 | is_valid_raw_voltage = ( 57 | charge_mgmt_data.bmsPackVol is not None 58 | and value_in_range(charge_mgmt_data.bmsPackVol, 0, 65535) 59 | ) 60 | is_valid_voltage, _ = self._transform_and_publish( 61 | topic=mqtt_topics.DRIVETRAIN_VOLTAGE, 62 | value=charge_mgmt_data.decoded_voltage, 63 | validator=lambda _: is_valid_raw_voltage, 64 | transform=lambda x: round(x, 3), 65 | ) 66 | 67 | is_valid_power, _ = self._transform_and_publish( 68 | topic=mqtt_topics.DRIVETRAIN_POWER, 69 | value=charge_mgmt_data.decoded_power, 70 | validator=lambda _: is_valid_current and is_valid_voltage, 71 | transform=lambda x: round(x, 3), 72 | ) 73 | 74 | obc_voltage = charge_mgmt_data.onBdChrgrAltrCrntInptVol 75 | obc_current = charge_mgmt_data.onBdChrgrAltrCrntInptCrnt 76 | if obc_voltage is not None and obc_current is not None: 77 | self._publish( 78 | topic=mqtt_topics.OBC_CURRENT, 79 | value=round(obc_current / 5.0, 1), 80 | ) 81 | self._publish( 82 | topic=mqtt_topics.OBC_VOLTAGE, 83 | value=2 * obc_voltage, 84 | ) 85 | self._publish( 86 | topic=mqtt_topics.OBC_POWER_SINGLE_PHASE, 87 | value=round(2.0 * obc_voltage * obc_current / 5.0, 1), 88 | ) 89 | self._publish( 90 | topic=mqtt_topics.OBC_POWER_THREE_PHASE, 91 | value=round(math.sqrt(3) * 2 * obc_voltage * obc_current / 15.0, 1), 92 | ) 93 | else: 94 | self._publish( 95 | topic=mqtt_topics.OBC_CURRENT, 96 | value=0.0, 97 | ) 98 | self._publish( 99 | topic=mqtt_topics.OBC_VOLTAGE, 100 | value=0, 101 | ) 102 | 103 | raw_charge_current_limit = charge_mgmt_data.bmsAltngChrgCrntDspCmd 104 | charge_current_limit: ChargeCurrentLimitCode | None = None 105 | if raw_charge_current_limit is not None and raw_charge_current_limit != 0: 106 | try: 107 | charge_current_limit = ChargeCurrentLimitCode(raw_charge_current_limit) 108 | except ValueError: 109 | LOG.warning( 110 | f"Invalid charge current limit received: {raw_charge_current_limit}" 111 | ) 112 | 113 | raw_target_soc = charge_mgmt_data.bmsOnBdChrgTrgtSOCDspCmd 114 | target_soc: TargetBatteryCode | None = None 115 | if raw_target_soc is not None: 116 | try: 117 | target_soc = TargetBatteryCode(raw_target_soc) 118 | except ValueError: 119 | LOG.warning(f"Invalid target SOC received: {raw_target_soc}") 120 | 121 | self._publish( 122 | topic=mqtt_topics.DRIVETRAIN_HYBRID_ELECTRICAL_RANGE, 123 | value=charge_mgmt_data.bmsEstdElecRng, 124 | validator=lambda x: value_in_range(x, 0, 2046), 125 | ) 126 | 127 | self._transform_and_publish( 128 | topic=mqtt_topics.BMS_CHARGE_STATUS, 129 | value=charge_mgmt_data.bms_charging_status, 130 | transform=lambda x: f"UNKNOWN {charge_mgmt_data.bmsChrgSts}" 131 | if x is None 132 | else x.name, 133 | ) 134 | 135 | self._transform_and_publish( 136 | topic=mqtt_topics.DRIVETRAIN_CHARGING_STOP_REASON, 137 | value=charge_mgmt_data.charging_stop_reason, 138 | transform=lambda x: f"UNKNOWN {charge_mgmt_data.bmsChrgSpRsn}" 139 | if x is None 140 | else x.name, 141 | ) 142 | 143 | self._publish( 144 | topic=mqtt_topics.CCU_ONBOARD_PLUG_STATUS, 145 | value=charge_mgmt_data.ccuOnbdChrgrPlugOn, 146 | ) 147 | 148 | self._publish( 149 | topic=mqtt_topics.CCU_OFFBOARD_PLUG_STATUS, 150 | value=charge_mgmt_data.ccuOffBdChrgrPlugOn, 151 | ) 152 | 153 | scheduled_charging: ScheduledCharging | None = None 154 | if charge_mgmt_data is not None and ( 155 | charge_mgmt_data.bmsReserStHourDspCmd is not None 156 | and charge_mgmt_data.bmsReserStMintueDspCmd is not None 157 | and charge_mgmt_data.bmsReserSpHourDspCmd is not None 158 | and charge_mgmt_data.bmsReserSpMintueDspCmd is not None 159 | ): 160 | try: 161 | start_hour = charge_mgmt_data.bmsReserStHourDspCmd 162 | start_minute = charge_mgmt_data.bmsReserStMintueDspCmd 163 | start_time = datetime.time(hour=start_hour, minute=start_minute) 164 | end_hour = charge_mgmt_data.bmsReserSpHourDspCmd 165 | end_minute = charge_mgmt_data.bmsReserSpMintueDspCmd 166 | mode = ScheduledChargingMode(charge_mgmt_data.bmsReserCtrlDspCmd) 167 | self._publish( 168 | topic=mqtt_topics.DRIVETRAIN_CHARGING_SCHEDULE, 169 | value={ 170 | "startTime": f"{start_hour:02d}:{start_minute:02d}", 171 | "endTime": f"{end_hour:02d}:{end_minute:02d}", 172 | "mode": mode.name, 173 | }, 174 | ) 175 | scheduled_charging = ScheduledCharging(start_time=start_time, mode=mode) 176 | 177 | except ValueError: 178 | LOG.exception("Error parsing scheduled charging info") 179 | 180 | # Only publish remaining charging time if the car tells us the value is OK 181 | remaining_charging_time: int | None = None 182 | valid_remaining_time, remaining_charging_time = self._transform_and_publish( 183 | topic=mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME, 184 | value=charge_mgmt_data.chrgngRmnngTime, 185 | validator=lambda _: charge_mgmt_data.chrgngRmnngTimeV != 1, 186 | transform=lambda x: x * 60, 187 | ) 188 | if not valid_remaining_time: 189 | self._publish(topic=mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME, value=0) 190 | 191 | # We are charging if the BMS tells us so 192 | is_charging = charge_mgmt_data.is_bms_charging 193 | self._publish(topic=mqtt_topics.DRIVETRAIN_CHARGING, value=is_charging) 194 | 195 | self._publish( 196 | topic=mqtt_topics.DRIVETRAIN_BATTERY_HEATING, 197 | value=charge_mgmt_data.is_battery_heating, 198 | ) 199 | 200 | self._transform_and_publish( 201 | topic=mqtt_topics.DRIVETRAIN_BATTERY_HEATING_STOP_REASON, 202 | value=charge_mgmt_data.heating_stop_reason, 203 | transform=lambda x: f"UNKNOWN ({charge_mgmt_data.bmsPTCHeatResp})" 204 | if x is None 205 | else x.name, 206 | ) 207 | 208 | self._transform_and_publish( 209 | topic=mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK, 210 | value=charge_mgmt_data.charging_port_locked, 211 | transform=int_to_bool, 212 | ) 213 | 214 | return ChrgMgmtDataProcessingResult( 215 | charge_current_limit=charge_current_limit, 216 | target_soc=target_soc, 217 | scheduled_charging=scheduled_charging, 218 | is_charging=is_charging, 219 | remaining_charging_time=remaining_charging_time, 220 | power=charge_mgmt_data.decoded_power if is_valid_power else None, 221 | raw_soc=charge_mgmt_data.bmsPackSOCDsp, 222 | ) 223 | -------------------------------------------------------------------------------- /src/status_publisher/charge/chrg_mgmt_data_resp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | import datetime 5 | from typing import TYPE_CHECKING 6 | 7 | import mqtt_topics 8 | from status_publisher import VehicleDataPublisher 9 | from status_publisher.charge.chrg_mgmt_data import ( 10 | ChrgMgmtDataProcessingResult, 11 | ChrgMgmtDataPublisher, 12 | ScheduledCharging, 13 | ) 14 | from status_publisher.charge.rvs_charge_status import ( 15 | RvsChargeStatusProcessingResult, 16 | RvsChargeStatusPublisher, 17 | ) 18 | 19 | if TYPE_CHECKING: 20 | from saic_ismart_client_ng.api.vehicle_charging import ( 21 | ChargeCurrentLimitCode, 22 | ChrgMgmtDataResp, 23 | TargetBatteryCode, 24 | ) 25 | 26 | from publisher.core import Publisher 27 | from vehicle_info import VehicleInfo 28 | 29 | 30 | @dataclasses.dataclass(kw_only=True, frozen=True) 31 | class ChrgMgmtDataRespProcessingResult: 32 | charge_current_limit: ChargeCurrentLimitCode | None 33 | target_soc: TargetBatteryCode | None 34 | scheduled_charging: ScheduledCharging | None 35 | is_charging: bool | None 36 | remaining_charging_time: int | None 37 | power: float | None 38 | real_total_battery_capacity: float 39 | raw_soc: int | None 40 | raw_fuel_range_elec: int | None 41 | 42 | 43 | class ChrgMgmtDataRespPublisher(VehicleDataPublisher): 44 | def __init__( 45 | self, vin: VehicleInfo, publisher: Publisher, mqtt_vehicle_prefix: str 46 | ) -> None: 47 | super().__init__(vin, publisher, mqtt_vehicle_prefix) 48 | self.__chrg_mgmt_data_publisher = ChrgMgmtDataPublisher( 49 | vin, publisher, mqtt_vehicle_prefix 50 | ) 51 | self.__rvs_charge_status_publisher = RvsChargeStatusPublisher( 52 | vin, publisher, mqtt_vehicle_prefix 53 | ) 54 | 55 | def on_chrg_mgmt_data_resp( 56 | self, chrg_mgmt_data_resp: ChrgMgmtDataResp 57 | ) -> ChrgMgmtDataRespProcessingResult: 58 | chrg_mgmt_data = chrg_mgmt_data_resp.chrgMgmtData 59 | chrg_mgmt_data_result: ChrgMgmtDataProcessingResult | None = None 60 | if chrg_mgmt_data is not None: 61 | chrg_mgmt_data_result = self.__chrg_mgmt_data_publisher.on_chrg_mgmt_data( 62 | chrg_mgmt_data 63 | ) 64 | 65 | charge_status = chrg_mgmt_data_resp.rvsChargeStatus 66 | charge_status_result: RvsChargeStatusProcessingResult | None = None 67 | if charge_status is not None: 68 | charge_status_result = ( 69 | self.__rvs_charge_status_publisher.on_rvs_charge_status(charge_status) 70 | ) 71 | else: 72 | pass 73 | 74 | if chrg_mgmt_data_result is not None or charge_status_result is not None: 75 | self._publish( 76 | topic=mqtt_topics.REFRESH_LAST_CHARGE_STATE, 77 | value=datetime.datetime.now(), 78 | ) 79 | return ChrgMgmtDataRespProcessingResult( 80 | charge_current_limit=chrg_mgmt_data_result.charge_current_limit 81 | if chrg_mgmt_data_result is not None 82 | else None, 83 | target_soc=chrg_mgmt_data_result.target_soc 84 | if chrg_mgmt_data_result is not None 85 | else None, 86 | scheduled_charging=chrg_mgmt_data_result.scheduled_charging 87 | if chrg_mgmt_data_result is not None 88 | else None, 89 | is_charging=chrg_mgmt_data_result.is_charging 90 | if chrg_mgmt_data_result is not None 91 | else None, 92 | remaining_charging_time=chrg_mgmt_data_result.remaining_charging_time 93 | if chrg_mgmt_data_result is not None 94 | else None, 95 | power=chrg_mgmt_data_result.power 96 | if chrg_mgmt_data_result is not None 97 | else None, 98 | real_total_battery_capacity=charge_status_result.real_total_battery_capacity 99 | if charge_status_result is not None 100 | else 0.0, 101 | raw_soc=chrg_mgmt_data_result.raw_soc 102 | if chrg_mgmt_data_result is not None 103 | else None, 104 | raw_fuel_range_elec=charge_status_result.raw_fuel_range_elec 105 | if charge_status_result is not None 106 | else None, 107 | ) 108 | -------------------------------------------------------------------------------- /src/status_publisher/charge/rvs_charge_status.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | import logging 5 | from typing import TYPE_CHECKING 6 | 7 | import mqtt_topics 8 | from status_publisher import VehicleDataPublisher 9 | from utils import int_to_bool, value_in_range 10 | 11 | if TYPE_CHECKING: 12 | from saic_ismart_client_ng.api.vehicle_charging import RvsChargeStatus 13 | 14 | LOG = logging.getLogger(__name__) 15 | 16 | 17 | @dataclass(kw_only=True, frozen=True) 18 | class RvsChargeStatusProcessingResult: 19 | real_total_battery_capacity: float 20 | raw_fuel_range_elec: int | None 21 | 22 | 23 | class RvsChargeStatusPublisher(VehicleDataPublisher): 24 | def on_rvs_charge_status( 25 | self, charge_status: RvsChargeStatus 26 | ) -> RvsChargeStatusProcessingResult: 27 | self._transform_and_publish( 28 | topic=mqtt_topics.DRIVETRAIN_MILEAGE_OF_DAY, 29 | value=charge_status.mileageOfDay, 30 | validator=lambda x: value_in_range(x, 0, 65535), 31 | transform=lambda x: x / 10.0, 32 | ) 33 | 34 | self._transform_and_publish( 35 | topic=mqtt_topics.DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE, 36 | value=charge_status.mileageSinceLastCharge, 37 | validator=lambda x: value_in_range(x, 0, 65535), 38 | transform=lambda x: x / 10.0, 39 | ) 40 | 41 | self._publish( 42 | topic=mqtt_topics.DRIVETRAIN_CHARGING_TYPE, 43 | value=charge_status.chargingType, 44 | ) 45 | 46 | self._transform_and_publish( 47 | topic=mqtt_topics.DRIVETRAIN_CHARGER_CONNECTED, 48 | value=charge_status.chargingGunState, 49 | transform=int_to_bool, 50 | ) 51 | 52 | self._publish( 53 | topic=mqtt_topics.DRIVETRAIN_CHARGING_LAST_START, 54 | value=charge_status.startTime, 55 | validator=lambda x: value_in_range(x, 1, 2147483647), 56 | ) 57 | 58 | self._publish( 59 | topic=mqtt_topics.DRIVETRAIN_CHARGING_LAST_END, 60 | value=charge_status.endTime, 61 | validator=lambda x: value_in_range(x, 1, 2147483647), 62 | ) 63 | 64 | real_total_battery_capacity, battery_capacity_correction_factor = ( 65 | self.get_actual_battery_capacity(charge_status) 66 | ) 67 | 68 | self._publish( 69 | topic=mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY, 70 | value=real_total_battery_capacity, 71 | validator=lambda x: x > 0, 72 | ) 73 | 74 | self._transform_and_publish( 75 | topic=mqtt_topics.DRIVETRAIN_SOC_KWH, 76 | value=charge_status.realtimePower, 77 | transform=lambda p: round( 78 | (battery_capacity_correction_factor * p) / 10.0, 2 79 | ), 80 | ) 81 | 82 | self._transform_and_publish( 83 | topic=mqtt_topics.DRIVETRAIN_LAST_CHARGE_ENDING_POWER, 84 | value=charge_status.lastChargeEndingPower, 85 | validator=lambda x: value_in_range(x, 0, 65535), 86 | transform=lambda p: round( 87 | (battery_capacity_correction_factor * p) / 10.0, 2 88 | ), 89 | ) 90 | 91 | self._transform_and_publish( 92 | topic=mqtt_topics.DRIVETRAIN_POWER_USAGE_OF_DAY, 93 | value=charge_status.powerUsageOfDay, 94 | validator=lambda x: value_in_range(x, 0, 65535), 95 | transform=lambda p: round( 96 | (battery_capacity_correction_factor * p) / 10.0, 2 97 | ), 98 | ) 99 | 100 | self._transform_and_publish( 101 | topic=mqtt_topics.DRIVETRAIN_POWER_USAGE_SINCE_LAST_CHARGE, 102 | value=charge_status.powerUsageSinceLastCharge, 103 | validator=lambda x: value_in_range(x, 0, 65535), 104 | transform=lambda p: round( 105 | (battery_capacity_correction_factor * p) / 10.0, 2 106 | ), 107 | ) 108 | 109 | return RvsChargeStatusProcessingResult( 110 | real_total_battery_capacity=real_total_battery_capacity, 111 | raw_fuel_range_elec=charge_status.fuelRangeElec, 112 | ) 113 | 114 | def get_actual_battery_capacity( 115 | self, charge_status: RvsChargeStatus 116 | ) -> tuple[float, float]: 117 | real_battery_capacity = self._vehicle_info.real_battery_capacity 118 | if real_battery_capacity is not None and real_battery_capacity <= 0: 119 | # Negative or 0 value for real capacity means we don't know that info 120 | real_battery_capacity = None 121 | 122 | raw_battery_capacity = None 123 | if ( 124 | charge_status.totalBatteryCapacity is not None 125 | and charge_status.totalBatteryCapacity > 0 126 | ): 127 | raw_battery_capacity = charge_status.totalBatteryCapacity / 10.0 128 | 129 | if raw_battery_capacity is not None: 130 | if real_battery_capacity is not None: 131 | LOG.debug( 132 | "Calculating full battery capacity correction factor based on " 133 | "real=%f and raw=%f", 134 | real_battery_capacity, 135 | raw_battery_capacity, 136 | ) 137 | return ( 138 | real_battery_capacity, 139 | real_battery_capacity / raw_battery_capacity, 140 | ) 141 | LOG.debug( 142 | "Setting real battery capacity to raw battery capacity %f", 143 | raw_battery_capacity, 144 | ) 145 | return raw_battery_capacity, 1.0 146 | if real_battery_capacity is not None: 147 | LOG.debug( 148 | "Setting raw battery capacity to real battery capacity %f", 149 | real_battery_capacity, 150 | ) 151 | return real_battery_capacity, 1.0 152 | LOG.warning("No battery capacity information available") 153 | return 0, 1.0 154 | -------------------------------------------------------------------------------- /src/status_publisher/message.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | from typing import TYPE_CHECKING 6 | 7 | import mqtt_topics 8 | from status_publisher import VehicleDataPublisher 9 | 10 | if TYPE_CHECKING: 11 | from saic_ismart_client_ng.api.message import MessageEntity 12 | 13 | from publisher.core import Publisher 14 | from vehicle_info import VehicleInfo 15 | 16 | 17 | @dataclass(kw_only=True, frozen=True) 18 | class MessagePublisherProcessingResult: 19 | processed: bool 20 | 21 | 22 | class MessagePublisher(VehicleDataPublisher): 23 | def __init__( 24 | self, vin: VehicleInfo, publisher: Publisher, mqtt_vehicle_prefix: str 25 | ) -> None: 26 | super().__init__(vin, publisher, mqtt_vehicle_prefix) 27 | self.__last_car_vehicle_message = datetime.min 28 | 29 | def on_message(self, message: MessageEntity) -> MessagePublisherProcessingResult: 30 | if ( 31 | self.__last_car_vehicle_message == datetime.min 32 | or message.message_time > self.__last_car_vehicle_message 33 | ): 34 | self.__last_car_vehicle_message = message.message_time 35 | self._publish( 36 | topic=mqtt_topics.INFO_LAST_MESSAGE_TIME, 37 | value=self.__last_car_vehicle_message, 38 | ) 39 | 40 | if isinstance(message.messageId, str): 41 | self._publish( 42 | topic=mqtt_topics.INFO_LAST_MESSAGE_ID, 43 | value=message.messageId, 44 | ) 45 | else: 46 | self._transform_and_publish( 47 | topic=mqtt_topics.INFO_LAST_MESSAGE_ID, 48 | value=message.messageId, 49 | transform=lambda x: str(x), 50 | ) 51 | 52 | self._publish( 53 | topic=mqtt_topics.INFO_LAST_MESSAGE_TYPE, 54 | value=message.messageType, 55 | ) 56 | 57 | self._publish( 58 | topic=mqtt_topics.INFO_LAST_MESSAGE_TITLE, 59 | value=message.title, 60 | ) 61 | 62 | self._publish( 63 | topic=mqtt_topics.INFO_LAST_MESSAGE_SENDER, 64 | value=message.sender, 65 | ) 66 | 67 | self._publish( 68 | topic=mqtt_topics.INFO_LAST_MESSAGE_CONTENT, 69 | value=message.content, 70 | ) 71 | 72 | self._publish( 73 | topic=mqtt_topics.INFO_LAST_MESSAGE_STATUS, 74 | value=message.read_status, 75 | ) 76 | 77 | self._publish( 78 | topic=mqtt_topics.INFO_LAST_MESSAGE_VIN, 79 | value=message.vin, 80 | ) 81 | 82 | return MessagePublisherProcessingResult(processed=True) 83 | return MessagePublisherProcessingResult(processed=False) 84 | -------------------------------------------------------------------------------- /src/status_publisher/vehicle/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAIC-iSmart-API/saic-python-mqtt-gateway/84f96c7c0040ac77be40d4beb2a8cedc9356829d/src/status_publisher/vehicle/__init__.py -------------------------------------------------------------------------------- /src/status_publisher/vehicle/basic_vehicle_status.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING 5 | 6 | import mqtt_topics 7 | from status_publisher import VehicleDataPublisher 8 | from utils import int_to_bool, is_valid_temperature, to_remote_climate, value_in_range 9 | 10 | if TYPE_CHECKING: 11 | from saic_ismart_client_ng.api.vehicle import BasicVehicleStatus 12 | 13 | PRESSURE_TO_BAR_FACTOR = 0.04 14 | 15 | 16 | @dataclass(kw_only=True, frozen=True) 17 | class BasicVehicleStatusProcessingResult: 18 | hv_battery_active_from_car: bool 19 | remote_ac_running: bool 20 | remote_heated_seats_front_right_level: int | None 21 | remote_heated_seats_front_left_level: int | None 22 | is_parked: bool 23 | fuel_rage_elec: int | None 24 | raw_soc: int | None 25 | 26 | 27 | class BasicVehicleStatusPublisher(VehicleDataPublisher): 28 | def on_basic_vehicle_status( 29 | self, basic_vehicle_status: BasicVehicleStatus 30 | ) -> BasicVehicleStatusProcessingResult: 31 | is_engine_running = basic_vehicle_status.is_engine_running 32 | remote_climate_status = basic_vehicle_status.remoteClimateStatus or 0 33 | rear_window_heat_state = basic_vehicle_status.rmtHtdRrWndSt or 0 34 | 35 | hv_battery_active_from_car = ( 36 | is_engine_running or remote_climate_status > 0 or rear_window_heat_state > 0 37 | ) 38 | 39 | is_valid_mileage, _ = self._transform_and_publish( 40 | topic=mqtt_topics.DRIVETRAIN_MILEAGE, 41 | value=basic_vehicle_status.mileage, 42 | validator=lambda x: value_in_range(x, 1, 2147483647), 43 | transform=lambda x: x / 10.0, 44 | ) 45 | 46 | self._publish( 47 | topic=mqtt_topics.DRIVETRAIN_RUNNING, 48 | value=is_engine_running, 49 | ) 50 | 51 | self._publish( 52 | topic=mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE, 53 | value=basic_vehicle_status.interiorTemperature, 54 | validator=is_valid_temperature, 55 | ) 56 | 57 | self._publish( 58 | topic=mqtt_topics.CLIMATE_EXTERIOR_TEMPERATURE, 59 | value=basic_vehicle_status.exteriorTemperature, 60 | validator=is_valid_temperature, 61 | ) 62 | 63 | self._transform_and_publish( 64 | topic=mqtt_topics.DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE, 65 | value=basic_vehicle_status.batteryVoltage, 66 | validator=lambda x: value_in_range(x, 1, 65535), 67 | transform=lambda x: x / 10.0, 68 | ) 69 | 70 | if is_valid_mileage: 71 | self._transform_and_publish( 72 | topic=mqtt_topics.WINDOWS_DRIVER, 73 | value=basic_vehicle_status.driverWindow, 74 | transform=int_to_bool, 75 | ) 76 | 77 | self._transform_and_publish( 78 | topic=mqtt_topics.WINDOWS_PASSENGER, 79 | value=basic_vehicle_status.passengerWindow, 80 | transform=int_to_bool, 81 | ) 82 | 83 | self._transform_and_publish( 84 | topic=mqtt_topics.WINDOWS_REAR_LEFT, 85 | value=basic_vehicle_status.rearLeftWindow, 86 | transform=int_to_bool, 87 | ) 88 | 89 | self._transform_and_publish( 90 | topic=mqtt_topics.WINDOWS_REAR_RIGHT, 91 | value=basic_vehicle_status.rearRightWindow, 92 | transform=int_to_bool, 93 | ) 94 | 95 | self._transform_and_publish( 96 | topic=mqtt_topics.WINDOWS_SUN_ROOF, 97 | value=basic_vehicle_status.sunroofStatus, 98 | transform=int_to_bool, 99 | ) 100 | 101 | self._transform_and_publish( 102 | topic=mqtt_topics.DOORS_LOCKED, 103 | value=basic_vehicle_status.lockStatus, 104 | transform=int_to_bool, 105 | ) 106 | 107 | self._transform_and_publish( 108 | topic=mqtt_topics.DOORS_DRIVER, 109 | value=basic_vehicle_status.driverDoor, 110 | transform=int_to_bool, 111 | ) 112 | 113 | self._transform_and_publish( 114 | topic=mqtt_topics.DOORS_PASSENGER, 115 | value=basic_vehicle_status.passengerDoor, 116 | transform=int_to_bool, 117 | ) 118 | 119 | self._transform_and_publish( 120 | topic=mqtt_topics.DOORS_REAR_LEFT, 121 | value=basic_vehicle_status.rearLeftDoor, 122 | transform=int_to_bool, 123 | ) 124 | 125 | self._transform_and_publish( 126 | topic=mqtt_topics.DOORS_REAR_RIGHT, 127 | value=basic_vehicle_status.rearRightDoor, 128 | transform=int_to_bool, 129 | ) 130 | 131 | self._transform_and_publish( 132 | topic=mqtt_topics.DOORS_BONNET, 133 | value=basic_vehicle_status.bonnetStatus, 134 | transform=int_to_bool, 135 | ) 136 | 137 | self._transform_and_publish( 138 | topic=mqtt_topics.DOORS_BOOT, 139 | value=basic_vehicle_status.bootStatus, 140 | transform=int_to_bool, 141 | ) 142 | 143 | self.__publish_tyre( 144 | basic_vehicle_status.frontLeftTyrePressure, 145 | mqtt_topics.TYRES_FRONT_LEFT_PRESSURE, 146 | ) 147 | 148 | self.__publish_tyre( 149 | basic_vehicle_status.frontRightTyrePressure, 150 | mqtt_topics.TYRES_FRONT_RIGHT_PRESSURE, 151 | ) 152 | self.__publish_tyre( 153 | basic_vehicle_status.rearLeftTyrePressure, 154 | mqtt_topics.TYRES_REAR_LEFT_PRESSURE, 155 | ) 156 | self.__publish_tyre( 157 | basic_vehicle_status.rearRightTyrePressure, 158 | mqtt_topics.TYRES_REAR_RIGHT_PRESSURE, 159 | ) 160 | 161 | self._transform_and_publish( 162 | topic=mqtt_topics.LIGHTS_MAIN_BEAM, 163 | value=basic_vehicle_status.mainBeamStatus, 164 | transform=int_to_bool, 165 | ) 166 | 167 | self._transform_and_publish( 168 | topic=mqtt_topics.LIGHTS_DIPPED_BEAM, 169 | value=basic_vehicle_status.dippedBeamStatus, 170 | transform=int_to_bool, 171 | ) 172 | 173 | self._transform_and_publish( 174 | topic=mqtt_topics.LIGHTS_SIDE, 175 | value=basic_vehicle_status.sideLightStatus, 176 | transform=int_to_bool, 177 | ) 178 | 179 | self._transform_and_publish( 180 | topic=mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE, 181 | value=remote_climate_status, 182 | transform=lambda x: to_remote_climate(x), 183 | ) 184 | 185 | remote_ac_running = remote_climate_status == 2 186 | 187 | self._transform_and_publish( 188 | topic=mqtt_topics.CLIMATE_BACK_WINDOW_HEAT, 189 | value=rear_window_heat_state, 190 | transform=lambda x: "off" if x == 0 else "on", 191 | ) 192 | 193 | _, front_left_seat_level = self._publish( 194 | topic=mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_LEFT_LEVEL, 195 | value=basic_vehicle_status.frontLeftSeatHeatLevel, 196 | validator=lambda x: value_in_range(x, 0, 255), 197 | ) 198 | 199 | _, front_right_seat_level = self._publish( 200 | topic=mqtt_topics.CLIMATE_HEATED_SEATS_FRONT_RIGHT_LEVEL, 201 | value=basic_vehicle_status.frontRightSeatHeatLevel, 202 | validator=lambda x: value_in_range(x, 0, 255), 203 | ) 204 | 205 | # Standard fossil fuels vehicles 206 | self._transform_and_publish( 207 | topic=mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_RANGE, 208 | value=basic_vehicle_status.fuelRange, 209 | validator=lambda x: value_in_range(x, 0, 65535), 210 | transform=lambda x: x / 10.0, 211 | ) 212 | 213 | self._publish( 214 | topic=mqtt_topics.DRIVETRAIN_FOSSIL_FUEL_PERCENTAGE, 215 | value=basic_vehicle_status.fuelLevelPrc, 216 | validator=lambda x: value_in_range(x, 0, 100, is_max_excl=False), 217 | ) 218 | 219 | if (journey_id := basic_vehicle_status.currentJourneyId) is not None and ( 220 | journey_distance := basic_vehicle_status.currentJourneyDistance 221 | ) is not None: 222 | self._publish( 223 | topic=mqtt_topics.DRIVETRAIN_CURRENT_JOURNEY, 224 | value={ 225 | "id": journey_id, 226 | "distance": round(journey_distance / 10.0, 1), 227 | }, 228 | ) 229 | return BasicVehicleStatusProcessingResult( 230 | hv_battery_active_from_car=hv_battery_active_from_car, 231 | remote_ac_running=remote_ac_running, 232 | is_parked=basic_vehicle_status.is_parked, 233 | remote_heated_seats_front_left_level=front_left_seat_level, 234 | remote_heated_seats_front_right_level=front_right_seat_level, 235 | fuel_rage_elec=basic_vehicle_status.fuelRangeElec, 236 | raw_soc=basic_vehicle_status.extendedData1, 237 | ) 238 | 239 | def __publish_tyre(self, raw_value: int | None, topic: str) -> None: 240 | self._transform_and_publish( 241 | topic=topic, 242 | value=raw_value, 243 | validator=lambda x: value_in_range(x, 1, 255), 244 | transform=lambda x: round(x * PRESSURE_TO_BAR_FACTOR, 2), 245 | ) 246 | -------------------------------------------------------------------------------- /src/status_publisher/vehicle/gps_position.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus 6 | 7 | import mqtt_topics 8 | from status_publisher import VehicleDataPublisher 9 | from utils import value_in_range 10 | 11 | 12 | @dataclass(kw_only=True, frozen=True) 13 | class GpsPositionProcessingResult: 14 | speed: float | None 15 | 16 | 17 | class GpsPositionPublisher(VehicleDataPublisher): 18 | def on_gps_position(self, gps_position: GpsPosition) -> GpsPositionProcessingResult: 19 | speed: float | None = None 20 | if gps_position.gps_status_decoded in [ 21 | GpsStatus.FIX_2D, 22 | GpsStatus.FIX_3d, 23 | ]: 24 | way_point = gps_position.wayPoint 25 | if way_point: 26 | if way_point.speed is not None: 27 | speed = way_point.speed / 10.0 28 | 29 | self._publish( 30 | topic=mqtt_topics.LOCATION_HEADING, 31 | value=way_point.heading, 32 | ) 33 | 34 | position = way_point.position 35 | if ( 36 | position 37 | and (raw_lat := position.latitude) is not None 38 | and (raw_long := position.longitude) is not None 39 | ): 40 | latitude = raw_lat / 1000000.0 41 | longitude = raw_long / 1000000.0 42 | if abs(latitude) <= 90 and abs(longitude) <= 180: 43 | self._publish( 44 | topic=mqtt_topics.LOCATION_LATITUDE, value=latitude 45 | ) 46 | self._publish( 47 | topic=mqtt_topics.LOCATION_LONGITUDE, value=longitude 48 | ) 49 | position_json = { 50 | "latitude": latitude, 51 | "longitude": longitude, 52 | } 53 | _, altitude = self._publish( 54 | topic=mqtt_topics.LOCATION_ELEVATION, 55 | value=position.altitude, 56 | validator=lambda x: value_in_range(x, -500, 8900), 57 | ) 58 | if altitude is not None: 59 | position_json["altitude"] = altitude 60 | self._publish( 61 | topic=mqtt_topics.LOCATION_POSITION, 62 | value=position_json, 63 | ) 64 | return GpsPositionProcessingResult(speed=speed) 65 | -------------------------------------------------------------------------------- /src/status_publisher/vehicle/vehicle_status_resp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | import datetime 5 | from typing import TYPE_CHECKING, Final 6 | 7 | from exceptions import MqttGatewayException 8 | import mqtt_topics 9 | from status_publisher import VehicleDataPublisher 10 | from status_publisher.vehicle.basic_vehicle_status import ( 11 | BasicVehicleStatusProcessingResult, 12 | BasicVehicleStatusPublisher, 13 | ) 14 | from status_publisher.vehicle.gps_position import ( 15 | GpsPositionProcessingResult, 16 | GpsPositionPublisher, 17 | ) 18 | 19 | if TYPE_CHECKING: 20 | from saic_ismart_client_ng.api.schema import GpsPosition 21 | from saic_ismart_client_ng.api.vehicle import BasicVehicleStatus, VehicleStatusResp 22 | 23 | from publisher.core import Publisher 24 | from vehicle_info import VehicleInfo 25 | 26 | 27 | @dataclass(kw_only=True, frozen=True) 28 | class VehicleStatusRespProcessingResult: 29 | hv_battery_active_from_car: bool 30 | remote_ac_running: bool 31 | remote_heated_seats_front_right_level: int | None 32 | remote_heated_seats_front_left_level: int | None 33 | fuel_range_elec: int | None 34 | raw_soc: int | None 35 | 36 | 37 | class VehicleStatusRespPublisher(VehicleDataPublisher): 38 | def __init__( 39 | self, vin: VehicleInfo, publisher: Publisher, mqtt_vehicle_prefix: str 40 | ) -> None: 41 | super().__init__(vin, publisher, mqtt_vehicle_prefix) 42 | self.__gps_position_publisher: Final[GpsPositionPublisher] = ( 43 | GpsPositionPublisher(vin, publisher, mqtt_vehicle_prefix) 44 | ) 45 | self.__basic_vehicle_status_publisher: Final[BasicVehicleStatusPublisher] = ( 46 | BasicVehicleStatusPublisher(vin, publisher, mqtt_vehicle_prefix) 47 | ) 48 | 49 | def on_vehicle_status_resp( 50 | self, vehicle_status: VehicleStatusResp 51 | ) -> VehicleStatusRespProcessingResult: 52 | vehicle_status_time = datetime.datetime.fromtimestamp( 53 | vehicle_status.statusTime or 0, tz=datetime.UTC 54 | ) 55 | now_time = datetime.datetime.now(tz=datetime.UTC) 56 | vehicle_status_drift = abs(now_time - vehicle_status_time) 57 | 58 | if vehicle_status_drift > datetime.timedelta(minutes=15): 59 | msg = f"Vehicle status time drifted more than 15 minutes from current time: {vehicle_status_drift}. Server reported {vehicle_status_time}" 60 | raise MqttGatewayException(msg) 61 | 62 | basic_vehicle_status = vehicle_status.basicVehicleStatus 63 | if basic_vehicle_status: 64 | return self.__on_basic_vehicle_status( 65 | basic_vehicle_status, vehicle_status.gpsPosition 66 | ) 67 | msg = f"Missing basic vehicle status data: {basic_vehicle_status}. We'll mark this poll as failed" 68 | raise MqttGatewayException(msg) 69 | 70 | def __on_basic_vehicle_status( 71 | self, basic_vehicle_status: BasicVehicleStatus, gps_position: GpsPosition | None 72 | ) -> VehicleStatusRespProcessingResult: 73 | basic_vehicle_status_result = ( 74 | self.__basic_vehicle_status_publisher.on_basic_vehicle_status( 75 | basic_vehicle_status 76 | ) 77 | ) 78 | 79 | if gps_position: 80 | self.__on_gps_position(basic_vehicle_status_result, gps_position) 81 | 82 | self._publish( 83 | topic=mqtt_topics.REFRESH_LAST_VEHICLE_STATE, 84 | value=datetime.datetime.now(), 85 | ) 86 | 87 | return VehicleStatusRespProcessingResult( 88 | hv_battery_active_from_car=basic_vehicle_status_result.hv_battery_active_from_car, 89 | remote_ac_running=basic_vehicle_status_result.remote_ac_running, 90 | remote_heated_seats_front_left_level=basic_vehicle_status_result.remote_heated_seats_front_left_level, 91 | remote_heated_seats_front_right_level=basic_vehicle_status_result.remote_heated_seats_front_right_level, 92 | raw_soc=basic_vehicle_status_result.raw_soc, 93 | fuel_range_elec=basic_vehicle_status_result.fuel_rage_elec, 94 | ) 95 | 96 | def __on_gps_position( 97 | self, 98 | basic_vehicle_status_result: BasicVehicleStatusProcessingResult, 99 | gps_position: GpsPosition, 100 | ) -> None: 101 | gps_position_result = self.__gps_position_publisher.on_gps_position( 102 | gps_position 103 | ) 104 | self.__fuse_data(basic_vehicle_status_result, gps_position_result) 105 | 106 | def __fuse_data( 107 | self, 108 | basic_vehicle_status_result: BasicVehicleStatusProcessingResult, 109 | gps_position_result: GpsPositionProcessingResult, 110 | ) -> None: 111 | gps_speed = gps_position_result.speed 112 | is_parked = basic_vehicle_status_result.is_parked 113 | if gps_speed is not None and is_parked: 114 | gps_speed = 0.0 115 | 116 | self._publish(topic=mqtt_topics.LOCATION_SPEED, value=gps_speed) 117 | -------------------------------------------------------------------------------- /src/status_publisher/vehicle_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import asdict 4 | import json 5 | import logging 6 | 7 | import mqtt_topics 8 | from status_publisher import VehicleDataPublisher 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | 13 | class VehicleInfoPublisher(VehicleDataPublisher): 14 | def publish(self) -> None: 15 | LOG.info("Publishing vehicle info to MQTT") 16 | self._transform_and_publish( 17 | topic=mqtt_topics.INTERNAL_CONFIGURATION_RAW, 18 | value=self._vehicle_info.configuration, 19 | transform=lambda c: json.dumps([asdict(x) for x in c]), 20 | ) 21 | self._publish( 22 | topic=mqtt_topics.INFO_BRAND, 23 | value=self._vehicle_info.brand, 24 | ) 25 | self._publish( 26 | topic=mqtt_topics.INFO_MODEL, 27 | value=self._vehicle_info.model, 28 | ) 29 | self._publish(topic=mqtt_topics.INFO_YEAR, value=self._vehicle_info.model_year) 30 | self._publish( 31 | topic=mqtt_topics.INFO_SERIES, 32 | value=self._vehicle_info.series, 33 | ) 34 | self._publish( 35 | topic=mqtt_topics.INFO_COLOR, 36 | value=self._vehicle_info.color, 37 | ) 38 | for c in self._vehicle_info.configuration: 39 | property_value = c.itemValue 40 | if property_value is None: 41 | continue 42 | if property_name := c.itemName: 43 | property_name_topic = ( 44 | f"{mqtt_topics.INFO_CONFIGURATION}/{property_name}" 45 | ) 46 | self._publish( 47 | topic=property_name_topic, 48 | value=property_value, 49 | ) 50 | if property_code := c.itemCode: 51 | property_code_topic = ( 52 | f"{mqtt_topics.INFO_CONFIGURATION}/{property_code}" 53 | ) 54 | self._publish( 55 | topic=property_code_topic, 56 | value=property_value, 57 | ) 58 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import UTC, datetime, timedelta 4 | from typing import TYPE_CHECKING, TypeVar 5 | 6 | from saic_ismart_client_ng.api.schema import GpsStatus 7 | 8 | if TYPE_CHECKING: 9 | from saic_ismart_client_ng.api.vehicle import VehicleStatusResp 10 | 11 | Numeric = TypeVar("Numeric", int, float) 12 | 13 | 14 | def value_in_range( 15 | value: Numeric, 16 | min_value: Numeric, 17 | max_value: Numeric, 18 | is_max_excl: bool = True, 19 | ) -> bool: 20 | if value is None: 21 | return False 22 | if is_max_excl: 23 | return min_value <= value < max_value 24 | return min_value <= value <= max_value 25 | 26 | 27 | def is_valid_temperature(value: Numeric) -> bool: 28 | return value_in_range(value, -127, 127) and value != 87 29 | 30 | 31 | def get_update_timestamp(vehicle_status: VehicleStatusResp) -> datetime: 32 | vehicle_status_time = datetime.fromtimestamp(vehicle_status.statusTime or 0, tz=UTC) 33 | now_time = datetime.now(tz=UTC) 34 | # Do not use GPS data if it is not available 35 | if vehicle_status.gpsPosition and vehicle_status.gpsPosition.gps_status_decoded in [ 36 | GpsStatus.FIX_2D, 37 | GpsStatus.FIX_3d, 38 | ]: 39 | gps_time = datetime.fromtimestamp( 40 | vehicle_status.gpsPosition.timeStamp or 0, tz=UTC 41 | ) 42 | else: 43 | gps_time = datetime.fromtimestamp(0, tz=UTC) 44 | vehicle_status_drift = abs(now_time - vehicle_status_time) 45 | gps_time_drift = abs(now_time - gps_time) 46 | reference_drift = min(gps_time_drift, vehicle_status_drift) 47 | reference_time = ( 48 | gps_time if gps_time_drift < vehicle_status_drift else vehicle_status_time 49 | ) 50 | if reference_drift < timedelta(minutes=15): 51 | return reference_time 52 | return now_time 53 | 54 | 55 | def datetime_to_str(dt: datetime) -> str: 56 | return datetime.astimezone(dt, tz=UTC).isoformat() 57 | 58 | 59 | def int_to_bool(value: int) -> bool: 60 | return value > 0 61 | 62 | 63 | def to_remote_climate(rmt_htd_rr_wnd_st: int) -> str: 64 | match rmt_htd_rr_wnd_st: 65 | case 0: 66 | return "off" 67 | case 1: 68 | return "blowingonly" 69 | case 2: 70 | return "on" 71 | case 5: 72 | return "front" 73 | 74 | return f"unknown ({rmt_htd_rr_wnd_st})" 75 | -------------------------------------------------------------------------------- /src/vehicle_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Final 5 | 6 | from exceptions import MqttGatewayException 7 | 8 | if TYPE_CHECKING: 9 | from saic_ismart_client_ng.api.vehicle import VehicleModelConfiguration, VinInfo 10 | 11 | LOG = logging.getLogger(__name__) 12 | 13 | 14 | class VehicleInfo: 15 | def __init__( 16 | self, 17 | vin_info: VinInfo, 18 | custom_battery_capacity: float | None, 19 | ) -> None: 20 | if not vin_info.vin: 21 | raise MqttGatewayException("Could not handle a car without a vin") 22 | self.vin: Final[str] = vin_info.vin 23 | self.configuration: Final[list[VehicleModelConfiguration]] = ( 24 | vin_info.vehicleModelConfiguration or [] 25 | ) 26 | self.brand: Final[str] = str(vin_info.brandName or "").strip() 27 | self.model: Final[str] = str(vin_info.modelName or "").strip().upper() 28 | self.model_year: Final[str] = str(vin_info.modelYear or "").strip() 29 | self.series: Final[str] = str(vin_info.series or "").strip().upper() 30 | self.color: Final[str] = str(vin_info.colorName or "").strip() 31 | self.properties: Final[dict[str, dict[str, str | None]]] = ( 32 | self.__properties_from_configuration(self.configuration) 33 | ) 34 | self.__custom_battery_capacity: float | None = custom_battery_capacity 35 | 36 | @staticmethod 37 | def __properties_from_configuration( 38 | configuration: list[VehicleModelConfiguration], 39 | ) -> dict[str, dict[str, str | None]]: 40 | properties = {} 41 | for c in configuration: 42 | property_name = c.itemName 43 | property_code = c.itemCode 44 | property_value = c.itemValue 45 | if property_name is not None: 46 | properties[property_name] = { 47 | "name": property_name, 48 | "code": property_code, 49 | "value": property_value, 50 | } 51 | if property_code is not None: 52 | properties[property_code] = { 53 | "name": property_name, 54 | "code": property_code, 55 | "value": property_value, 56 | } 57 | return properties 58 | 59 | def get_ac_temperature_idx(self, remote_ac_temperature: int) -> int: 60 | if self.series.startswith("EH32"): 61 | return 3 + remote_ac_temperature - self.min_ac_temperature 62 | return 2 + remote_ac_temperature - self.min_ac_temperature 63 | 64 | @property 65 | def min_ac_temperature(self) -> int: 66 | if self.series.startswith("EH32"): 67 | return 17 68 | return 16 69 | 70 | @property 71 | def max_ac_temperature(self) -> int: 72 | if self.series.startswith("EH32"): 73 | return 33 74 | return 28 75 | 76 | def __get_property_value(self, property_name: str) -> str | None: 77 | if property_name in self.properties: 78 | pdict = self.properties[property_name] 79 | if pdict is not None and isinstance(pdict, dict) and "value" in pdict: 80 | return pdict["value"] 81 | return None 82 | 83 | @property 84 | def is_ev(self) -> bool: 85 | return not self.series.startswith("ZP22") 86 | 87 | @property 88 | def has_fossil_fuel(self) -> bool: 89 | return not self.is_ev 90 | 91 | @property 92 | def has_sunroof(self) -> bool: 93 | return self.__get_property_value("Sunroof") != "0" 94 | 95 | @property 96 | def has_on_off_heated_seats(self) -> bool: 97 | return self.__get_property_value("HeatedSeat") == "2" 98 | 99 | @property 100 | def has_level_heated_seats(self) -> bool: 101 | return self.__get_property_value("HeatedSeat") == "1" 102 | 103 | @property 104 | def has_heated_seats(self) -> bool: 105 | return self.has_level_heated_seats or self.has_on_off_heated_seats 106 | 107 | @property 108 | def supports_target_soc(self) -> bool: 109 | return self.__get_property_value("Battery") == "1" 110 | 111 | @property 112 | def real_battery_capacity(self) -> float | None: 113 | if ( 114 | self.__custom_battery_capacity is not None 115 | and self.__custom_battery_capacity > 0 116 | ): 117 | return float(self.__custom_battery_capacity) 118 | if self.series.startswith("EH32"): 119 | return self.__mg4_real_battery_capacity() 120 | if self.series.startswith("EP2"): 121 | return self.__mg5_real_battery_capacity() 122 | # Model: MG ZS EV 2021 123 | if self.series.startswith("ZS EV"): 124 | return self.__zs_ev_real_battery_capacity() 125 | LOG.warning( 126 | f"Unknown battery capacity for car series='{self.series}' and model='{self.model}'. " 127 | "Please file an issue to improve data accuracy" 128 | ) 129 | return None 130 | 131 | def __mg4_real_battery_capacity(self) -> float | None: 132 | # MG4 high trim level 133 | if self.series.startswith("EH32 S"): 134 | if self.model.startswith("EH32 X3"): 135 | # MG4 Trophy Extended Range 136 | return 77.0 137 | if self.supports_target_soc: 138 | # MG4 high trim level with NMC battery 139 | return 64.0 140 | # MG4 High trim level with LFP battery 141 | return 51.0 142 | # MG4 low trim level 143 | # Note: EH32 X/ is used for the 2023 MY with both NMC and LFP batter chem 144 | if self.series.startswith("EH32 L"): 145 | if self.supports_target_soc: 146 | # MG4 low trim level with NMC battery 147 | return 64.0 148 | # MG4 low trim level with LFP battery 149 | return 51.0 150 | return None 151 | 152 | def __mg5_real_battery_capacity(self) -> float | None: 153 | # Model: MG5 Electric, variant MG5 SR Comfort 154 | if self.series.startswith("EP2CP3"): 155 | return 50.3 156 | # Model: MG5 Electric, variant MG5 MR Luxury 157 | if self.series.startswith("EP2DP3"): 158 | return 61.1 159 | return None 160 | 161 | def __zs_ev_real_battery_capacity(self) -> float | None: 162 | if self.supports_target_soc: 163 | # Long Range with NMC battery 164 | return 68.3 165 | # Standard Range with LFP battery 166 | return 49.0 167 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SAIC-iSmart-API/saic-python-mqtt-gateway/84f96c7c0040ac77be40d4beb2a8cedc9356829d/tests/__init__.py -------------------------------------------------------------------------------- /tests/common_mocks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | 5 | from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus 6 | from saic_ismart_client_ng.api.vehicle import VehicleStatusResp 7 | from saic_ismart_client_ng.api.vehicle.schema import BasicVehicleStatus 8 | from saic_ismart_client_ng.api.vehicle_charging import ChrgMgmtDataResp 9 | from saic_ismart_client_ng.api.vehicle_charging.schema import ( 10 | ChrgMgmtData, 11 | RvsChargeStatus, 12 | ) 13 | 14 | VIN = "vin10000000000000" 15 | 16 | DRIVETRAIN_RUNNING = True 17 | DRIVETRAIN_CHARGING = True 18 | DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE = 42 19 | DRIVETRAIN_MILEAGE = 4000 20 | DRIVETRAIN_RANGE_BMS = 250 21 | DRIVETRAIN_RANGE_VEHICLE = 350 22 | DRIVETRAIN_CURRENT = -42 23 | DRIVETRAIN_VOLTAGE = 42 24 | DRIVETRAIN_POWER = -1.764 25 | DRIVETRAIN_SOC_BMS = 96.3 26 | DRIVETRAIN_SOC_VEHICLE = 48 27 | DRIVETRAIN_HYBRID_ELECTRICAL_RANGE = 0 28 | DRIVETRAIN_MILEAGE_OF_DAY = 200 29 | DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE = 5 30 | DRIVETRAIN_SOC_KWH = 42 31 | DRIVETRAIN_CHARGING_TYPE = 1 32 | DRIVETRAIN_CHARGER_CONNECTED = True 33 | DRIVETRAIN_REMAINING_CHARGING_TIME = 0 34 | DRIVETRAIN_LAST_CHARGE_ENDING_POWER = 200 35 | DRIVETRAIN_CHARGING_CABLE_LOCK = 1 36 | REAL_TOTAL_BATTERY_CAPACITY = 64.0 37 | RAW_TOTAL_BATTERY_CAPACITY = 72.5 38 | BATTERY_CAPACITY_CORRECTION_FACTOR = ( 39 | REAL_TOTAL_BATTERY_CAPACITY / RAW_TOTAL_BATTERY_CAPACITY 40 | ) 41 | 42 | CLIMATE_INTERIOR_TEMPERATURE = 22 43 | CLIMATE_EXTERIOR_TEMPERATURE = 18 44 | CLIMATE_REMOTE_CLIMATE_STATE = 2 45 | CLIMATE_BACK_WINDOW_HEAT = 1 46 | 47 | LOCATION_SPEED = 2.0 48 | LOCATION_HEADING = 42 49 | LOCATION_LATITUDE = 48.8584 50 | LOCATION_LONGITUDE = 22.945 51 | LOCATION_ELEVATION = 200 52 | 53 | WINDOWS_DRIVER = False 54 | WINDOWS_PASSENGER = False 55 | WINDOWS_REAR_LEFT = False 56 | WINDOWS_REAR_RIGHT = False 57 | WINDOWS_SUN_ROOF = False 58 | 59 | DOORS_LOCKED = True 60 | DOORS_DRIVER = False 61 | DOORS_PASSENGER = True 62 | DOORS_REAR_LEFT = False 63 | DOORS_REAR_RIGHT = False 64 | DOORS_BONNET = True 65 | DOORS_BOOT = False 66 | 67 | TYRES_FRONT_LEFT_PRESSURE = 2.8 68 | TYRES_FRONT_RIGHT_PRESSURE = 2.8 69 | TYRES_REAR_LEFT_PRESSURE = 2.8 70 | TYRES_REAR_RIGHT_PRESSURE = 2.8 71 | 72 | LIGHTS_MAIN_BEAM = False 73 | LIGHTS_DIPPED_BEAM = False 74 | LIGHTS_SIDE = False 75 | 76 | BMS_CHARGE_STATUS = "CHARGING_1" 77 | 78 | 79 | def get_mock_vehicle_status_resp() -> VehicleStatusResp: 80 | return VehicleStatusResp( 81 | statusTime=int(time.time()), 82 | basicVehicleStatus=BasicVehicleStatus( 83 | engineStatus=1 if DRIVETRAIN_RUNNING else 0, 84 | extendedData1=DRIVETRAIN_SOC_VEHICLE, 85 | extendedData2=1 if DRIVETRAIN_CHARGING else 0, 86 | batteryVoltage=DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE * 10, 87 | mileage=DRIVETRAIN_MILEAGE * 10, 88 | fuelRangeElec=DRIVETRAIN_RANGE_VEHICLE * 10, 89 | interiorTemperature=CLIMATE_INTERIOR_TEMPERATURE, 90 | exteriorTemperature=CLIMATE_EXTERIOR_TEMPERATURE, 91 | remoteClimateStatus=CLIMATE_REMOTE_CLIMATE_STATE, 92 | rmtHtdRrWndSt=CLIMATE_BACK_WINDOW_HEAT, 93 | driverWindow=WINDOWS_DRIVER, 94 | passengerWindow=WINDOWS_PASSENGER, 95 | rearLeftWindow=WINDOWS_REAR_LEFT, 96 | rearRightWindow=WINDOWS_REAR_RIGHT, 97 | sunroofStatus=WINDOWS_SUN_ROOF, 98 | lockStatus=DOORS_LOCKED, 99 | driverDoor=DOORS_DRIVER, 100 | passengerDoor=DOORS_PASSENGER, 101 | rearRightDoor=DOORS_REAR_RIGHT, 102 | rearLeftDoor=DOORS_REAR_LEFT, 103 | bootStatus=DOORS_BOOT, 104 | bonnetStatus=DOORS_BONNET, 105 | frontLeftTyrePressure=int(TYRES_FRONT_LEFT_PRESSURE * 25), 106 | frontRightTyrePressure=int(TYRES_FRONT_RIGHT_PRESSURE * 25), 107 | rearLeftTyrePressure=int(TYRES_REAR_LEFT_PRESSURE * 25), 108 | rearRightTyrePressure=int(TYRES_REAR_RIGHT_PRESSURE * 25), 109 | mainBeamStatus=LIGHTS_MAIN_BEAM, 110 | dippedBeamStatus=LIGHTS_DIPPED_BEAM, 111 | sideLightStatus=LIGHTS_SIDE, 112 | frontLeftSeatHeatLevel=0, 113 | frontRightSeatHeatLevel=1, 114 | ), 115 | gpsPosition=GpsPosition( 116 | gpsStatus=GpsStatus.FIX_3d.value, 117 | timeStamp=42, 118 | wayPoint=GpsPosition.WayPoint( 119 | position=GpsPosition.WayPoint.Position( 120 | latitude=int(LOCATION_LATITUDE * 1000000), 121 | longitude=int(LOCATION_LONGITUDE * 1000000), 122 | altitude=LOCATION_ELEVATION, 123 | ), 124 | heading=LOCATION_HEADING, 125 | hdop=0, 126 | satellites=3, 127 | speed=20, 128 | ), 129 | ), 130 | ) 131 | 132 | 133 | def get_mock_charge_management_data_resp() -> ChrgMgmtDataResp: 134 | return ChrgMgmtDataResp( 135 | chrgMgmtData=ChrgMgmtData( 136 | bmsPackCrntV=0, 137 | bmsPackCrnt=int((DRIVETRAIN_CURRENT + 1000.0) * 20), 138 | bmsPackVol=DRIVETRAIN_VOLTAGE * 4, 139 | bmsPackSOCDsp=int(DRIVETRAIN_SOC_BMS * 10.0), 140 | bmsEstdElecRng=int(DRIVETRAIN_HYBRID_ELECTRICAL_RANGE * 10.0), 141 | ccuEleccLckCtrlDspCmd=1, 142 | bmsChrgSts=1 if DRIVETRAIN_CHARGING else 0, 143 | ), 144 | rvsChargeStatus=RvsChargeStatus( 145 | mileageOfDay=int(DRIVETRAIN_MILEAGE_OF_DAY * 10.0), 146 | mileageSinceLastCharge=int(DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE * 10.0), 147 | realtimePower=int( 148 | (DRIVETRAIN_SOC_KWH / BATTERY_CAPACITY_CORRECTION_FACTOR) * 10 149 | ), 150 | chargingType=DRIVETRAIN_CHARGING_TYPE, 151 | chargingGunState=DRIVETRAIN_CHARGER_CONNECTED, 152 | lastChargeEndingPower=int( 153 | ( 154 | DRIVETRAIN_LAST_CHARGE_ENDING_POWER 155 | / BATTERY_CAPACITY_CORRECTION_FACTOR 156 | ) 157 | * 10.0 158 | ), 159 | totalBatteryCapacity=int(RAW_TOTAL_BATTERY_CAPACITY * 10.0), 160 | fuelRangeElec=int(DRIVETRAIN_RANGE_BMS * 10.0), 161 | ), 162 | ) 163 | -------------------------------------------------------------------------------- /tests/mocks/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Any, override 5 | 6 | from publisher.log_publisher import ConsolePublisher 7 | 8 | if TYPE_CHECKING: 9 | from configuration import Configuration 10 | 11 | LOG = logging.getLogger(__name__) 12 | 13 | 14 | class MessageCapturingConsolePublisher(ConsolePublisher): 15 | def __init__(self, configuration: Configuration) -> None: 16 | super().__init__(configuration) 17 | self.map: dict[str, Any] = {} 18 | 19 | @override 20 | def internal_publish(self, key: str, value: Any) -> None: 21 | self.map[key] = value 22 | LOG.debug(f"{key}: {value}") 23 | -------------------------------------------------------------------------------- /tests/test_mqtt_publisher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, override 4 | import unittest 5 | 6 | from configuration import Configuration, TransportProtocol 7 | from publisher.core import MqttCommandListener 8 | from publisher.mqtt_publisher import MqttPublisher 9 | 10 | USER = "me@home.da" 11 | VIN = "vin10000000000000" 12 | DELAY = "42" 13 | MODE = "periodic" 14 | LOCK_STATE = "true" 15 | REAR_WINDOW_HEAT_STATE = "on" 16 | 17 | 18 | class TestMqttPublisher(unittest.IsolatedAsyncioTestCase, MqttCommandListener): 19 | @override 20 | async def on_mqtt_global_command_received( 21 | self, *, topic: str, payload: str 22 | ) -> None: 23 | pass 24 | 25 | @override 26 | async def on_mqtt_command_received( 27 | self, *, vin: str, topic: str, payload: str 28 | ) -> None: 29 | self.received_vin = vin 30 | self.received_payload = payload.strip().lower() 31 | 32 | @override 33 | def setUp(self) -> None: 34 | config = Configuration() 35 | config.mqtt_topic = "saic" 36 | config.saic_user = "user+a#b*c>d$e" 37 | config.mqtt_transport_protocol = TransportProtocol.TCP 38 | self.mqtt_client = MqttPublisher(config) 39 | self.mqtt_client.command_listener = self 40 | self.received_vin = "" 41 | self.received_payload = "" 42 | self.vehicle_base_topic = ( 43 | f"{self.mqtt_client.configuration.mqtt_topic}/{USER}/vehicles/{VIN}" 44 | ) 45 | 46 | def test_special_character_username(self) -> None: 47 | assert self.mqtt_client.get_mqtt_account_prefix() == "saic/user_a_b_c_d_e" 48 | 49 | async def test_update_mode(self) -> None: 50 | topic = "refresh/mode/set" 51 | full_topic = f"{self.vehicle_base_topic}/{topic}" 52 | await self.send_message(full_topic, MODE) 53 | assert self.received_vin == VIN 54 | assert self.received_payload == MODE 55 | 56 | async def test_update_lock_state(self) -> None: 57 | topic = "doors/locked/set" 58 | full_topic = f"{self.vehicle_base_topic}/{topic}" 59 | await self.send_message(full_topic, LOCK_STATE) 60 | assert self.received_vin == VIN 61 | assert self.received_payload == LOCK_STATE 62 | 63 | async def test_update_rear_window_heat_state(self) -> None: 64 | topic = "climate/rearWindowDefrosterHeating/set" 65 | full_topic = f"{self.vehicle_base_topic}/{topic}" 66 | await self.send_message(full_topic, REAR_WINDOW_HEAT_STATE) 67 | assert self.received_vin == VIN 68 | assert self.received_payload == REAR_WINDOW_HEAT_STATE 69 | 70 | async def send_message(self, topic: str, payload: Any) -> None: 71 | await self.mqtt_client.client.on_message("client", topic, payload, 0, {}) 72 | 73 | async def on_charging_detected(self, vin: str) -> None: 74 | pass 75 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | from unittest import TestCase 5 | 6 | from saic_ismart_client_ng.api.schema import GpsPosition, GpsStatus 7 | from saic_ismart_client_ng.api.vehicle import VehicleStatusResp 8 | 9 | from utils import get_update_timestamp 10 | 11 | 12 | class Test(TestCase): 13 | def test_get_update_timestamp_should_return_vehicle_if_closest(self) -> None: 14 | base_ts = datetime.datetime.now(tz=datetime.UTC) 15 | ts_plus_30_s = base_ts + datetime.timedelta(minutes=1) 16 | vehicle_status_resp = VehicleStatusResp( 17 | statusTime=int(base_ts.timestamp()), 18 | gpsPosition=GpsPosition( 19 | gpsStatus=GpsStatus.FIX_3d.value, 20 | timeStamp=int(ts_plus_30_s.timestamp()), 21 | ), 22 | ) 23 | 24 | result = get_update_timestamp(vehicle_status_resp) 25 | 26 | assert int(result.timestamp()) == int(base_ts.timestamp()), ( 27 | "This test should have selected the vehicle timestamp" 28 | ) 29 | 30 | assert result <= datetime.datetime.now(tz=datetime.UTC) 31 | 32 | def test_get_update_timestamp_should_return_gps_if_closest(self) -> None: 33 | base_ts = datetime.datetime.now(tz=datetime.UTC) 34 | ts_plus_30_s = base_ts + datetime.timedelta(minutes=1) 35 | vehicle_status_resp = VehicleStatusResp( 36 | statusTime=int(ts_plus_30_s.timestamp()), 37 | gpsPosition=GpsPosition( 38 | gpsStatus=GpsStatus.FIX_3d.value, 39 | timeStamp=int(base_ts.timestamp()), 40 | ), 41 | ) 42 | 43 | result = get_update_timestamp(vehicle_status_resp) 44 | 45 | assert int(result.timestamp()) == int(base_ts.timestamp()), ( 46 | "This test should have selected the GPS timestamp" 47 | ) 48 | 49 | assert result <= datetime.datetime.now(tz=datetime.UTC) 50 | 51 | def test_get_update_timestamp_should_return_now_if_drift_too_much(self) -> None: 52 | base_ts = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta( 53 | minutes=30 54 | ) 55 | ts_plus_30_s = base_ts + datetime.timedelta(minutes=1) 56 | vehicle_status_resp = VehicleStatusResp( 57 | statusTime=int(ts_plus_30_s.timestamp()), 58 | gpsPosition=GpsPosition( 59 | gpsStatus=GpsStatus.FIX_3d.value, 60 | timeStamp=int(base_ts.timestamp()), 61 | ), 62 | ) 63 | 64 | result = get_update_timestamp(vehicle_status_resp) 65 | 66 | assert int(result.timestamp()) != int(ts_plus_30_s.timestamp()), ( 67 | "This test should have NOT selected the vehicle timestamp" 68 | ) 69 | 70 | assert int(result.timestamp()) != int(base_ts.timestamp()), ( 71 | "This test should have NOT selected the GPS timestamp" 72 | ) 73 | 74 | assert result <= datetime.datetime.now(tz=datetime.UTC) 75 | 76 | def test_get_update_should_return_now_if_no_other_info_is_there(self) -> None: 77 | base_ts = datetime.datetime.now(tz=datetime.UTC) + datetime.timedelta( 78 | minutes=30 79 | ) 80 | ts_plus_30_s = base_ts + datetime.timedelta(minutes=1) 81 | vehicle_status_resp = VehicleStatusResp( 82 | statusTime=None, 83 | gpsPosition=GpsPosition( 84 | gpsStatus=GpsStatus.FIX_3d.value, 85 | timeStamp=None, 86 | ), 87 | ) 88 | 89 | result = get_update_timestamp(vehicle_status_resp) 90 | 91 | assert int(result.timestamp()) != int(ts_plus_30_s.timestamp()), ( 92 | "This test should have NOT selected the vehicle timestamp" 93 | ) 94 | 95 | assert int(result.timestamp()) != int(base_ts.timestamp()), ( 96 | "This test should have NOT selected the GPS timestamp" 97 | ) 98 | 99 | assert result <= datetime.datetime.now(tz=datetime.UTC) 100 | 101 | def test_get_update_should_return_now_if_no_other_info_is_there_v2(self) -> None: 102 | vehicle_status_resp = VehicleStatusResp( 103 | statusTime=None, 104 | ) 105 | 106 | result = get_update_timestamp(vehicle_status_resp) 107 | 108 | assert int(result.timestamp()) is not None, ( 109 | "This test should have returned a timestamp" 110 | ) 111 | 112 | assert result <= datetime.datetime.now(tz=datetime.UTC) 113 | 114 | def test_get_update_should_return_now_if_no_other_info_is_there_v3(self) -> None: 115 | vehicle_status_resp = VehicleStatusResp( 116 | gpsPosition=GpsPosition( 117 | gpsStatus=GpsStatus.FIX_3d.value, 118 | timeStamp=None, 119 | ) 120 | ) 121 | 122 | result = get_update_timestamp(vehicle_status_resp) 123 | 124 | assert int(result.timestamp()) is not None, ( 125 | "This test should have returned a timestamp" 126 | ) 127 | 128 | assert result <= datetime.datetime.now(tz=datetime.UTC) 129 | -------------------------------------------------------------------------------- /tests/test_vehicle_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | import unittest 5 | from unittest.mock import patch 6 | 7 | from apscheduler.schedulers.blocking import BlockingScheduler 8 | from common_mocks import ( 9 | BMS_CHARGE_STATUS, 10 | CLIMATE_EXTERIOR_TEMPERATURE, 11 | CLIMATE_INTERIOR_TEMPERATURE, 12 | DOORS_BONNET, 13 | DOORS_BOOT, 14 | DOORS_DRIVER, 15 | DOORS_LOCKED, 16 | DOORS_PASSENGER, 17 | DOORS_REAR_LEFT, 18 | DOORS_REAR_RIGHT, 19 | DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE, 20 | DRIVETRAIN_CHARGER_CONNECTED, 21 | DRIVETRAIN_CHARGING, 22 | DRIVETRAIN_CHARGING_CABLE_LOCK, 23 | DRIVETRAIN_CHARGING_TYPE, 24 | DRIVETRAIN_CURRENT, 25 | DRIVETRAIN_HYBRID_ELECTRICAL_RANGE, 26 | DRIVETRAIN_LAST_CHARGE_ENDING_POWER, 27 | DRIVETRAIN_MILEAGE, 28 | DRIVETRAIN_MILEAGE_OF_DAY, 29 | DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE, 30 | DRIVETRAIN_POWER, 31 | DRIVETRAIN_REMAINING_CHARGING_TIME, 32 | DRIVETRAIN_RUNNING, 33 | DRIVETRAIN_SOC_KWH, 34 | DRIVETRAIN_VOLTAGE, 35 | LIGHTS_DIPPED_BEAM, 36 | LIGHTS_MAIN_BEAM, 37 | LIGHTS_SIDE, 38 | LOCATION_ELEVATION, 39 | LOCATION_HEADING, 40 | LOCATION_LATITUDE, 41 | LOCATION_LONGITUDE, 42 | LOCATION_SPEED, 43 | REAL_TOTAL_BATTERY_CAPACITY, 44 | TYRES_FRONT_LEFT_PRESSURE, 45 | TYRES_FRONT_RIGHT_PRESSURE, 46 | TYRES_REAR_LEFT_PRESSURE, 47 | TYRES_REAR_RIGHT_PRESSURE, 48 | VIN, 49 | WINDOWS_DRIVER, 50 | WINDOWS_PASSENGER, 51 | WINDOWS_REAR_LEFT, 52 | WINDOWS_REAR_RIGHT, 53 | WINDOWS_SUN_ROOF, 54 | get_mock_charge_management_data_resp, 55 | get_mock_vehicle_status_resp, 56 | ) 57 | from mocks import MessageCapturingConsolePublisher 58 | import pytest 59 | from saic_ismart_client_ng import SaicApi 60 | from saic_ismart_client_ng.api.vehicle.schema import ( 61 | VehicleModelConfiguration, 62 | VinInfo, 63 | ) 64 | from saic_ismart_client_ng.model import SaicApiConfiguration 65 | 66 | from configuration import Configuration 67 | from handlers.relogin import ReloginHandler 68 | from mqtt_gateway import VehicleHandler 69 | import mqtt_topics 70 | from vehicle import VehicleState 71 | from vehicle_info import VehicleInfo 72 | 73 | 74 | class TestVehicleHandler(unittest.IsolatedAsyncioTestCase): 75 | def setUp(self) -> None: 76 | config = Configuration() 77 | config.anonymized_publishing = False 78 | self.saicapi = SaicApi( 79 | configuration=SaicApiConfiguration( 80 | username="aaa@nowhere.org", 81 | password="xxxxxxxxx", # noqa: S106 82 | ), 83 | listener=None, 84 | ) 85 | self.publisher = MessageCapturingConsolePublisher(config) 86 | vin_info = VinInfo() 87 | vin_info.vin = VIN 88 | vin_info.series = "EH32 S" 89 | vin_info.modelName = "MG4 Electric" 90 | vin_info.modelYear = "2022" 91 | vin_info.vehicleModelConfiguration = [ 92 | VehicleModelConfiguration("BATTERY", "BATTERY", "1"), 93 | VehicleModelConfiguration("BType", "Battery", "1"), 94 | ] 95 | vehicle_info = VehicleInfo(vin_info, None) 96 | account_prefix = f"/vehicles/{VIN}" 97 | scheduler = BlockingScheduler() 98 | vehicle_state = VehicleState( 99 | self.publisher, scheduler, account_prefix, vehicle_info 100 | ) 101 | mock_relogin_handler = ReloginHandler( 102 | relogin_relay=30, api=self.saicapi, scheduler=None 103 | ) 104 | self.vehicle_handler = VehicleHandler( 105 | config, 106 | mock_relogin_handler, 107 | self.saicapi, 108 | self.publisher, 109 | vehicle_info, 110 | vehicle_state, 111 | ) 112 | 113 | async def test_update_vehicle_status(self) -> None: 114 | with patch.object( 115 | self.saicapi, "get_vehicle_status" 116 | ) as mock_get_vehicle_status: 117 | mock_get_vehicle_status.return_value = get_mock_vehicle_status_resp() 118 | await self.vehicle_handler.update_vehicle_status() 119 | 120 | self.assert_mqtt_topic( 121 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_RUNNING), 122 | DRIVETRAIN_RUNNING, 123 | ) 124 | self.assert_mqtt_topic( 125 | TestVehicleHandler.get_topic( 126 | mqtt_topics.DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE 127 | ), 128 | DRIVETRAIN_AUXILIARY_BATTERY_VOLTAGE, 129 | ) 130 | self.assert_mqtt_topic( 131 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE), 132 | DRIVETRAIN_MILEAGE, 133 | ) 134 | self.assert_mqtt_topic( 135 | TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_INTERIOR_TEMPERATURE), 136 | CLIMATE_INTERIOR_TEMPERATURE, 137 | ) 138 | self.assert_mqtt_topic( 139 | TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_EXTERIOR_TEMPERATURE), 140 | CLIMATE_EXTERIOR_TEMPERATURE, 141 | ) 142 | self.assert_mqtt_topic( 143 | TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_REMOTE_CLIMATE_STATE), "on" 144 | ) 145 | self.assert_mqtt_topic( 146 | TestVehicleHandler.get_topic(mqtt_topics.CLIMATE_BACK_WINDOW_HEAT), "on" 147 | ) 148 | self.assert_mqtt_topic( 149 | TestVehicleHandler.get_topic(mqtt_topics.LOCATION_SPEED), LOCATION_SPEED 150 | ) 151 | self.assert_mqtt_topic( 152 | TestVehicleHandler.get_topic(mqtt_topics.LOCATION_HEADING), LOCATION_HEADING 153 | ) 154 | self.assert_mqtt_topic( 155 | TestVehicleHandler.get_topic(mqtt_topics.LOCATION_LATITUDE), 156 | LOCATION_LATITUDE, 157 | ) 158 | self.assert_mqtt_topic( 159 | TestVehicleHandler.get_topic(mqtt_topics.LOCATION_LONGITUDE), 160 | LOCATION_LONGITUDE, 161 | ) 162 | self.assert_mqtt_topic( 163 | TestVehicleHandler.get_topic(mqtt_topics.LOCATION_ELEVATION), 164 | LOCATION_ELEVATION, 165 | ) 166 | self.assert_mqtt_topic( 167 | TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_DRIVER), WINDOWS_DRIVER 168 | ) 169 | self.assert_mqtt_topic( 170 | TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_PASSENGER), 171 | WINDOWS_PASSENGER, 172 | ) 173 | self.assert_mqtt_topic( 174 | TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_REAR_LEFT), 175 | WINDOWS_REAR_LEFT, 176 | ) 177 | self.assert_mqtt_topic( 178 | TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_REAR_RIGHT), 179 | WINDOWS_REAR_RIGHT, 180 | ) 181 | self.assert_mqtt_topic( 182 | TestVehicleHandler.get_topic(mqtt_topics.WINDOWS_SUN_ROOF), WINDOWS_SUN_ROOF 183 | ) 184 | self.assert_mqtt_topic( 185 | TestVehicleHandler.get_topic(mqtt_topics.DOORS_LOCKED), DOORS_LOCKED 186 | ) 187 | self.assert_mqtt_topic( 188 | TestVehicleHandler.get_topic(mqtt_topics.DOORS_DRIVER), DOORS_DRIVER 189 | ) 190 | self.assert_mqtt_topic( 191 | TestVehicleHandler.get_topic(mqtt_topics.DOORS_PASSENGER), DOORS_PASSENGER 192 | ) 193 | self.assert_mqtt_topic( 194 | TestVehicleHandler.get_topic(mqtt_topics.DOORS_REAR_LEFT), DOORS_REAR_LEFT 195 | ) 196 | self.assert_mqtt_topic( 197 | TestVehicleHandler.get_topic(mqtt_topics.DOORS_REAR_RIGHT), DOORS_REAR_RIGHT 198 | ) 199 | self.assert_mqtt_topic( 200 | TestVehicleHandler.get_topic(mqtt_topics.DOORS_BONNET), DOORS_BONNET 201 | ) 202 | self.assert_mqtt_topic( 203 | TestVehicleHandler.get_topic(mqtt_topics.DOORS_BOOT), DOORS_BOOT 204 | ) 205 | self.assert_mqtt_topic( 206 | TestVehicleHandler.get_topic(mqtt_topics.TYRES_FRONT_LEFT_PRESSURE), 207 | TYRES_FRONT_LEFT_PRESSURE, 208 | ) 209 | self.assert_mqtt_topic( 210 | TestVehicleHandler.get_topic(mqtt_topics.TYRES_FRONT_RIGHT_PRESSURE), 211 | TYRES_FRONT_RIGHT_PRESSURE, 212 | ) 213 | self.assert_mqtt_topic( 214 | TestVehicleHandler.get_topic(mqtt_topics.TYRES_REAR_LEFT_PRESSURE), 215 | TYRES_REAR_LEFT_PRESSURE, 216 | ) 217 | self.assert_mqtt_topic( 218 | TestVehicleHandler.get_topic(mqtt_topics.TYRES_REAR_RIGHT_PRESSURE), 219 | TYRES_REAR_RIGHT_PRESSURE, 220 | ) 221 | self.assert_mqtt_topic( 222 | TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_MAIN_BEAM), LIGHTS_MAIN_BEAM 223 | ) 224 | self.assert_mqtt_topic( 225 | TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_DIPPED_BEAM), 226 | LIGHTS_DIPPED_BEAM, 227 | ) 228 | self.assert_mqtt_topic( 229 | TestVehicleHandler.get_topic(mqtt_topics.LIGHTS_SIDE), LIGHTS_SIDE 230 | ) 231 | expected_topics = { 232 | "/vehicles/vin10000000000000/drivetrain/running", 233 | "/vehicles/vin10000000000000/climate/interiorTemperature", 234 | "/vehicles/vin10000000000000/climate/exteriorTemperature", 235 | "/vehicles/vin10000000000000/drivetrain/auxiliaryBatteryVoltage", 236 | "/vehicles/vin10000000000000/location/heading", 237 | "/vehicles/vin10000000000000/location/latitude", 238 | "/vehicles/vin10000000000000/location/longitude", 239 | "/vehicles/vin10000000000000/location/elevation", 240 | "/vehicles/vin10000000000000/location/position", 241 | "/vehicles/vin10000000000000/location/speed", 242 | "/vehicles/vin10000000000000/windows/driver", 243 | "/vehicles/vin10000000000000/windows/passenger", 244 | "/vehicles/vin10000000000000/windows/rearLeft", 245 | "/vehicles/vin10000000000000/windows/rearRight", 246 | "/vehicles/vin10000000000000/windows/sunRoof", 247 | "/vehicles/vin10000000000000/doors/locked", 248 | "/vehicles/vin10000000000000/doors/driver", 249 | "/vehicles/vin10000000000000/doors/passenger", 250 | "/vehicles/vin10000000000000/doors/rearLeft", 251 | "/vehicles/vin10000000000000/doors/rearRight", 252 | "/vehicles/vin10000000000000/doors/bonnet", 253 | "/vehicles/vin10000000000000/doors/boot", 254 | "/vehicles/vin10000000000000/tyres/frontLeftPressure", 255 | "/vehicles/vin10000000000000/tyres/frontRightPressure", 256 | "/vehicles/vin10000000000000/tyres/rearLeftPressure", 257 | "/vehicles/vin10000000000000/tyres/rearRightPressure", 258 | "/vehicles/vin10000000000000/lights/mainBeam", 259 | "/vehicles/vin10000000000000/lights/dippedBeam", 260 | "/vehicles/vin10000000000000/lights/side", 261 | "/vehicles/vin10000000000000/climate/remoteClimateState", 262 | "/vehicles/vin10000000000000/climate/rearWindowDefrosterHeating", 263 | "/vehicles/vin10000000000000/climate/heatedSeatsFrontLeftLevel", 264 | "/vehicles/vin10000000000000/climate/heatedSeatsFrontRightLevel", 265 | "/vehicles/vin10000000000000/drivetrain/mileage", 266 | "/vehicles/vin10000000000000/refresh/lastVehicleState", 267 | } 268 | assert expected_topics == set(self.publisher.map.keys()) 269 | 270 | async def test_update_charge_status(self) -> None: 271 | with patch.object( 272 | self.saicapi, "get_vehicle_charging_management_data" 273 | ) as mock_get_vehicle_charging_management_data: 274 | mock_get_vehicle_charging_management_data.return_value = ( 275 | get_mock_charge_management_data_resp() 276 | ) 277 | await self.vehicle_handler.update_charge_status() 278 | 279 | self.assert_mqtt_topic( 280 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING), 281 | DRIVETRAIN_CHARGING, 282 | ) 283 | self.assert_mqtt_topic( 284 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CURRENT), 285 | DRIVETRAIN_CURRENT, 286 | ) 287 | self.assert_mqtt_topic( 288 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_VOLTAGE), 289 | DRIVETRAIN_VOLTAGE, 290 | ) 291 | self.assert_mqtt_topic( 292 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_POWER), DRIVETRAIN_POWER 293 | ) 294 | self.assert_mqtt_topic( 295 | TestVehicleHandler.get_topic( 296 | mqtt_topics.DRIVETRAIN_HYBRID_ELECTRICAL_RANGE 297 | ), 298 | DRIVETRAIN_HYBRID_ELECTRICAL_RANGE, 299 | ) 300 | self.assert_mqtt_topic( 301 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_MILEAGE_OF_DAY), 302 | DRIVETRAIN_MILEAGE_OF_DAY, 303 | ) 304 | self.assert_mqtt_topic( 305 | TestVehicleHandler.get_topic( 306 | mqtt_topics.DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE 307 | ), 308 | DRIVETRAIN_MILEAGE_SINCE_LAST_CHARGE, 309 | ) 310 | self.assert_mqtt_topic( 311 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_SOC_KWH), 312 | DRIVETRAIN_SOC_KWH, 313 | ) 314 | self.assert_mqtt_topic( 315 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_TYPE), 316 | DRIVETRAIN_CHARGING_TYPE, 317 | ) 318 | self.assert_mqtt_topic( 319 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGER_CONNECTED), 320 | DRIVETRAIN_CHARGER_CONNECTED, 321 | ) 322 | self.assert_mqtt_topic( 323 | TestVehicleHandler.get_topic( 324 | mqtt_topics.DRIVETRAIN_REMAINING_CHARGING_TIME 325 | ), 326 | DRIVETRAIN_REMAINING_CHARGING_TIME, 327 | ) 328 | self.assert_mqtt_topic( 329 | TestVehicleHandler.get_topic( 330 | mqtt_topics.DRIVETRAIN_LAST_CHARGE_ENDING_POWER 331 | ), 332 | DRIVETRAIN_LAST_CHARGE_ENDING_POWER, 333 | ) 334 | self.assert_mqtt_topic( 335 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_TOTAL_BATTERY_CAPACITY), 336 | REAL_TOTAL_BATTERY_CAPACITY, 337 | ) 338 | self.assert_mqtt_topic( 339 | TestVehicleHandler.get_topic(mqtt_topics.DRIVETRAIN_CHARGING_CABLE_LOCK), 340 | DRIVETRAIN_CHARGING_CABLE_LOCK, 341 | ) 342 | self.assert_mqtt_topic( 343 | TestVehicleHandler.get_topic(mqtt_topics.BMS_CHARGE_STATUS), 344 | BMS_CHARGE_STATUS, 345 | ) 346 | expected_topics = { 347 | "/vehicles/vin10000000000000/drivetrain/charging", 348 | "/vehicles/vin10000000000000/drivetrain/current", 349 | "/vehicles/vin10000000000000/drivetrain/voltage", 350 | "/vehicles/vin10000000000000/drivetrain/power", 351 | "/vehicles/vin10000000000000/obc/current", 352 | "/vehicles/vin10000000000000/obc/voltage", 353 | "/vehicles/vin10000000000000/drivetrain/hybrid_electrical_range", 354 | "/vehicles/vin10000000000000/drivetrain/mileageOfTheDay", 355 | "/vehicles/vin10000000000000/drivetrain/mileageSinceLastCharge", 356 | "/vehicles/vin10000000000000/drivetrain/chargingType", 357 | "/vehicles/vin10000000000000/drivetrain/chargerConnected", 358 | "/vehicles/vin10000000000000/drivetrain/remainingChargingTime", 359 | "/vehicles/vin10000000000000/refresh/lastChargeState", 360 | "/vehicles/vin10000000000000/drivetrain/totalBatteryCapacity", 361 | "/vehicles/vin10000000000000/drivetrain/soc_kwh", 362 | "/vehicles/vin10000000000000/drivetrain/lastChargeEndingPower", 363 | "/vehicles/vin10000000000000/drivetrain/batteryHeating", 364 | "/vehicles/vin10000000000000/drivetrain/chargingCableLock", 365 | "/vehicles/vin10000000000000/bms/chargeStatus", 366 | "/vehicles/vin10000000000000/refresh/period/charging", 367 | } 368 | assert expected_topics == set(self.publisher.map.keys()) 369 | 370 | # Note: The closer the decorator is to the function definition, the earlier it is in the parameter list 371 | async def test_should_not_publish_same_data_twice(self) -> None: 372 | with patch.object( 373 | self.saicapi, "get_vehicle_charging_management_data" 374 | ) as mock_get_vehicle_charging_management_data: 375 | mock_get_vehicle_charging_management_data.return_value = ( 376 | get_mock_charge_management_data_resp() 377 | ) 378 | with patch.object( 379 | self.saicapi, "get_vehicle_status" 380 | ) as mock_get_vehicle_status: 381 | mock_get_vehicle_status.return_value = get_mock_vehicle_status_resp() 382 | 383 | await self.vehicle_handler.update_vehicle_status() 384 | vehicle_mqtt_map = dict(self.publisher.map.items()) 385 | self.publisher.map.clear() 386 | 387 | await self.vehicle_handler.update_charge_status() 388 | charge_data_mqtt_map = dict(self.publisher.map.items()) 389 | self.publisher.map.clear() 390 | 391 | common_data = set(vehicle_mqtt_map.keys()).intersection( 392 | set(charge_data_mqtt_map.keys()) 393 | ) 394 | 395 | assert len(common_data) == 0, ( 396 | f"Some topics have been published from both car state and BMS state: {common_data!s}" 397 | ) 398 | 399 | def assert_mqtt_topic(self, topic: str, value: Any) -> None: 400 | mqtt_map = self.publisher.map 401 | if topic in mqtt_map: 402 | if isinstance(value, float) or isinstance(mqtt_map[topic], float): 403 | assert value == pytest.approx(mqtt_map[topic], abs=0.1) 404 | else: 405 | assert value == mqtt_map[topic] 406 | else: 407 | self.fail(f"MQTT map does not contain topic {topic}") 408 | 409 | @staticmethod 410 | def get_topic(sub_topic: str) -> str: 411 | return f"/vehicles/{VIN}/{sub_topic}" 412 | -------------------------------------------------------------------------------- /tests/test_vehicle_state.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | import unittest 5 | 6 | from apscheduler.schedulers.blocking import BlockingScheduler 7 | from common_mocks import ( 8 | DRIVETRAIN_RANGE_BMS, 9 | DRIVETRAIN_RANGE_VEHICLE, 10 | DRIVETRAIN_SOC_BMS, 11 | DRIVETRAIN_SOC_VEHICLE, 12 | VIN, 13 | get_mock_charge_management_data_resp, 14 | get_mock_vehicle_status_resp, 15 | ) 16 | from mocks import MessageCapturingConsolePublisher 17 | import pytest 18 | from saic_ismart_client_ng.api.vehicle.schema import VinInfo 19 | 20 | from configuration import Configuration 21 | import mqtt_topics 22 | from vehicle import VehicleState 23 | from vehicle_info import VehicleInfo 24 | 25 | 26 | class TestVehicleState(unittest.IsolatedAsyncioTestCase): 27 | def setUp(self) -> None: 28 | config = Configuration() 29 | config.anonymized_publishing = False 30 | self.publisher = MessageCapturingConsolePublisher(config) 31 | vin_info = VinInfo() 32 | vin_info.vin = VIN 33 | vehicle_info = VehicleInfo(vin_info, None) 34 | account_prefix = f"/vehicles/{VIN}" 35 | scheduler = BlockingScheduler() 36 | self.vehicle_state = VehicleState( 37 | self.publisher, scheduler, account_prefix, vehicle_info 38 | ) 39 | 40 | async def test_update_soc_with_no_bms_data(self) -> None: 41 | vehicle_status_resp = get_mock_vehicle_status_resp() 42 | result = self.vehicle_state.handle_vehicle_status(vehicle_status_resp) 43 | 44 | # Reset topics since we are only asserting the differences 45 | self.publisher.map.clear() 46 | 47 | self.vehicle_state.update_data_conflicting_in_vehicle_and_bms( 48 | vehicle_status=result, charge_status=None 49 | ) 50 | self.assert_mqtt_topic( 51 | TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_SOC), 52 | DRIVETRAIN_SOC_VEHICLE, 53 | ) 54 | self.assert_mqtt_topic( 55 | TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_RANGE), 56 | DRIVETRAIN_RANGE_VEHICLE, 57 | ) 58 | self.assert_mqtt_topic( 59 | TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE), True 60 | ) 61 | expected_topics = { 62 | "/vehicles/vin10000000000000/drivetrain/hvBatteryActive", 63 | "/vehicles/vin10000000000000/refresh/lastActivity", 64 | "/vehicles/vin10000000000000/drivetrain/soc", 65 | "/vehicles/vin10000000000000/drivetrain/range", 66 | } 67 | assert expected_topics == set(self.publisher.map.keys()) 68 | 69 | async def test_update_soc_with_bms_data(self) -> None: 70 | vehicle_status_resp = get_mock_vehicle_status_resp() 71 | chrg_mgmt_data_resp = get_mock_charge_management_data_resp() 72 | vehicle_status_resp_result = self.vehicle_state.handle_vehicle_status( 73 | vehicle_status_resp 74 | ) 75 | chrg_mgmt_data_resp_result = self.vehicle_state.handle_charge_status( 76 | chrg_mgmt_data_resp 77 | ) 78 | 79 | # Reset topics since we are only asserting the differences 80 | self.publisher.map.clear() 81 | 82 | self.vehicle_state.update_data_conflicting_in_vehicle_and_bms( 83 | vehicle_status=vehicle_status_resp_result, 84 | charge_status=chrg_mgmt_data_resp_result, 85 | ) 86 | self.assert_mqtt_topic( 87 | TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_SOC), DRIVETRAIN_SOC_BMS 88 | ) 89 | self.assert_mqtt_topic( 90 | TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_RANGE), 91 | DRIVETRAIN_RANGE_BMS, 92 | ) 93 | self.assert_mqtt_topic( 94 | TestVehicleState.get_topic(mqtt_topics.DRIVETRAIN_HV_BATTERY_ACTIVE), True 95 | ) 96 | expected_topics = { 97 | "/vehicles/vin10000000000000/drivetrain/hvBatteryActive", 98 | "/vehicles/vin10000000000000/refresh/lastActivity", 99 | "/vehicles/vin10000000000000/drivetrain/soc", 100 | "/vehicles/vin10000000000000/drivetrain/range", 101 | } 102 | assert expected_topics == set(self.publisher.map.keys()) 103 | 104 | def assert_mqtt_topic(self, topic: str, value: Any) -> None: 105 | mqtt_map = self.publisher.map 106 | if topic in mqtt_map: 107 | if isinstance(value, float) or isinstance(mqtt_map[topic], float): 108 | assert value == pytest.approx(mqtt_map[topic], abs=0.1) 109 | else: 110 | assert value == mqtt_map[topic] 111 | else: 112 | self.fail(f"MQTT map does not contain topic {topic}") 113 | 114 | @staticmethod 115 | def get_topic(sub_topic: str) -> str: 116 | return f"/vehicles/{VIN}/{sub_topic}" 117 | --------------------------------------------------------------------------------