├── .nvmrc ├── .dockerignore ├── .gitignore ├── images └── lovelace.png ├── .babelrc ├── Dockerfile ├── .eslintrc.yml ├── src ├── logger.js ├── vehicle.js ├── diagnostic.js ├── measurement.js ├── commands.js ├── index.js └── mqtt.js ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ ├── ci.yml │ └── codeql-analysis.yml ├── LICENSE ├── package.json ├── test ├── vehicle.spec.js ├── diagnostic.spec.js ├── diagnostic.sample.json ├── mqtt.spec.js └── vehicles.sample.json ├── README.md └── HA-MQTT.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/hydrogen -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | test 3 | node_modules -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .nyc_output/ 3 | node_modules/ 4 | 5 | onstar2mqtt.env 6 | -------------------------------------------------------------------------------- /images/lovelace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelwoods/onstar2mqtt/HEAD/images/lovelace.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"], 3 | "plugins": [ 4 | "@babel/plugin-syntax-class-properties" 5 | ] 6 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | RUN mkdir /app 4 | WORKDIR /app 5 | 6 | COPY ["package.json", "/app/"] 7 | COPY ["package-lock.json", "/app/"] 8 | RUN npm ci --omit=dev --no-fund 9 | 10 | COPY ["src", "/app/src"] 11 | 12 | ENTRYPOINT ["npm", "run", "start"] 13 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | node: true 3 | browser: false 4 | commonjs: true 5 | es6: true 6 | mocha: true 7 | extends: 8 | - eslint:recommended 9 | parser: "@babel/eslint-parser" 10 | parserOptions: 11 | babelOptions: 12 | configFile: './.babelrc' 13 | ecmaVersion: 2018 14 | plugins: 15 | - "@babel" 16 | rules: {} 17 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const _ = require('lodash'); 3 | 4 | const logger = winston.createLogger({ 5 | level: _.get(process, 'env.LOG_LEVEL', 'info'), 6 | format: winston.format.simple(), 7 | // format: winston.format.json(), 8 | transports: [new winston.transports.Console({stderrLevels: ['error']})] 9 | }) 10 | 11 | 12 | module.exports = logger; -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | # Maintain dependencies for GitHub Actions 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "monthly" 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | groups: 14 | development-dependencies: 15 | dependency-type: "development" 16 | production-dependencies: 17 | dependency-type: "production" 18 | exclude-patterns: 19 | - "onstarjs*" 20 | onstarjs: 21 | patterns: 22 | - "onstarjs*" 23 | -------------------------------------------------------------------------------- /src/vehicle.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | class Vehicle { 4 | constructor(vehicle) { 5 | this.make = vehicle.make; 6 | this.model = vehicle.model; 7 | this.vin = vehicle.vin; 8 | this.year = vehicle.year; 9 | 10 | const diagCmd = _.find( 11 | _.get(vehicle, 'commands.command'), 12 | cmd => cmd.name === 'diagnostics' 13 | ); 14 | this.supportedDiagnostics = _.get(diagCmd, 15 | 'commandData.supportedDiagnostics.supportedDiagnostic'); 16 | } 17 | 18 | isSupported(diag) { 19 | return _.includes(this.supportedDiagnostics, diag); 20 | } 21 | 22 | getSupported(diags = []) { 23 | if (diags.length === 0) { 24 | return this.supportedDiagnostics; 25 | } 26 | return _.intersection(this.supportedDiagnostics, diags); 27 | } 28 | 29 | toString() { 30 | return `${this.year} ${this.make} ${this.model}`; 31 | } 32 | } 33 | 34 | module.exports = Vehicle; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Michael Woods 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | jobs: 7 | push_to_registry: 8 | name: release - build, push 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out the repo 12 | uses: actions/checkout@v4 13 | 14 | - name: Docker meta 15 | id: docker_meta 16 | uses: docker/metadata-action@v5 17 | with: 18 | images: michaelwoods/onstar2mqtt 19 | flavor: | 20 | latest=true 21 | tags: | 22 | type=semver,pattern={{version}} 23 | type=semver,pattern={{major}}.{{minor}} 24 | 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@v3.0.0 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v3 29 | - name: Login to DockerHub 30 | uses: docker/login-action@v3 31 | with: 32 | username: ${{ secrets.DOCKER_USERNAME }} 33 | password: ${{ secrets.DOCKER_PASSWORD }} 34 | - name: Push to DockerHub 35 | uses: docker/build-push-action@v6 36 | with: 37 | platforms: linux/amd64,linux/arm64,linux/arm/v7 38 | push: true 39 | tags: ${{ steps.docker_meta.outputs.tags }} 40 | labels: ${{ steps.docker_meta.outputs.labels }} 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "onstar2mqtt", 3 | "version": "1.5.12", 4 | "description": "OnStarJS wrapper for MQTT", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "coverage": "nyc npm test", 8 | "lint": "npx eslint src test", 9 | "start": "node src/index.js", 10 | "test": "mocha" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/michaelwoods/onstar2mqtt.git" 15 | }, 16 | "keywords": [ 17 | "onstar", 18 | "mqtt", 19 | "gm", 20 | "chevrolet", 21 | "homeassistant", 22 | "home-assistant", 23 | "home assistant" 24 | ], 25 | "author": "Michael Woods", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/michaelwoods/onstar2mqtt/issues" 29 | }, 30 | "homepage": "https://github.com/michaelwoods/onstar2mqtt#readme", 31 | "dependencies": { 32 | "async-mqtt": "^2.6.3", 33 | "convert-units": "^2.3.4", 34 | "lodash": "^4.17.21", 35 | "onstarjs": "^2.5.3", 36 | "uuid": "^10.0.0", 37 | "winston": "^3.13.0" 38 | }, 39 | "devDependencies": { 40 | "@babel/eslint-parser": "^7.25.1", 41 | "@babel/eslint-plugin": "^7.25.1", 42 | "@babel/plugin-syntax-class-properties": "^7.12.13", 43 | "@babel/preset-env": "^7.25.4", 44 | "eslint": "^8.51.0", 45 | "eslint-plugin-import": "^2.30.0", 46 | "eslint-plugin-node": "^11.1.0", 47 | "eslint-plugin-promise": "^7.1.0", 48 | "mocha": "^10.7.3", 49 | "nyc": "^17.0.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/vehicle.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const _ = require('lodash'); 3 | 4 | const Vehicle = require('../src/vehicle'); 5 | const apiResponse = require('./vehicles.sample.json'); 6 | 7 | describe('Vehicle', () => { 8 | let v; 9 | beforeEach(() => v = new Vehicle(_.get(apiResponse, 'vehicles.vehicle[0]'))); 10 | 11 | it('should parse a vehicle response', () => { 12 | assert.notStrictEqual(v.year, 2020); 13 | assert.strictEqual(v.make, 'Chevrolet'); 14 | assert.strictEqual(v.model, 'Bolt EV'); 15 | assert.strictEqual(v.vin, 'foobarVIN'); 16 | }); 17 | 18 | it('should return the list of supported diagnostics', () => { 19 | const supported = v.getSupported(); 20 | assert.ok(_.isArray(supported)); 21 | assert.strictEqual(supported.length, 22); 22 | }); 23 | 24 | it('should return common supported and requested diagnostics', () => { 25 | let supported = v.getSupported(['ODOMETER']); 26 | assert.ok(_.isArray(supported)); 27 | assert.strictEqual(supported.length, 1); 28 | 29 | supported = v.getSupported(['ODOMETER', 'foo', 'bar']); 30 | assert.ok(_.isArray(supported)); 31 | assert.strictEqual(supported.length, 1); 32 | 33 | supported = v.getSupported(['foo', 'bar']); 34 | assert.ok(_.isArray(supported)); 35 | assert.strictEqual(supported.length, 0); 36 | }); 37 | 38 | it('should toString() correctly', () => { 39 | assert.strictEqual(v.toString(), '2020 Chevrolet Bolt EV') 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | # build weekly for fresh base docker image 9 | schedule: 10 | - cron: '30 4 * * 1' 11 | 12 | jobs: 13 | test_build_push: 14 | name: ci - test, build, push 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x] 20 | 21 | steps: 22 | - name: Check out the repo 23 | uses: actions/checkout@v4 24 | 25 | - name: Docker meta 26 | id: docker_meta 27 | uses: docker/metadata-action@v5 28 | with: 29 | images: michaelwoods/onstar2mqtt 30 | flavor: | 31 | latest=true 32 | tags: | 33 | type=ref,event=branch 34 | type=schedule,pattern=weekly 35 | 36 | - name: Use Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | - run: npm ci 41 | - run: npm run build --if-present 42 | - run: npm run lint 43 | - run: npm test 44 | 45 | - name: Set up QEMU 46 | uses: docker/setup-qemu-action@v3.0.0 47 | - name: Set up Docker Buildx 48 | uses: docker/setup-buildx-action@v3 49 | - name: Login to DockerHub 50 | if: github.event_name != 'pull_request' 51 | uses: docker/login-action@v3 52 | with: 53 | username: ${{ secrets.DOCKER_USERNAME }} 54 | password: ${{ secrets.DOCKER_PASSWORD }} 55 | - name: Push to DockerHub 56 | uses: docker/build-push-action@v6 57 | with: 58 | platforms: linux/amd64,linux/arm64,linux/arm/v7 59 | push: ${{ github.event_name != 'pull_request' }} 60 | tags: ${{ steps.docker_meta.outputs.tags }} 61 | labels: ${{ steps.docker_meta.outputs.labels }} 62 | -------------------------------------------------------------------------------- /test/diagnostic.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const _ = require('lodash'); 3 | 4 | const { Diagnostic, DiagnosticElement } = require('../src/diagnostic'); 5 | const apiResponse = require('./diagnostic.sample.json'); 6 | 7 | describe('Diagnostics', () => { 8 | let d; 9 | 10 | describe('Diagnostic', () => { 11 | beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[0]'))); 12 | 13 | it('should parse a diagnostic response', () => { 14 | assert.strictEqual(d.name, 'AMBIENT AIR TEMPERATURE'); 15 | assert.strictEqual(d.diagnosticElements.length, 2); 16 | }); 17 | 18 | it('should toString() correctly', () => { 19 | const output = d.toString().trimEnd(); 20 | const lines = output.split(/\r\n|\r|\n/); 21 | assert.strictEqual(lines.length, 3); 22 | assert.strictEqual(lines[0], 'AMBIENT AIR TEMPERATURE:'); 23 | }); 24 | }); 25 | 26 | describe('DiagnosticElement', () => { 27 | beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[8]'))); 28 | it('should parse a diagnostic element', () => { 29 | assert.strictEqual(d.name, 'TIRE PRESSURE'); 30 | assert.ok(_.isArray(d.diagnosticElements)); 31 | assert.strictEqual(d.diagnosticElements.length, 12); 32 | }); 33 | 34 | it('should toString() correctly', () => { 35 | const output = d.toString().trimEnd(); 36 | const lines = output.split(/\r\n|\r|\n/); 37 | assert.strictEqual(lines.length, 13); 38 | assert.strictEqual(lines[0], 'TIRE PRESSURE:'); 39 | assert.strictEqual(lines[1], ' TIRE PRESSURE LF: 240.0kPa'); 40 | }); 41 | 42 | it('should strip non-alpha chars', () => { 43 | assert.strictEqual(DiagnosticElement.convertName('TEMP', '°F'), 'TEMP F'); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/diagnostic.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | const Measurement = require('./measurement'); 4 | 5 | class Diagnostic { 6 | constructor(diagResponse) { 7 | this.name = diagResponse.name; 8 | const validEle = _.filter( 9 | diagResponse.diagnosticElement, 10 | d => _.has(d, 'value') && _.has(d, 'unit') 11 | ); 12 | this.diagnosticElements = _.map(validEle, e => new DiagnosticElement(e)); 13 | const converted = _.map(_.filter(this.diagnosticElements, e => e.isConvertible), 14 | e => DiagnosticElement.convert(e)); 15 | this.diagnosticElements.push(... converted); 16 | } 17 | 18 | hasElements() { 19 | return this.diagnosticElements.length >= 1; 20 | } 21 | 22 | toString() { 23 | let elements = ''; 24 | _.forEach(this.diagnosticElements, e => elements += ` ${e.toString()}\n`) 25 | return `${this.name}:\n` + elements; 26 | } 27 | } 28 | 29 | class DiagnosticElement { 30 | /** 31 | * 32 | * @param {DiagnosticElement} element 33 | */ 34 | static convert(element) { 35 | const {name, unit, value} = element; 36 | const convertedUnit = Measurement.convertUnit(unit); 37 | return new DiagnosticElement({ 38 | name: DiagnosticElement.convertName(name, convertedUnit), 39 | unit: convertedUnit, 40 | value: Measurement.convertValue(value, unit) 41 | }) 42 | } 43 | 44 | static convertName(name, unit) { 45 | return `${name} ${_.replace(_.toUpper(unit), /\W/g, '')}`; 46 | } 47 | 48 | /** 49 | * @param {string} ele.name 50 | * @param {string|number} ele.value 51 | * @param {string} ele.unit 52 | */ 53 | constructor(ele) { 54 | this._name = ele.name; 55 | this.measurement = new Measurement(ele.value, ele.unit); 56 | } 57 | 58 | get name() { 59 | return this._name; 60 | } 61 | 62 | get value() { 63 | return this.measurement.value; 64 | } 65 | 66 | get unit() { 67 | return this.measurement.unit; 68 | } 69 | 70 | get isConvertible() { 71 | return this.measurement.isConvertible; 72 | } 73 | 74 | toString() { 75 | return `${this.name}: ${this.measurement.toString()}`; 76 | } 77 | } 78 | 79 | module.exports = {Diagnostic, DiagnosticElement}; -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '28 1 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v4 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v3 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v3 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # onstar2mqtt 2 | 3 | **NOTE**: This service is no longer functional as the OnStar API has moved to TOTP backed auth. Work is in progress on converting to typescript and using the newer onstarjs2 library that includes TOTP compatibility. 4 | 5 | A service that utilizes the [OnStarJS](https://github.com/samrum/OnStarJS) library to expose OnStar data to MQTT topics. 6 | 7 | The functionality is mostly focused around EVs (specifically the Bolt EV), however PRs for other vehicle types are certainly welcome. 8 | 9 | There is no affiliation with this project and GM, Chevrolet nor OnStar. In fact, it would be nice if they'd even respond to development requests so we wouldn't have to reverse engineer their API. 10 | 11 | ## Running 12 | Collect the following information: 13 | 1. [Generate](https://www.uuidgenerator.net/version4) a v4 uuid for the device ID 14 | 1. OnStar login: username, password, PIN 15 | 1. Your car's VIN. Easily found in the monthly OnStar diagnostic emails. 16 | 1. MQTT server information: hostname, username, password 17 | 1. If using TLS, define `MQTT_PORT` and `MQTT_TLS=true` 18 | 19 | Supply these values to the ENV vars below. The default data refresh interval is 30 minutes and can be overridden with ONSTAR_REFRESH with values in milliseconds. 20 | ### [Docker](https://hub.docker.com/r/michaelwoods/onstar2mqtt) 21 | 22 | ```shell 23 | docker run \ 24 | --env ONSTAR_DEVICEID= \ 25 | --env ONSTAR_VIN= \ 26 | --env ONSTAR_USERNAME= \ 27 | --env ONSTAR_PASSWORD= \ 28 | --env ONSTAR_PIN= \ 29 | --env MQTT_HOST= \ 30 | --env MQTT_USERNAME \ 31 | --env MQTT_PASSWORD \ 32 | michaelwoods/onstar2mqtt:latest 33 | ``` 34 | ### docker-compose 35 | ```yaml 36 | onstar2mqtt: 37 | container_name: onstar2mqtt 38 | image: michaelwoods/onstar2mqtt 39 | restart: unless-stopped 40 | env_file: 41 | - /srv/containers/secrets/onstar2mqtt.env 42 | environment: 43 | - ONSTAR_DEVICEID= 44 | - ONSTAR_VIN= 45 | - MQTT_HOST= 46 | ``` 47 | onstar2mqtt.env: 48 | ```shell 49 | ONSTAR_USERNAME= 50 | ONSTAR_PASSWORD= 51 | ONSTAR_PIN= 52 | MQTT_USERNAME= 53 | MQTT_PASSWORD= 54 | ``` 55 | ### Node.js 56 | It's a typical node.js application, define the same environment values as described in the docker sections and run with: 57 | `npm run start`. Currently, this is only tested with Node.js 18.x. 58 | 59 | ### Home Assistant configuration templates 60 | MQTT auto discovery is enabled. For further integrations and screenshots see [HA-MQTT.md](HA-MQTT.md). 61 | 62 | ## Development 63 | ### Running 64 | `npm run start` 65 | ### Testing 66 | `npm run test` 67 | ### Coverage 68 | `npm run coverage` 69 | ### Releases 70 | `npm version [major|minor|patch] -m "Version %s" && git push --follow-tags` 71 | 72 | Publish the release on GitHub to trigger a release build (ie, update 'latest' docker tag). 73 | -------------------------------------------------------------------------------- /src/measurement.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const convert = require('convert-units'); 3 | 4 | class Measurement { 5 | static CONVERTABLE_UNITS = [ 6 | '°C', 7 | 'km', 8 | 'kPa', 9 | 'km/l(e)', 10 | 'km/l', 11 | // Helps with conversion to Gallons. 12 | 'lit' 13 | ]; 14 | 15 | constructor(value, unit) { 16 | this.value = value; 17 | this.unit = Measurement.correctUnitName(unit); 18 | this.isConvertible = _.includes(Measurement.CONVERTABLE_UNITS, this.unit); 19 | } 20 | 21 | /** 22 | * Would be nice if GM used sane unit labels. 23 | * @param {string} unit 24 | * @returns {string} 25 | */ 26 | static correctUnitName(unit) { 27 | switch (unit) { 28 | case 'Cel': 29 | return '°C'; 30 | case 'kwh': 31 | return 'kWh'; 32 | case 'KM': 33 | return 'km'; 34 | case 'KPa': 35 | return 'kPa'; 36 | case 'kmple': 37 | return 'km/l(e)'; 38 | case 'kmpl': 39 | return 'km/l'; 40 | case 'volts': 41 | case 'Volts': 42 | return 'V'; 43 | case 'l': 44 | return 'lit'; 45 | case 'L': 46 | return 'lit'; 47 | // these are states 48 | case 'Stat': 49 | case 'N/A': 50 | return undefined; 51 | 52 | default: 53 | return unit; 54 | } 55 | } 56 | 57 | /** 58 | * 59 | * @param {string|number} value 60 | * @param {string} unit 61 | * @returns {string|number} 62 | */ 63 | static convertValue(value, unit) { 64 | switch (unit) { 65 | case '°C': 66 | value = _.round(convert(value).from('C').to('F')); 67 | break; 68 | case 'km': 69 | value = _.round(convert(value).from('km').to('mi'), 1); 70 | break; 71 | case 'kPa': 72 | value = _.round(convert(value).from('kPa').to('psi'), 1); 73 | break; 74 | case 'km/l(e)': 75 | // km/L = (1.609344 / 3.785411784) * MPG 76 | value = _.round(value / (1.609344 / 3.785411784), 1); 77 | break; 78 | case 'km/l': 79 | // km/L = (1.609344 / 3.785411784) * MPG 80 | value = _.round(value / (1.609344 / 3.785411784), 1); 81 | break; 82 | case 'lit': 83 | value = _.round(value / 3.785411784, 1); 84 | break; 85 | } 86 | return value; 87 | } 88 | 89 | /** 90 | * 91 | * @param {string} unit 92 | * @returns {string} 93 | */ 94 | static convertUnit(unit) { 95 | switch (unit) { 96 | case '°C': 97 | return '°F'; 98 | case 'km': 99 | return 'mi'; 100 | case 'kPa': 101 | return 'psi'; 102 | case 'km/l(e)': 103 | return 'mpg(e)'; 104 | case 'km/l': 105 | return 'mpg'; 106 | case 'lit': 107 | return 'gal'; 108 | default: 109 | return unit; 110 | } 111 | } 112 | 113 | toString() { 114 | return `${this.value}${this.unit}`; 115 | } 116 | } 117 | 118 | module.exports = Measurement; 119 | -------------------------------------------------------------------------------- /src/commands.js: -------------------------------------------------------------------------------- 1 | 2 | class Commands { 3 | static CONSTANTS = { 4 | ALERT_ACTION: { 5 | FLASH: 'Flash', 6 | HONK: 'Honk', 7 | }, 8 | ALERT_OVERRIDE: { 9 | DOOR_OPEN: 'DoorOpen', 10 | IGNITION_ON: 'IgnitionOn' 11 | }, 12 | CHARGE_OVERRIDE: { 13 | CHARGE_NOW: 'CHARGE_NOW', 14 | CANCEL_OVERRIDE: 'CANCEL_OVERRIDE' 15 | }, 16 | CHARGING_PROFILE_MODE: { 17 | DEFAULT_IMMEDIATE: 'DEFAULT_IMMEDIATE', 18 | IMMEDIATE: 'IMMEDIATE', 19 | DEPARTURE_BASED: 'DEPARTURE_BASED', 20 | RATE_BASED: 'RATE_BASED', 21 | PHEV_AFTER_MIDNIGHT: 'PHEV_AFTER_MIDNIGHT' 22 | }, 23 | CHARGING_PROFILE_RATE: { 24 | OFFPEAK: 'OFFPEAK', 25 | MIDPEAK: 'MIDPEAK', 26 | PEAK: 'PEAK' 27 | }, 28 | DIAGNOSTICS: { 29 | ENGINE_COOLANT_TEMP: 'ENGINE COOLANT TEMP', 30 | ENGINE_RPM: 'ENGINE RPM', 31 | LAST_TRIP_FUEL_ECONOMY: 'LAST TRIP FUEL ECONOMY', 32 | EV_ESTIMATED_CHARGE_END: 'EV ESTIMATED CHARGE END', 33 | EV_BATTERY_LEVEL: 'EV BATTERY LEVEL', 34 | OIL_LIFE: 'OIL LIFE', 35 | EV_PLUG_VOLTAGE: 'EV PLUG VOLTAGE', 36 | LIFETIME_FUEL_ECON: 'LIFETIME FUEL ECON', 37 | HOTSPOT_CONFIG: 'HOTSPOT CONFIG', 38 | LIFETIME_FUEL_USED: 'LIFETIME FUEL USED', 39 | ODOMETER: 'ODOMETER', 40 | HOTSPOT_STATUS: 'HOTSPOT STATUS', 41 | LIFETIME_EV_ODOMETER: 'LIFETIME EV ODOMETER', 42 | EV_PLUG_STATE: 'EV PLUG STATE', 43 | EV_CHARGE_STATE: 'EV CHARGE STATE', 44 | TIRE_PRESSURE: 'TIRE PRESSURE', 45 | AMBIENT_AIR_TEMPERATURE: 'AMBIENT AIR TEMPERATURE', 46 | LAST_TRIP_DISTANCE: 'LAST TRIP DISTANCE', 47 | INTERM_VOLT_BATT_VOLT: 'INTERM VOLT BATT VOLT', 48 | GET_COMMUTE_SCHEDULE: 'GET COMMUTE SCHEDULE', 49 | GET_CHARGE_MODE: 'GET CHARGE MODE', 50 | EV_SCHEDULED_CHARGE_START: 'EV SCHEDULED CHARGE START', 51 | FUEL_TANK_INFO: 'FUEL TANK INFO', 52 | HANDS_FREE_CALLING: 'HANDS FREE CALLING', 53 | ENERGY_EFFICIENCY: 'ENERGY EFFICIENCY', 54 | VEHICLE_RANGE: 'VEHICLE RANGE', 55 | } 56 | } 57 | 58 | constructor(onstar) { 59 | this.onstar = onstar; 60 | } 61 | 62 | async getAccountVehicles() { 63 | return this.onstar.getAccountVehicles(); 64 | } 65 | 66 | async startVehicle() { 67 | return this.onstar.start(); 68 | } 69 | 70 | async cancelStartVehicle() { 71 | return this.onstar.cancelStart(); 72 | } 73 | 74 | //async alert({action = [Commands.CONSTANTS.ALERT_ACTION.FLASH], 75 | // delay = 0, duration = 1, override = []}) { 76 | // return this.onstar.alert({ 77 | // action, 78 | // delay, 79 | // duration, 80 | // override 81 | // }); 82 | //} 83 | 84 | async alert() { 85 | return this.onstar.alert(); 86 | } 87 | 88 | async alertFlash({action = [Commands.CONSTANTS.ALERT_ACTION.FLASH]}) { 89 | return this.onstar.alert({action}); 90 | } 91 | 92 | async alertHonk({action = [Commands.CONSTANTS.ALERT_ACTION.HONK]}) { 93 | return this.onstar.alert({action}); 94 | } 95 | 96 | async cancelAlert() { 97 | return this.onstar.cancelAlert(); 98 | } 99 | 100 | async lockDoor({delay = 0}) { 101 | return this.onstar.lockDoor({delay}); 102 | } 103 | 104 | async unlockDoor({delay = 0}) { 105 | return this.onstar.unlockDoor({delay}); 106 | } 107 | 108 | async chargeOverride({mode = Commands.CONSTANTS.CHARGE_OVERRIDE.CHARGE_NOW}) { 109 | return this.onstar.chargeOverride({mode}); 110 | } 111 | 112 | async cancelChargeOverride({mode = Commands.CONSTANTS.CHARGE_OVERRIDE.CANCEL_OVERRIDE}) { 113 | return this.onstar.chargeOverride({mode}); 114 | } 115 | 116 | async getChargingProfile() { 117 | return this.onstar.getChargingProfile(); 118 | } 119 | 120 | async setChargingProfile() { 121 | return this.onstar.setChargingProfile(); 122 | } 123 | 124 | async getLocation() { 125 | return this.onstar.location(); 126 | } 127 | 128 | async diagnostics({diagnosticItem = [ 129 | Commands.CONSTANTS.DIAGNOSTICS.ODOMETER, 130 | Commands.CONSTANTS.DIAGNOSTICS.TIRE_PRESSURE, 131 | Commands.CONSTANTS.DIAGNOSTICS.AMBIENT_AIR_TEMPERATURE, 132 | Commands.CONSTANTS.DIAGNOSTICS.LAST_TRIP_DISTANCE 133 | ]}) { 134 | return this.onstar.diagnostics({diagnosticItem}); 135 | } 136 | } 137 | 138 | module.exports = Commands; 139 | -------------------------------------------------------------------------------- /HA-MQTT.md: -------------------------------------------------------------------------------- 1 | Sample configs for MQTT Home Assistant integration. 2 | 3 | ### Commands 4 | 5 | #### example script yaml: 6 | ```yaml 7 | alias: Car - Start Vehicle 8 | sequence: 9 | - service: mqtt.publish 10 | data: 11 | topic: homeassistant/YOUR_CAR_VIN/command 12 | payload: '{"command": "startVehicle"}' 13 | mode: single 14 | icon: 'mdi:car-electric' 15 | ``` 16 | 17 | #### Triger precondition via calendar 18 | ````yaml 19 | alias: Car Precondition 20 | description: Precondition if group.family is home (ie, at least one person). 21 | trigger: 22 | - platform: state 23 | entity_id: calendar.YOUR_CAL_NAME 24 | from: 'off' 25 | to: 'on' 26 | condition: 27 | - condition: state 28 | entity_id: group.family 29 | state: home 30 | - condition: state 31 | entity_id: calendar.YOUR_CAL_NAME 32 | state: Bolt Start 33 | attribute: message 34 | action: 35 | - service: script.car_start_vehicle 36 | data: {} 37 | mode: single 38 | ```` 39 | 40 | ### Location 41 | Unfortunately, the MQTT Device tracker uses a home/not_home state and the MQTT Json device tracker does not support 42 | the discovery schema so a manual entity configuration is required. 43 | 44 | device tracker yaml: 45 | ```yaml 46 | device_tracker: 47 | - platform: mqtt_json 48 | devices: 49 | your_car_name: homeassistant/device_tracker/YOUR_CAR_VIN/getlocation/state 50 | ``` 51 | 52 | #### script yaml: 53 | ```yaml 54 | alias: Car - Location 55 | sequence: 56 | - service: mqtt.publish 57 | data: 58 | topic: homeassistant/YOUR_CAR_VIN/command 59 | payload: '{"command": "getLocation"}' 60 | mode: single 61 | icon: 'mdi:map-marker' 62 | ``` 63 | ### Automation: 64 | Create an automation to update the location whenever the odometer changes, instead of on a time interval. 65 | ```alias: Update EV Location 66 | description: "" 67 | trigger: 68 | - platform: state 69 | entity_id: 70 | - sensor.odometer_mi 71 | condition: [] 72 | action: 73 | - service: script.locate_bolt_ev 74 | data: {} 75 | mode: single 76 | ``` 77 | 78 | #### Commands: 79 | [OnStarJS Command Docs](https://github.com/samrum/OnStarJS#commands) 80 | 1. `getAccountVehicles` 81 | 2. `startVehicle` 82 | 3. `cancelStartVehicle` 83 | 4. `alert` 84 | 5. `cancelAlert` 85 | 6. `lockDoor` 86 | 7. `unlockDoor` 87 | 8. `chargeOverride` 88 | 9. `cancelChargeOverride` 89 | 10. `getLocation` 90 | 91 | 92 | ### Lovelace Dashboard 93 | Create a new dashboard, or use the cards in your own view. The `mdi:car-electric` icon works well here. 94 | 95 | ![lovelace screenshot](images/lovelace.png) 96 | 97 | #### dashboard yaml: 98 | ```yaml 99 | views: 100 | - badges: [] 101 | cards: 102 | - type: gauge 103 | entity: sensor.ev_battery_level 104 | min: 0 105 | max: 100 106 | name: Battery 107 | severity: 108 | green: 60 109 | yellow: 40 110 | red: 15 111 | - type: gauge 112 | entity: sensor.ev_range 113 | min: 0 114 | max: 420 115 | name: Range 116 | severity: 117 | green: 250 118 | yellow: 150 119 | red: 75 120 | - type: glance 121 | entities: 122 | - entity: sensor.tire_pressure_left_front 123 | name: Left Front 124 | icon: 'mdi:car-tire-alert' 125 | - entity: sensor.tire_pressure_right_front 126 | name: Right Front 127 | icon: 'mdi:car-tire-alert' 128 | - entity: sensor.tire_pressure_left_rear 129 | name: Left Rear 130 | icon: 'mdi:car-tire-alert' 131 | - entity: sensor.tire_pressure_right_rear 132 | name: Right Rear 133 | icon: 'mdi:car-tire-alert' 134 | columns: 2 135 | title: Tires 136 | - type: entities 137 | title: Mileage 138 | entities: 139 | - entity: sensor.lifetime_mpge 140 | - entity: sensor.lifetime_efficiency 141 | - entity: sensor.electric_economy 142 | state_color: true 143 | footer: 144 | type: 'custom:mini-graph-card' 145 | entities: 146 | - entity: sensor.odometer 147 | - entity: sensor.lifetime_energy_used 148 | y_axis: secondary 149 | show_state: true 150 | hours_to_show: 672 151 | group_by: date 152 | decimals: 0 153 | show: 154 | graph: bar 155 | name: false 156 | icon: false 157 | - type: entities 158 | entities: 159 | - entity: binary_sensor.ev_plug_state 160 | secondary_info: last-changed 161 | - entity: binary_sensor.ev_charge_state 162 | secondary_info: last-changed 163 | - entity: binary_sensor.priority_charge_indicator 164 | - entity: binary_sensor.priority_charge_status 165 | - entity: sensor.ev_plug_voltage 166 | - entity: sensor.interm_volt_batt_volt 167 | - entity: sensor.charger_power_level 168 | title: Charging 169 | state_color: true 170 | - type: 'custom:mini-graph-card' 171 | entities: 172 | - entity: sensor.last_trip_total_distance 173 | - entity: sensor.last_trip_electric_econ 174 | y_axis: secondary 175 | show_state: true 176 | name: Last Trip 177 | hours_to_show: 672 178 | group_by: date 179 | agreggate_func: null 180 | show: 181 | graph: bar 182 | icon: false 183 | - type: 'custom:mini-graph-card' 184 | entities: 185 | - entity: sensor.ambient_air_temperature 186 | name: Ambient 187 | - entity: sensor.hybrid_battery_minimum_temperature 188 | name: Battery 189 | - entity: sensor.kewr_daynight_temperature 190 | name: Outdoor 191 | name: Temperature 192 | hours_to_show: 24 193 | points_per_hour: 1 194 | line_width: 2 195 | - type: grid 196 | cards: 197 | - type: button 198 | tap_action: 199 | action: toggle 200 | entity: script.car_start_vehicle 201 | name: Start 202 | show_state: false 203 | - type: button 204 | tap_action: 205 | action: toggle 206 | entity: script.car_cancel_start_vehicle 207 | name: Cancel Start 208 | show_state: false 209 | icon: 'mdi:car-off' 210 | - type: button 211 | tap_action: 212 | action: toggle 213 | entity: script.car_lock_doors 214 | name: Lock 215 | show_state: false 216 | icon: 'mdi:car-door-lock' 217 | - type: button 218 | tap_action: 219 | action: toggle 220 | entity: script.car_unlock_doors 221 | name: Unlock 222 | show_state: false 223 | icon: 'mdi:car-door' 224 | columns: 2 225 | title: Bolt EV 226 | ``` 227 | -------------------------------------------------------------------------------- /test/diagnostic.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "commandResponse": { 3 | "requestTime": "2020-11-30T12:00:00.000Z", 4 | "completionTime": "2020-11-30T00:00:00.000Z", 5 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/requests/fizzbuzzREQ", 6 | "status": "success", 7 | "type": "diagnostics", 8 | "body": { 9 | "diagnosticResponse": [ 10 | { 11 | "name": "AMBIENT AIR TEMPERATURE", 12 | "diagnosticElement": [ 13 | { 14 | "name": "AMBIENT AIR TEMPERATURE", 15 | "status": "NA", 16 | "message": "na", 17 | "value": "15", 18 | "unit": "Cel" 19 | } 20 | ] 21 | }, 22 | { 23 | "name": "CHARGER POWER LEVEL", 24 | "diagnosticElement": [ 25 | { 26 | "name": "CHARGER POWER LEVEL", 27 | "status": "NA", 28 | "message": "na", 29 | "value": "NO_REDUCTION", 30 | "unit": "N/A" 31 | } 32 | ] 33 | }, 34 | { 35 | "name": "ENERGY EFFICIENCY", 36 | "diagnosticElement": [ 37 | { 38 | "name": "CO2 AVOIDED", 39 | "status": "NA", 40 | "message": "na" 41 | }, 42 | { 43 | "name": "ELECTRIC ECONOMY", 44 | "status": "NA", 45 | "message": "na", 46 | "value": "21.85", 47 | "unit": "kwh" 48 | }, 49 | { 50 | "name": "FUEL AVOIDED", 51 | "status": "NA", 52 | "message": "na" 53 | }, 54 | { 55 | "name": "GAS MILES", 56 | "status": "NA", 57 | "message": "na" 58 | }, 59 | { 60 | "name": "LIFETIME EFFICIENCY", 61 | "status": "NA", 62 | "message": "na", 63 | "value": "21.85", 64 | "unit": "kwh" 65 | }, 66 | { 67 | "name": "LIFETIME EV ODO", 68 | "status": "NA", 69 | "message": "na" 70 | }, 71 | { 72 | "name": "LIFETIME FUEL ECON", 73 | "status": "NA", 74 | "message": "na" 75 | }, 76 | { 77 | "name": "LIFETIME MPGE", 78 | "status": "NA", 79 | "message": "na", 80 | "value": "40.73", 81 | "unit": "kmple" 82 | }, 83 | { 84 | "name": "ODOMETER", 85 | "status": "NA", 86 | "message": "na", 87 | "value": "6013.8", 88 | "unit": "KM" 89 | } 90 | ] 91 | }, 92 | { 93 | "name": "EV CHARGE STATE", 94 | "diagnosticElement": [ 95 | { 96 | "name": "EV CHARGE STATE", 97 | "status": "NA", 98 | "message": "charging_complete", 99 | "value": "charging_complete", 100 | "unit": "N/A" 101 | }, 102 | { 103 | "name": "PRIORITY CHARGE INDICATOR", 104 | "status": "NA", 105 | "message": "na", 106 | "value": "FALSE", 107 | "unit": "N/A" 108 | }, 109 | { 110 | "name": "PRIORITY CHARGE STATUS", 111 | "status": "NA", 112 | "message": "na", 113 | "value": "NOT_ACTIVE", 114 | "unit": "N/A" 115 | }, 116 | { 117 | "name": "PRIORITY_CHARGE_STATUS", 118 | "status": "NA", 119 | "message": "na" 120 | } 121 | ] 122 | }, 123 | { 124 | "name": "EV PLUG STATE", 125 | "diagnosticElement": [ 126 | { 127 | "name": "EV PLUG STATE", 128 | "status": "NA", 129 | "message": "plugged", 130 | "value": "plugged", 131 | "unit": "Stat" 132 | } 133 | ] 134 | }, 135 | { 136 | "name": "EV SCHEDULED CHARGE START", 137 | "diagnosticElement": [ 138 | { 139 | "name": "EV SCHEDULED CHARGE START 120V DAY", 140 | "status": "NA", 141 | "message": "na", 142 | "value": "Monday" 143 | }, 144 | { 145 | "name": "EV SCHEDULED CHARGE START 240V DAY", 146 | "status": "NA", 147 | "message": "na", 148 | "value": "Monday" 149 | }, 150 | { 151 | "name": "SCHED CHG START 120V", 152 | "status": "NA", 153 | "message": "na", 154 | "value": "11:30" 155 | }, 156 | { 157 | "name": "SCHED CHG START 240V", 158 | "status": "NA", 159 | "message": "na", 160 | "value": "11:30" 161 | } 162 | ] 163 | }, 164 | { 165 | "name": "GET CHARGE MODE", 166 | "diagnosticElement": [ 167 | { 168 | "name": "CHARGE MODE", 169 | "status": "NA", 170 | "message": "na", 171 | "value": "DEPARTURE_BASED" 172 | }, 173 | { 174 | "name": "RATE TYPE", 175 | "status": "NA", 176 | "message": "na", 177 | "value": "PEAK" 178 | } 179 | ] 180 | }, 181 | { 182 | "name": "ODOMETER", 183 | "diagnosticElement": [ 184 | { 185 | "name": "ODOMETER", 186 | "status": "NA", 187 | "message": "na", 188 | "value": "6013.8", 189 | "unit": "KM" 190 | } 191 | ] 192 | }, 193 | { 194 | "name": "TIRE PRESSURE", 195 | "diagnosticElement": [ 196 | { 197 | "name": "TIRE PRESSURE LF", 198 | "status": "NA", 199 | "message": "YELLOW", 200 | "value": "240.0", 201 | "unit": "KPa" 202 | }, 203 | { 204 | "name": "TIRE PRESSURE LR", 205 | "status": "NA", 206 | "message": "YELLOW", 207 | "value": "236.0", 208 | "unit": "KPa" 209 | }, 210 | { 211 | "name": "TIRE PRESSURE PLACARD FRONT", 212 | "status": "NA", 213 | "message": "na", 214 | "value": "262.0", 215 | "unit": "KPa" 216 | }, 217 | { 218 | "name": "TIRE PRESSURE PLACARD REAR", 219 | "status": "NA", 220 | "message": "na", 221 | "value": "262.0", 222 | "unit": "KPa" 223 | }, 224 | { 225 | "name": "TIRE PRESSURE RF", 226 | "status": "NA", 227 | "message": "YELLOW", 228 | "value": "236.0", 229 | "unit": "KPa" 230 | }, 231 | { 232 | "name": "TIRE PRESSURE RR", 233 | "status": "NA", 234 | "message": "YELLOW", 235 | "value": "228.0", 236 | "unit": "KPa" 237 | } 238 | ] 239 | }, 240 | { 241 | "name": "VEHICLE RANGE", 242 | "diagnosticElement": [ 243 | { 244 | "name": "EV MAX RANGE", 245 | "status": "NA", 246 | "message": "na" 247 | }, 248 | { 249 | "name": "EV MIN RANGE", 250 | "status": "NA", 251 | "message": "na" 252 | }, 253 | { 254 | "name": "EV RANGE", 255 | "status": "NA", 256 | "message": "na", 257 | "value": "341.0", 258 | "unit": "KM" 259 | }, 260 | { 261 | "name": "GAS RANGE", 262 | "status": "NA", 263 | "message": "na" 264 | }, 265 | { 266 | "name": "TOTAL RANGE", 267 | "status": "NA", 268 | "message": "na" 269 | } 270 | ] 271 | } 272 | ] 273 | } 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const OnStar = require('onstarjs'); 2 | const mqtt = require('async-mqtt'); 3 | const uuidv4 = require('uuid').v4; 4 | const _ = require('lodash'); 5 | const Vehicle = require('./vehicle'); 6 | const {Diagnostic} = require('./diagnostic'); 7 | const MQTT = require('./mqtt'); 8 | const Commands = require('./commands'); 9 | const logger = require('./logger'); 10 | 11 | 12 | const onstarConfig = { 13 | deviceId: process.env.ONSTAR_DEVICEID || uuidv4(), 14 | vin: process.env.ONSTAR_VIN, 15 | username: process.env.ONSTAR_USERNAME, 16 | password: process.env.ONSTAR_PASSWORD, 17 | onStarPin: process.env.ONSTAR_PIN, 18 | checkRequestStatus: _.get(process.env, 'ONSTAR_SYNC', 'true') === 'true', 19 | refreshInterval: parseInt(process.env.ONSTAR_REFRESH) || (30 * 60 * 1000), // 30 min 20 | requestPollingIntervalSeconds: parseInt(process.env.ONSTAR_POLL_INTERVAL) || 6, // 6 sec default 21 | requestPollingTimeoutSeconds: parseInt(process.env.ONSTAR_POLL_TIMEOUT) || 60, // 60 sec default 22 | allowCommands: _.get(process.env, 'ONSTAR_ALLOW_COMMANDS', 'true') === 'true' 23 | }; 24 | logger.info('OnStar Config', {onstarConfig}); 25 | 26 | const mqttConfig = { 27 | host: process.env.MQTT_HOST || 'localhost', 28 | username: process.env.MQTT_USERNAME, 29 | password: process.env.MQTT_PASSWORD, 30 | port: parseInt(process.env.MQTT_PORT) || 1883, 31 | tls: process.env.MQTT_TLS || false, 32 | prefix: process.env.MQTT_PREFIX || 'homeassistant', 33 | namePrefix: process.env.MQTT_NAME_PREFIX || '', 34 | }; 35 | logger.info('MQTT Config', {mqttConfig}); 36 | 37 | const init = () => new Commands(OnStar.create(onstarConfig)); 38 | 39 | const getVehicles = async commands => { 40 | logger.info('Requesting vehicles'); 41 | const vehiclesRes = await commands.getAccountVehicles(); 42 | logger.info('Vehicle request status', {status: _.get(vehiclesRes, 'status')}); 43 | const vehicles = _.map( 44 | _.get(vehiclesRes, 'response.data.vehicles.vehicle'), 45 | v => new Vehicle(v) 46 | ); 47 | logger.debug('Vehicle request response', {vehicles: _.map(vehicles, v => v.toString())}); 48 | return vehicles; 49 | } 50 | 51 | const getCurrentVehicle = async commands => { 52 | const vehicles = await getVehicles(commands); 53 | const currentVeh = _.find(vehicles, v => v.vin.toLowerCase() === onstarConfig.vin.toLowerCase()); 54 | if (!currentVeh) { 55 | throw new Error(`Configured vehicle VIN ${onstarConfig.vin} not available in account vehicles`); 56 | } 57 | return currentVeh; 58 | } 59 | 60 | const connectMQTT = async availabilityTopic => { 61 | const url = `${mqttConfig.tls ? 'mqtts' : 'mqtt'}://${mqttConfig.host}:${mqttConfig.port}`; 62 | const config = { 63 | username: mqttConfig.username, 64 | password: mqttConfig.password, 65 | will: {topic: availabilityTopic, payload: 'false', retain: true} 66 | }; 67 | logger.info('Connecting to MQTT', {url, config: _.omit(config, 'password')}); 68 | const client = await mqtt.connectAsync(url, config); 69 | logger.info('Connected to MQTT'); 70 | return client; 71 | } 72 | 73 | const configureMQTT = async (commands, client, mqttHA) => { 74 | if (!onstarConfig.allowCommands) 75 | return; 76 | 77 | client.on('message', (topic, message) => { 78 | logger.debug('Subscription message', {topic, message}); 79 | const {command, options} = JSON.parse(message); 80 | const cmd = commands[command]; 81 | if (!cmd) { 82 | logger.error('Command not found', {command}); 83 | return; 84 | } 85 | const commandFn = cmd.bind(commands); 86 | logger.info('Command sent', { command }); 87 | commandFn(options || {}) 88 | .then(data => { 89 | // TODO refactor the response handling for commands 90 | logger.info('Command completed', { command }); 91 | const responseData = _.get(data, 'response.data'); 92 | if (responseData) { 93 | logger.info('Command response data', { responseData }); 94 | const location = _.get(data, 'response.data.commandResponse.body.location'); 95 | if (location) { 96 | const topic = mqttHA.getStateTopic({ name: command }); 97 | // TODO create device_tracker entity. MQTT device tracker doesn't support lat/lon and mqtt_json 98 | // doesn't have discovery 99 | client.publish(topic, 100 | JSON.stringify({ latitude: location.lat, longitude: location.long }), { retain: true }) 101 | .then(() => logger.info('Published location to topic.', { topic })); 102 | } 103 | } 104 | }) 105 | .catch(err=> logger.error('Command error', {command, err})); 106 | }); 107 | const topic = mqttHA.getCommandTopic(); 108 | logger.info('Subscribed to command topic', {topic}); 109 | await client.subscribe(topic); 110 | }; 111 | 112 | (async () => { 113 | try { 114 | const commands = init(); 115 | const vehicle = await getCurrentVehicle(commands); 116 | 117 | const mqttHA = new MQTT(vehicle, mqttConfig.prefix, mqttConfig.namePrefix); 118 | const availTopic = mqttHA.getAvailabilityTopic(); 119 | const client = await connectMQTT(availTopic); 120 | client.publish(availTopic, 'true', {retain: true}) 121 | .then(() => logger.debug('Published availability')); 122 | await configureMQTT(commands, client, mqttHA); 123 | 124 | const configurations = new Map(); 125 | const run = async () => { 126 | const states = new Map(); 127 | const v = vehicle; 128 | logger.info('Requesting diagnostics'); 129 | const statsRes = await commands.diagnostics({diagnosticItem: v.getSupported()}); 130 | logger.info('Diagnostic request status', {status: _.get(statsRes, 'status')}); 131 | const stats = _.map( 132 | _.get(statsRes, 'response.data.commandResponse.body.diagnosticResponse'), 133 | d => new Diagnostic(d) 134 | ); 135 | logger.debug('Diagnostic request response', {stats: _.map(stats, s => s.toString())}); 136 | 137 | for (const s of stats) { 138 | if (!s.hasElements()) { 139 | continue; 140 | } 141 | // configure once, then set or update states 142 | for (const d of s.diagnosticElements) { 143 | const topic = mqttHA.getConfigTopic(d) 144 | const payload = mqttHA.getConfigPayload(s, d); 145 | configurations.set(topic, {configured: false, payload}); 146 | } 147 | 148 | const topic = mqttHA.getStateTopic(s); 149 | const payload = mqttHA.getStatePayload(s); 150 | states.set(topic, payload); 151 | } 152 | const publishes = []; 153 | // publish sensor configs 154 | for (let [topic, config] of configurations) { 155 | // configure once 156 | if (!config.configured) { 157 | config.configured = true; 158 | const {payload} = config; 159 | logger.info('Publishing message', {topic, payload}); 160 | publishes.push( 161 | client.publish(topic, JSON.stringify(payload), {retain: true}) 162 | ); 163 | } 164 | } 165 | // update sensor states 166 | for (let [topic, state] of states) { 167 | logger.info('Publishing message', {topic, state}); 168 | publishes.push( 169 | client.publish(topic, JSON.stringify(state), {retain: true}) 170 | ); 171 | } 172 | await Promise.all(publishes); 173 | }; 174 | 175 | const main = async () => run() 176 | .then(() => logger.info('Updates complete, sleeping.')) 177 | .catch(e => { 178 | if (e instanceof Error) { 179 | logger.error('Error', {error: _.pick(e, [ 180 | 'message', 'stack', 181 | 'response.status', 'response.statusText', 'response.headers', 'response.data', 182 | 'request.method', 'request.body', 'request.contentType', 'request.headers', 'request.url' 183 | ])}); 184 | } else { 185 | logger.error('Error', {error: e}); 186 | } 187 | }); 188 | 189 | await main(); 190 | setInterval(main, onstarConfig.refreshInterval); 191 | } catch (e) { 192 | logger.error('Main function error.', {error: e}); 193 | } 194 | })(); 195 | -------------------------------------------------------------------------------- /test/mqtt.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const _ = require('lodash'); 3 | 4 | const { Diagnostic } = require('../src/diagnostic'); 5 | const MQTT = require('../src/mqtt'); 6 | const Vehicle = require('../src/vehicle'); 7 | const apiResponse = require('./diagnostic.sample.json'); 8 | 9 | describe('MQTT', () => { 10 | let mqtt; 11 | let vehicle = new Vehicle({make: 'foo', model: 'bar', vin: 'XXX', year: 2020}); 12 | beforeEach(() => mqtt = new MQTT(vehicle)); 13 | 14 | it('should set defaults', () => { 15 | assert.strictEqual(mqtt.prefix, 'homeassistant'); 16 | assert.strictEqual(mqtt.instance, 'XXX'); 17 | }); 18 | 19 | it('should convert names for mqtt topics', () => { 20 | assert.strictEqual(MQTT.convertName('foo bar'), 'foo_bar'); 21 | assert.strictEqual(MQTT.convertName('foo bar bazz'), 'foo_bar_bazz'); 22 | assert.strictEqual(MQTT.convertName('FOO BAR'), 'foo_bar'); 23 | assert.strictEqual(MQTT.convertName('FOO BAR bazz'), 'foo_bar_bazz'); 24 | }); 25 | 26 | it('should convert names to be human readable', () => { 27 | assert.strictEqual(MQTT.convertFriendlyName('foo bar'), 'Foo Bar'); 28 | assert.strictEqual(MQTT.convertFriendlyName('FOO BAR'), 'Foo Bar'); 29 | }); 30 | 31 | it('should determine sensor types', () => { 32 | assert.strictEqual(MQTT.determineSensorType('EV CHARGE STATE'), 'binary_sensor'); 33 | assert.strictEqual(MQTT.determineSensorType('EV PLUG STATE'), 'binary_sensor'); 34 | assert.strictEqual(MQTT.determineSensorType('PRIORITY CHARGE INDICATOR'), 'binary_sensor'); 35 | assert.strictEqual(MQTT.determineSensorType('PRIORITY CHARGE STATUS'), 'binary_sensor'); 36 | assert.strictEqual(MQTT.determineSensorType('getLocation'), 'device_tracker'); 37 | assert.strictEqual(MQTT.determineSensorType('foo'), 'sensor'); 38 | assert.strictEqual(MQTT.determineSensorType(''), 'sensor'); 39 | }); 40 | 41 | describe('topics', () => { 42 | let d; 43 | 44 | it('should generate availability topic', () => { 45 | assert.strictEqual(mqtt.getAvailabilityTopic(), 'homeassistant/XXX/available'); 46 | }); 47 | 48 | it('should generate command topic', () => { 49 | assert.strictEqual(mqtt.getCommandTopic(), 'homeassistant/XXX/command'); 50 | }); 51 | 52 | describe('sensor', () => { 53 | beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[0]'))); 54 | 55 | it('should generate config topics', () => { 56 | assert.strictEqual(mqtt.getConfigTopic(d), 'homeassistant/sensor/XXX/ambient_air_temperature/config'); 57 | }); 58 | it('should generate state topics', () => { 59 | assert.strictEqual(mqtt.getStateTopic(d), 'homeassistant/sensor/XXX/ambient_air_temperature/state'); 60 | }); 61 | }); 62 | 63 | describe('binary_sensor', () => { 64 | beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[3]'))); 65 | it('should generate config topics', () => { 66 | assert.strictEqual(mqtt.getConfigTopic(d), 'homeassistant/binary_sensor/XXX/ev_charge_state/config'); 67 | }); 68 | it('should generate state topics', () => { 69 | assert.strictEqual(mqtt.getStateTopic(d.diagnosticElements[1]), 'homeassistant/binary_sensor/XXX/priority_charge_indicator/state'); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('payloads', () => { 75 | let d; 76 | describe('sensor', () => { 77 | beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[0]'))); 78 | it('should generate config payloads', () => { 79 | assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[0]), { 80 | availability_topic: 'homeassistant/XXX/available', 81 | device: { 82 | identifiers: [ 83 | 'XXX' 84 | ], 85 | manufacturer: 'foo', 86 | model: 2020, 87 | name: '2020 foo bar' 88 | }, 89 | device_class: 'temperature', 90 | json_attributes_template: undefined, 91 | name: 'Ambient Air Temperature', 92 | payload_available: 'true', 93 | payload_not_available: 'false', 94 | state_topic: 'homeassistant/sensor/XXX/ambient_air_temperature/state', 95 | unique_id: 'xxx-ambient-air-temperature', 96 | json_attributes_topic: undefined, 97 | unit_of_measurement: '°C', 98 | value_template: '{{ value_json.ambient_air_temperature }}' 99 | }); 100 | assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[1]), { 101 | availability_topic: 'homeassistant/XXX/available', 102 | device: { 103 | identifiers: [ 104 | 'XXX' 105 | ], 106 | manufacturer: 'foo', 107 | model: 2020, 108 | name: '2020 foo bar' 109 | }, 110 | device_class: 'temperature', 111 | json_attributes_template: undefined, 112 | name: 'Ambient Air Temperature F', 113 | payload_available: 'true', 114 | payload_not_available: 'false', 115 | state_topic: 'homeassistant/sensor/XXX/ambient_air_temperature/state', 116 | unique_id: 'xxx-ambient-air-temperature-f', 117 | json_attributes_topic: undefined, 118 | unit_of_measurement: '°F', 119 | value_template: '{{ value_json.ambient_air_temperature_f }}' 120 | }); 121 | }); 122 | it('should generate state payloads', () => { 123 | assert.deepStrictEqual(mqtt.getStatePayload(d), { 124 | ambient_air_temperature: 15, 125 | ambient_air_temperature_f: 59 126 | }); 127 | }); 128 | }); 129 | 130 | describe('binary_sensor', () => { // TODO maybe not needed, payloads not diff 131 | beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[3]'))); 132 | it('should generate config payloads', () => { 133 | assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[1]), { 134 | availability_topic: 'homeassistant/XXX/available', 135 | device: { 136 | identifiers: [ 137 | 'XXX' 138 | ], 139 | manufacturer: 'foo', 140 | model: 2020, 141 | name: '2020 foo bar' 142 | }, 143 | device_class: undefined, 144 | json_attributes_template: undefined, 145 | name: 'Priority Charge Indicator', 146 | payload_available: 'true', 147 | payload_not_available: 'false', 148 | payload_off: false, 149 | payload_on: true, 150 | state_topic: 'homeassistant/binary_sensor/XXX/ev_charge_state/state', 151 | unique_id: 'xxx-priority-charge-indicator', 152 | json_attributes_topic: undefined, 153 | value_template: '{{ value_json.priority_charge_indicator }}' 154 | }); 155 | }); 156 | it('should generate state payloads', () => { 157 | assert.deepStrictEqual(mqtt.getStatePayload(d), { 158 | ev_charge_state: false, 159 | priority_charge_indicator: false, 160 | priority_charge_status: false 161 | }); 162 | }); 163 | }); 164 | 165 | describe('attributes', () => { 166 | beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[8]'))); 167 | it('should generate payloads with an attribute', () => { 168 | assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[0]), { 169 | availability_topic: 'homeassistant/XXX/available', 170 | device: { 171 | identifiers: [ 172 | 'XXX' 173 | ], 174 | manufacturer: 'foo', 175 | model: 2020, 176 | name: '2020 foo bar' 177 | }, 178 | device_class: 'pressure', 179 | json_attributes_template: "{{ {'recommendation': value_json.tire_pressure_placard_front} | tojson }}", 180 | name: 'Tire Pressure: Left Front', 181 | payload_available: 'true', 182 | payload_not_available: 'false', 183 | state_topic: 'homeassistant/sensor/XXX/tire_pressure/state', 184 | unique_id: 'xxx-tire-pressure-lf', 185 | json_attributes_topic: 'homeassistant/sensor/XXX/tire_pressure/state', 186 | unit_of_measurement: 'kPa', 187 | value_template: '{{ value_json.tire_pressure_lf }}' 188 | }); 189 | }); 190 | }); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /test/vehicles.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "vehicles": { 3 | "size": "1", 4 | "vehicle": [ 5 | { 6 | "vin": "foobarVIN", 7 | "make": "Chevrolet", 8 | "model": "Bolt EV", 9 | "year": "2020", 10 | "manufacturer": "General Motors", 11 | "bodyStyle": "CAR", 12 | "phone": "+5558675309", 13 | "unitType": "EMBEDDED", 14 | "onstarStatus": "ACTIVE", 15 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN", 16 | "isInPreActivation": "false", 17 | "commands": { 18 | "command": [ 19 | { 20 | "name": "cancelAlert", 21 | "description": "Cancel a vehicle alert (honk horns/flash lights).", 22 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/cancelAlert", 23 | "isPrivSessionRequired": "false" 24 | }, 25 | { 26 | "name": "getHotspotInfo", 27 | "description": "Retrives the WiFi Hotspot info", 28 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/hotspot/commands/getInfo", 29 | "isPrivSessionRequired": "false" 30 | }, 31 | { 32 | "name": "lockDoor", 33 | "description": "Locks the doors.", 34 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/lockDoor", 35 | "isPrivSessionRequired": "false" 36 | }, 37 | { 38 | "name": "unlockDoor", 39 | "description": "Unlocks the doors.", 40 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/unlockDoor", 41 | "isPrivSessionRequired": "true" 42 | }, 43 | { 44 | "name": "alert", 45 | "description": "Triggers a vehicle alert (honk horns/flash lights).", 46 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/alert", 47 | "isPrivSessionRequired": "true" 48 | }, 49 | { 50 | "name": "start", 51 | "description": "Remotely starts the vehicle.", 52 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/start", 53 | "isPrivSessionRequired": "true" 54 | }, 55 | { 56 | "name": "cancelStart", 57 | "description": "Cancels previous remote start command.", 58 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/cancelStart", 59 | "isPrivSessionRequired": "false" 60 | }, 61 | { 62 | "name": "diagnostics", 63 | "description": "Retrieves diagnostic vehicle data.", 64 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/diagnostics", 65 | "isPrivSessionRequired": "false", 66 | "commandData": { 67 | "supportedDiagnostics": { 68 | "supportedDiagnostic": [ 69 | "LAST TRIP FUEL ECONOMY", 70 | "ENERGY EFFICIENCY", 71 | "HYBRID BATTERY MINIMUM TEMPERATURE", 72 | "EV ESTIMATED CHARGE END", 73 | "LIFETIME ENERGY USED", 74 | "EV BATTERY LEVEL", 75 | "EV PLUG VOLTAGE", 76 | "HOTSPOT CONFIG", 77 | "ODOMETER", 78 | "HOTSPOT STATUS", 79 | "CHARGER POWER LEVEL", 80 | "LIFETIME EV ODOMETER", 81 | "EV PLUG STATE", 82 | "EV CHARGE STATE", 83 | "TIRE PRESSURE", 84 | "AMBIENT AIR TEMPERATURE", 85 | "LAST TRIP DISTANCE", 86 | "INTERM VOLT BATT VOLT", 87 | "GET COMMUTE SCHEDULE", 88 | "GET CHARGE MODE", 89 | "EV SCHEDULED CHARGE START", 90 | "VEHICLE RANGE" 91 | ] 92 | } 93 | } 94 | }, 95 | { 96 | "name": "location", 97 | "description": "Retrieves the vehicle's current location.", 98 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/location", 99 | "isPrivSessionRequired": "true" 100 | }, 101 | { 102 | "name": "chargeOverride", 103 | "description": "Sends Charge Override", 104 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/chargeOverride", 105 | "isPrivSessionRequired": "false" 106 | }, 107 | { 108 | "name": "getChargingProfile", 109 | "description": "Gets the Charge Mode", 110 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/getChargingProfile", 111 | "isPrivSessionRequired": "false" 112 | }, 113 | { 114 | "name": "getCommuteSchedule", 115 | "description": "Gets the commuting schedule", 116 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/getCommuteSchedule", 117 | "isPrivSessionRequired": "false" 118 | }, 119 | { 120 | "name": "connect", 121 | "description": "Initiates a connection to the vehicle", 122 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/connect", 123 | "isPrivSessionRequired": "false" 124 | }, 125 | { 126 | "name": "setChargingProfile", 127 | "description": "Sets the charging profile", 128 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/setChargingProfile", 129 | "isPrivSessionRequired": "false" 130 | }, 131 | { 132 | "name": "setCommuteSchedule", 133 | "description": "Sets the commuting schedule", 134 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/setCommuteSchedule", 135 | "isPrivSessionRequired": "false" 136 | }, 137 | { 138 | "name": "stopFastCharge", 139 | "description": "Stops the charge", 140 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/stopFastCharge", 141 | "isPrivSessionRequired": "true" 142 | }, 143 | { 144 | "name": "createTripPlan", 145 | "description": "Create Trip Plan", 146 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/createTripPlan", 147 | "isPrivSessionRequired": "false" 148 | }, 149 | { 150 | "name": "getTripPlan", 151 | "description": "Provides the ability to retrieve an existing trip plan for an electric vehicle", 152 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/getTripPlan", 153 | "isPrivSessionRequired": "false" 154 | }, 155 | { 156 | "name": "getHotspotStatus", 157 | "description": "Retrive WiFi status", 158 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/hotspot/commands/getStatus", 159 | "isPrivSessionRequired": "false" 160 | }, 161 | { 162 | "name": "setHotspotInfo", 163 | "description": "update the WiFi SSID and passPhrase", 164 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/hotspot/commands/setInfo", 165 | "isPrivSessionRequired": "false" 166 | }, 167 | { 168 | "name": "disableHotspot", 169 | "description": "Disable WiFi Hotspot", 170 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/hotspot/commands/disable", 171 | "isPrivSessionRequired": "false" 172 | }, 173 | { 174 | "name": "enableHotspot", 175 | "description": "Enable WiFi Hotspot", 176 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/hotspot/commands/enable", 177 | "isPrivSessionRequired": "false" 178 | }, 179 | { 180 | "name": "getRateSchedule", 181 | "description": "Get EV Rate Schedule", 182 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/getRateSchedule", 183 | "isPrivSessionRequired": "true" 184 | }, 185 | { 186 | "name": "setRateSchedule", 187 | "description": "Set EV Rate Schedule.", 188 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/setRateSchedule", 189 | "isPrivSessionRequired": "true" 190 | }, 191 | { 192 | "name": "getChargerPowerLevel", 193 | "description": " Get the charger level", 194 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/getChargerPowerLevel", 195 | "isPrivSessionRequired": "false" 196 | }, 197 | { 198 | "name": "setChargerPowerLevel", 199 | "description": " Set the charger level", 200 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/setChargerPowerLevel", 201 | "isPrivSessionRequired": "false" 202 | }, 203 | { 204 | "name": "setPriorityCharging", 205 | "description": "Set priority charging", 206 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/setPriorityCharging", 207 | "isPrivSessionRequired": "false" 208 | }, 209 | { 210 | "name": "getPriorityCharging", 211 | "description": "Get priority charging", 212 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/getPriorityCharging", 213 | "isPrivSessionRequired": "false" 214 | }, 215 | { 216 | "name": "stopCharge", 217 | "description": "Sets the Stop Charge", 218 | "url": "https://api.gm.com/api/v1/account/vehicles/foobarVIN/commands/stopCharge", 219 | "isPrivSessionRequired": "true" 220 | } 221 | ] 222 | }, 223 | "modules": { 224 | "module": [ 225 | { 226 | "moduleType": "BYOM2", 227 | "moduleCapability": "SF3" 228 | } 229 | ] 230 | }, 231 | "propulsionType": "BEV", 232 | "isSharedVehicle": "false", 233 | "ownerAccount": "999999999" 234 | } 235 | ] 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/mqtt.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | /** 4 | * Supports Home Assistant MQTT Discovery (https://www.home-assistant.io/docs/mqtt/discovery/) 5 | * 6 | * Supplies sensor configuration data and initialize sensors in HA. 7 | * 8 | * Topic format: prefix/type/instance/name 9 | * Examples: 10 | * - homeassistant/sensor/VIN/TIRE_PRESSURE/state -- Diagnostic 11 | * - payload: { 12 | * TIRE_PRESSURE_LF: 244.0, 13 | * TIRE_PRESSURE_LR: 240.0, 14 | * TIRE_PRESSURE_PLACARD_FRONT: 262.0, 15 | * TIRE_PRESSURE_PLACARD_REAR: 262.0, 16 | * TIRE_PRESSURE_RF: 240.0, 17 | * TIRE_PRESSURE_RR: 236.0, 18 | * } 19 | * - homeassistant/sensor/VIN/TIRE_PRESSURE_LF/config -- Diagnostic Element 20 | * - payload: { 21 | * device_class: "pressure", 22 | * name: "Tire Pressure: Left Front", 23 | * state_topic: "homeassistant/sensor/VIN/TIRE_PRESSURE/state", 24 | * unit_of_measurement: "kPa", 25 | * value_template: "{{ value_json.TIRE_PRESSURE_LF }}", 26 | * json_attributes_template: "{{ {'recommendation': value_json.TIRE_PRESSURE_PLACARD_FRONT} | tojson }}" 27 | * } 28 | * - homeassistant/sensor/VIN/TIRE_PRESSURE_RR/config -- Diagnostic Element 29 | * - payload: { 30 | * device_class: "pressure", 31 | * name: "Tire Pressure: Right Rear", 32 | * state_topic: "homeassistant/sensor/VIN/TIRE_PRESSURE/state", 33 | * unit_of_measurement: "kPa", 34 | * value_template: "{{ value_json.TIRE_PRESSURE_RR }}", 35 | * json_attributes_template: "{{ {'recommendation': value_json.TIRE_PRESSURE_PLACARD_REAR} | tojson }}" 36 | * } 37 | */ 38 | class MQTT { 39 | constructor(vehicle, prefix = 'homeassistant', namePrefix) { 40 | this.prefix = prefix; 41 | this.vehicle = vehicle; 42 | this.instance = vehicle.vin; 43 | this.namePrefix = namePrefix 44 | } 45 | 46 | static convertName(name) { 47 | return _.toLower(_.replace(name, / /g, '_')); 48 | } 49 | 50 | static convertFriendlyName(name) { 51 | return _.startCase(_.lowerCase(name)); 52 | } 53 | 54 | static determineSensorType(name) { 55 | switch (name) { 56 | case 'EV CHARGE STATE': 57 | case 'EV PLUG STATE': 58 | case 'PRIORITY CHARGE INDICATOR': 59 | case 'PRIORITY CHARGE STATUS': 60 | return 'binary_sensor'; 61 | case 'getLocation': 62 | return 'device_tracker'; 63 | default: 64 | return 'sensor'; 65 | } 66 | } 67 | 68 | /** 69 | * @param {string} name 70 | * @returns {string} 71 | */ 72 | addNamePrefix(name) { 73 | if (!this.namePrefix) return name 74 | return `${this.namePrefix} ${name}` 75 | } 76 | 77 | /** 78 | * @param {'sensor'|'binary_sensor'|'device_tracker'} type 79 | * @returns {string} 80 | */ 81 | getBaseTopic(type = 'sensor') { 82 | return `${this.prefix}/${type}/${this.instance}`; 83 | } 84 | 85 | getAvailabilityTopic() { 86 | return `${this.prefix}/${this.instance}/available`; 87 | } 88 | 89 | getCommandTopic() { 90 | return `${this.prefix}/${this.instance}/command`; 91 | } 92 | 93 | /** 94 | * 95 | * @param {DiagnosticElement} diag 96 | */ 97 | getConfigTopic(diag) { 98 | let sensorType = MQTT.determineSensorType(diag.name); 99 | return `${this.getBaseTopic(sensorType)}/${MQTT.convertName(diag.name)}/config`; 100 | } 101 | 102 | /** 103 | * 104 | * @param {Diagnostic} diag 105 | */ 106 | getStateTopic(diag) { 107 | let sensorType = MQTT.determineSensorType(diag.name); 108 | return `${this.getBaseTopic(sensorType)}/${MQTT.convertName(diag.name)}/state`; 109 | } 110 | 111 | /** 112 | * 113 | * @param {Diagnostic} diag 114 | * @param {DiagnosticElement} diagEl 115 | */ 116 | getConfigPayload(diag, diagEl) { 117 | return this.getConfigMapping(diag, diagEl); 118 | } 119 | 120 | /** 121 | * Return the state payload for this diagnostic 122 | * @param {Diagnostic} diag 123 | */ 124 | getStatePayload(diag) { 125 | const state = {}; 126 | _.forEach(diag.diagnosticElements, e => { 127 | // massage the binary_sensor values 128 | let value; 129 | switch (e.name) { 130 | case 'EV PLUG STATE': // unplugged/plugged 131 | value = e.value === 'plugged'; 132 | break; 133 | case 'EV CHARGE STATE': // not_charging/charging 134 | value = e.value === 'charging'; 135 | break; 136 | case 'PRIORITY CHARGE INDICATOR': // FALSE/TRUE 137 | value = e.value === 'TRUE'; 138 | break; 139 | case 'PRIORITY CHARGE STATUS': // NOT_ACTIVE/ACTIVE 140 | value = e.value === 'ACTIVE'; 141 | break; 142 | default: 143 | // coerce to number if possible, API uses strings :eyeroll: 144 | // eslint-disable-next-line no-case-declarations 145 | const num = _.toNumber(e.value); 146 | value = _.isNaN(num) ? e.value : num; 147 | break; 148 | } 149 | state[MQTT.convertName(e.name)] = value; 150 | }); 151 | return state; 152 | } 153 | 154 | mapBaseConfigPayload(diag, diagEl, device_class, name, attr) { 155 | name = name || MQTT.convertFriendlyName(diagEl.name); 156 | name = this.addNamePrefix(name); 157 | // Generate the unique id from the vin and name 158 | let unique_id = `${this.vehicle.vin}-${diagEl.name}` 159 | unique_id = unique_id.replace(/\s+/g, '-').toLowerCase(); 160 | return { 161 | device_class, 162 | name, 163 | device: { 164 | identifiers: [this.vehicle.vin], 165 | manufacturer: this.vehicle.make, 166 | model: this.vehicle.year, 167 | name: this.vehicle.toString() 168 | }, 169 | availability_topic: this.getAvailabilityTopic(), 170 | payload_available: 'true', 171 | payload_not_available: 'false', 172 | state_topic: this.getStateTopic(diag), 173 | value_template: `{{ value_json.${MQTT.convertName(diagEl.name)} }}`, 174 | json_attributes_topic: _.isUndefined(attr) ? undefined : this.getStateTopic(diag), 175 | json_attributes_template: attr, 176 | unique_id: unique_id 177 | }; 178 | } 179 | 180 | mapSensorConfigPayload(diag, diagEl, device_class, name, attr) { 181 | name = name || MQTT.convertFriendlyName(diagEl.name); 182 | return _.extend( 183 | this.mapBaseConfigPayload(diag, diagEl, device_class, name, attr), 184 | {unit_of_measurement: diagEl.unit}); 185 | } 186 | 187 | mapBinarySensorConfigPayload(diag, diagEl, device_class, name, attr) { 188 | name = name || MQTT.convertFriendlyName(diagEl.name); 189 | return _.extend( 190 | this.mapBaseConfigPayload(diag, diagEl, device_class, name, attr), 191 | {payload_on: true, payload_off: false}); 192 | } 193 | 194 | /** 195 | * 196 | * @param {Diagnostic} diag 197 | * @param {DiagnosticElement} diagEl 198 | */ 199 | getConfigMapping(diag, diagEl) { 200 | // TODO: this sucks, find a better way to map these diagnostics and their elements for discovery. 201 | switch (diagEl.name) { 202 | case 'LIFETIME ENERGY USED': 203 | case 'LIFETIME EFFICIENCY': 204 | case 'ELECTRIC ECONOMY': 205 | return this.mapSensorConfigPayload(diag, diagEl, 'energy'); 206 | case 'INTERM VOLT BATT VOLT': 207 | case 'EV PLUG VOLTAGE': 208 | return this.mapSensorConfigPayload(diag, diagEl, 'voltage'); 209 | case 'HYBRID BATTERY MINIMUM TEMPERATURE': 210 | case 'AMBIENT AIR TEMPERATURE': 211 | case 'AMBIENT AIR TEMPERATURE F': 212 | case 'ENGINE COOLANT TEMP': 213 | case 'ENGINE COOLANT TEMP F': 214 | return this.mapSensorConfigPayload(diag, diagEl, 'temperature'); 215 | case 'EV BATTERY LEVEL': 216 | return this.mapSensorConfigPayload(diag, diagEl, 'battery'); 217 | case 'TIRE PRESSURE LF': 218 | return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Front', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_FRONT')}} | tojson }}`); 219 | case 'TIRE PRESSURE LF PSI': 220 | return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Front PSI', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_FRONT_PSI')}} | tojson }}`); 221 | case 'TIRE PRESSURE LR': 222 | return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Rear', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_REAR')}} | tojson }}`); 223 | case 'TIRE PRESSURE LR PSI': 224 | return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Rear PSI', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_REAR_PSI')}} | tojson }}`); 225 | case 'TIRE PRESSURE RF': 226 | return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Front', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_FRONT')}} | tojson }}`); 227 | case 'TIRE PRESSURE RF PSI': 228 | return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Front PSI', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_FRONT_PSI')}} | tojson }}`); 229 | case 'TIRE PRESSURE RR': 230 | return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Rear', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_REAR')}} | tojson }}`); 231 | case 'TIRE PRESSURE RR PSI': 232 | return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Rear PSI', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_REAR_PSI')}} | tojson }}`); 233 | // binary sensor 234 | case 'EV PLUG STATE': // unplugged/plugged 235 | return this.mapBinarySensorConfigPayload(diag, diagEl, 'plug'); 236 | case 'EV CHARGE STATE': // not_charging/charging 237 | return this.mapBinarySensorConfigPayload(diag, diagEl, 'battery_charging'); 238 | // binary_sensor, but no applicable device_class 239 | case 'PRIORITY CHARGE INDICATOR': // FALSE/TRUE 240 | case 'PRIORITY CHARGE STATUS': // NOT_ACTIVE/ACTIVE 241 | return this.mapBinarySensorConfigPayload(diag, diagEl); 242 | // no device class, camel case name 243 | case 'EV RANGE': 244 | case 'ODOMETER': 245 | case 'LAST TRIP TOTAL DISTANCE': 246 | case 'LAST TRIP ELECTRIC ECON': 247 | case 'LIFETIME MPGE': 248 | case 'CHARGER POWER LEVEL': 249 | default: 250 | return this.mapSensorConfigPayload(diag, diagEl); 251 | } 252 | } 253 | } 254 | 255 | module.exports = MQTT; --------------------------------------------------------------------------------