Compare commits

..

No commits in common. "main" and "v1.0.3" have entirely different histories.
main ... v1.0.3

24 changed files with 1012 additions and 10403 deletions

View File

@ -1,6 +0,0 @@
{
"presets": ["@babel/env"],
"plugins": [
"@babel/plugin-syntax-class-properties"
]
}

View File

@ -1,16 +0,0 @@
env:
node: true
browser: false
commonjs: true
es6: true
mocha: true
extends:
- eslint:recommended
parser: "@babel/eslint-parser"
parserOptions:
babelOptions:
configFile: './.babelrc'
ecmaVersion: 2018
plugins:
- "@babel"
rules: {}

View File

@ -4,8 +4,8 @@ updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "monthly" interval: "daily"
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/" directory: "/"
schedule: schedule:
interval: "monthly" interval: "daily"

View File

@ -16,46 +16,42 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [18.x] node-version: [12.x]
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v3 uses: actions/checkout@v2
- name: Docker meta - name: Docker meta
id: docker_meta id: docker_meta
uses: docker/metadata-action@v4 uses: crazy-max/ghaction-docker-meta@v1
with: with:
images: michaelwoods/onstar2mqtt images: michaelwoods/onstar2mqtt
flavor: | tag-sha: true
latest=true tag-schedule: weekly
tags: |
type=ref,event=branch
type=schedule,pattern=weekly
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3 uses: actions/setup-node@v2.1.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- run: npm ci - run: npm ci
- run: npm run build --if-present - run: npm run build --if-present
- run: npm run lint
- run: npm test - run: npm test
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2.1.0 uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v1
- name: Login to DockerHub - name: Login to DockerHub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v2 uses: docker/login-action@v1
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Push to DockerHub - name: Push to DockerHub
uses: docker/build-push-action@v4 uses: docker/build-push-action@v2
with: with:
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }} labels: ${{ steps.docker_meta.outputs.labels }}

View File

@ -1,71 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '28 1 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# 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
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

View File

@ -9,32 +9,28 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v3 uses: actions/checkout@v2
- name: Docker meta - name: Docker meta
id: docker_meta id: docker_meta
uses: docker/metadata-action@v4 uses: crazy-max/ghaction-docker-meta@v1
with: with:
images: michaelwoods/onstar2mqtt images: michaelwoods/onstar2mqtt
flavor: | tag-sha: true
latest=true
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2.1.0 uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v1
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v2 uses: docker/login-action@v1
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Push to DockerHub - name: Push to DockerHub
uses: docker/build-push-action@v4 uses: docker/build-push-action@v2
with: with:
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }} labels: ${{ steps.docker_meta.outputs.labels }}

1
.gitignore vendored
View File

@ -2,4 +2,3 @@
.nyc_output/ .nyc_output/
node_modules/ node_modules/
onstar2mqtt.env

1
.nvmrc
View File

@ -1 +0,0 @@
lts/hydrogen

View File

@ -1,11 +1,10 @@
FROM node:18-alpine FROM node:12
RUN mkdir /app RUN mkdir /app
WORKDIR /app WORKDIR /app
COPY ["package.json", "/app/"] COPY ["package.json", "/app/"]
COPY ["package-lock.json", "/app/"] RUN npm install
RUN npm ci --omit=dev --no-fund
COPY ["src", "/app/src"] COPY ["src", "/app/src"]

View File

@ -1,100 +1,11 @@
Sample configs for MQTT Home Assistant integration. Sample configs for MQTT Home Assistant integration.
### Commands
#### example script yaml:
```yaml
alias: Car - Start Vehicle
sequence:
- service: mqtt.publish
data:
topic: homeassistant/YOUR_CAR_VIN/command
payload: '{"command": "startVehicle"}'
mode: single
icon: 'mdi:car-electric'
```
#### Triger precondition via calendar
````yaml
alias: Car Precondition
description: Precondition if group.family is home (ie, at least one person).
trigger:
- platform: state
entity_id: calendar.YOUR_CAL_NAME
from: 'off'
to: 'on'
condition:
- condition: state
entity_id: group.family
state: home
- condition: state
entity_id: calendar.YOUR_CAL_NAME
state: Bolt Start
attribute: message
action:
- service: script.car_start_vehicle
data: {}
mode: single
````
### Location
Unfortunately, the MQTT Device tracker uses a home/not_home state and the MQTT Json device tracker does not support
the discovery schema so a manual entity configuration is required.
device tracker yaml:
```yaml
device_tracker:
- platform: mqtt_json
devices:
your_car_name: homeassistant/device_tracker/YOUR_CAR_VIN/getlocation/state
```
#### script yaml:
```yaml
alias: Car - Location
sequence:
- service: mqtt.publish
data:
topic: homeassistant/YOUR_CAR_VIN/command
payload: '{"command": "getLocation"}'
mode: single
icon: 'mdi:map-marker'
```
### Automation:
Create an automation to update the location whenever the odometer changes, instead of on a time interval.
```alias: Update EV Location
description: ""
trigger:
- platform: state
entity_id:
- sensor.odometer_mi
condition: []
action:
- service: script.locate_bolt_ev
data: {}
mode: single
```
#### Commands:
[OnStarJS Command Docs](https://github.com/samrum/OnStarJS#commands)
1. `getAccountVehicles`
2. `startVehicle`
3. `cancelStartVehicle`
4. `alert`
5. `cancelAlert`
6. `lockDoor`
7. `unlockDoor`
8. `chargeOverride`
9. `cancelChargeOverride`
10. `getLocation`
### Lovelace Dashboard ### Lovelace Dashboard
Create a new dashboard, or use the cards in your own view. The `mdi:car-electric` icon works well here. Create a new dashboard, or use the cards in your own view. The `mdi:car-electric` icon works well here.
![lovelace screenshot](images/lovelace.png) ![lovelace screenshot](images/lovelace.png)
#### dashboard yaml: yaml:
```yaml ```yaml
views: views:
- badges: [] - badges: []
@ -133,54 +44,22 @@ views:
icon: 'mdi:car-tire-alert' icon: 'mdi:car-tire-alert'
columns: 2 columns: 2
title: Tires title: Tires
- type: glance
entities:
- entity: sensor.last_trip_total_distance
name: Distance
- entity: sensor.last_trip_electric_econ
name: Economy
title: Last Trip
- type: entities - type: entities
title: Mileage title: Mileage
entities: entities:
- entity: sensor.odometer
- entity: sensor.lifetime_energy_used
- entity: sensor.lifetime_mpge - entity: sensor.lifetime_mpge
- entity: sensor.lifetime_efficiency - entity: sensor.lifetime_efficiency
- entity: sensor.electric_economy - entity: sensor.electric_economy
state_color: true - type: glance
footer:
type: 'custom:mini-graph-card'
entities:
- entity: sensor.odometer
- entity: sensor.lifetime_energy_used
y_axis: secondary
show_state: true
hours_to_show: 672
group_by: date
decimals: 0
show:
graph: bar
name: false
icon: false
- type: entities
entities:
- entity: binary_sensor.ev_plug_state
secondary_info: last-changed
- entity: binary_sensor.ev_charge_state
secondary_info: last-changed
- entity: binary_sensor.priority_charge_indicator
- entity: binary_sensor.priority_charge_status
- entity: sensor.ev_plug_voltage
- entity: sensor.interm_volt_batt_volt
- entity: sensor.charger_power_level
title: Charging
state_color: true
- type: 'custom:mini-graph-card'
entities:
- entity: sensor.last_trip_total_distance
- entity: sensor.last_trip_electric_econ
y_axis: secondary
show_state: true
name: Last Trip
hours_to_show: 672
group_by: date
agreggate_func: null
show:
graph: bar
icon: false
- type: 'custom:mini-graph-card'
entities: entities:
- entity: sensor.ambient_air_temperature - entity: sensor.ambient_air_temperature
name: Ambient name: Ambient
@ -188,39 +67,20 @@ views:
name: Battery name: Battery
- entity: sensor.kewr_daynight_temperature - entity: sensor.kewr_daynight_temperature
name: Outdoor name: Outdoor
name: Temperature title: Temperature
hours_to_show: 24 - type: entities
points_per_hour: 1 entities:
line_width: 2 - entity: binary_sensor.ev_plug_state
- type: grid - entity: binary_sensor.ev_charge_state
cards: - entity: binary_sensor.priority_charge_indicator
- type: button - entity: binary_sensor.priority_charge_status
tap_action: - entity: sensor.ev_plug_voltage
action: toggle - entity: sensor.interm_volt_batt_volt
entity: script.car_start_vehicle - entity: sensor.charger_power_level
name: Start title: Charging
show_state: false
- type: button
tap_action:
action: toggle
entity: script.car_cancel_start_vehicle
name: Cancel Start
show_state: false
icon: 'mdi:car-off'
- type: button
tap_action:
action: toggle
entity: script.car_lock_doors
name: Lock
show_state: false
icon: 'mdi:car-door-lock'
- type: button
tap_action:
action: toggle
entity: script.car_unlock_doors
name: Unlock
show_state: false
icon: 'mdi:car-door'
columns: 2
title: Bolt EV title: Bolt EV
``` ```
TODO
- Utility meter that resets for monthly LIFETIME ENERGY USED. This seems to only be updated after a full charge, along with other data points.

View File

@ -1,9 +1,7 @@
# onstar2mqtt # onstar2mqtt
A service that utilizes the [OnStarJS](https://github.com/samrum/OnStarJS) library to expose OnStar data to MQTT topics. A service that utilizes the [OnStarJS](https://github.com/samrum/OnStarJS) library to expose OnStar data to MQTT topics. Mostly focused around EVs, however happy to accept PRs for other vehicle types.
The functionality is mostly focused around EVs (specifically the Bolt EV), however PRs for other vehicle types are certainly welcome. There is no official relationship with 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.
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.
## Running ## Running
Collect the following information: Collect the following information:
@ -13,7 +11,7 @@ Collect the following information:
1. MQTT server information: hostname, username, password 1. MQTT server information: hostname, username, password
1. If using TLS, define `MQTT_PORT` and `MQTT_TLS=true` 1. If using TLS, define `MQTT_PORT` and `MQTT_TLS=true`
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. Supply these values to the ENV vars below.
### [Docker](https://hub.docker.com/r/michaelwoods/onstar2mqtt) ### [Docker](https://hub.docker.com/r/michaelwoods/onstar2mqtt)
```shell ```shell
@ -51,10 +49,10 @@ MQTT_PASSWORD=
``` ```
### Node.js ### Node.js
It's a typical node.js application, define the same environment values as described in the docker sections and run with: It's a typical node.js application, define the same environment values as described in the docker sections and run with:
`npm run start`. Currently, this is only tested with Node.js 18.x. `npm run start`. Currently, only tested with Node.js 12.x.
### Home Assistant configuration templates ### Home Assistant configuration templates
MQTT auto discovery is enabled. For further integrations and screenshots see [HA-MQTT.md](HA-MQTT.md). MQTT auto discovery is enabled. For further integrations see [HA-MQTT.md](HA-MQTT.md).
## Development ## Development
### Running ### Running
@ -62,8 +60,13 @@ MQTT auto discovery is enabled. For further integrations and screenshots see [HA
### Testing ### Testing
`npm run test` `npm run test`
### Coverage ### Coverage
`npm run coverage` `rpm run coverage`
### Releases ### Releases
`npm version [major|minor|patch] -m "Version %s" && git push --follow-tags` `npm version [major|minor|patch] -m "Version %s" && git push --follow-tags`
Publish the release on GitHub to trigger a release build (ie, update 'latest' docker tag). Publish the release on GitHub to trigger a release build (ie, update 'latest' docker tag).
## TODO
1. Logging library
1. Figure out metric->imperial unit handling
1. Enable write actions to lock doors, flash lights, remote start, etc.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 97 KiB

10349
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,10 @@
{ {
"name": "onstar2mqtt", "name": "onstar2mqtt",
"version": "1.5.4", "version": "1.0.2",
"description": "OnStarJS wrapper for MQTT", "description": "OnStarJS wrapper for MQTT",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"coverage": "nyc npm test", "coverage": "nyc npm test",
"lint": "npx eslint src test",
"start": "node src/index.js", "start": "node src/index.js",
"test": "mocha" "test": "mocha"
}, },
@ -17,10 +16,7 @@
"onstar", "onstar",
"mqtt", "mqtt",
"gm", "gm",
"chevrolet", "chevrolet"
"homeassistant",
"home-assistant",
"home assistant"
], ],
"author": "Michael Woods", "author": "Michael Woods",
"license": "MIT", "license": "MIT",
@ -29,23 +25,14 @@
}, },
"homepage": "https://github.com/michaelwoods/onstar2mqtt#readme", "homepage": "https://github.com/michaelwoods/onstar2mqtt#readme",
"dependencies": { "dependencies": {
"async-mqtt": "^2.6.3", "async-mqtt": "^2.6.1",
"convert-units": "^2.3.4", "convert-units": "^2.3.4",
"lodash": "^4.17.21", "lodash": "^4.17.20",
"onstarjs": "^2.3.16", "onstarjs": "^2.0.10",
"uuid": "^9.0.0", "uuid": "^8.3.2"
"winston": "^3.8.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.19.1", "mocha": "^8.2.1",
"@babel/eslint-plugin": "^7.19.1",
"@babel/plugin-syntax-class-properties": "^7.12.13",
"@babel/preset-env": "^7.20.2",
"eslint": "^8.35.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"mocha": "^10.2.0",
"nyc": "^15.1.0" "nyc": "^15.1.0"
} }
} }

View File

@ -1,126 +0,0 @@
class Commands {
static CONSTANTS = {
ALERT_ACTION: {
FLASH: 'Flash',
HONK: 'Honk',
},
ALERT_OVERRIDE: {
DOOR_OPEN: 'DoorOpen',
IGNITION_ON: 'IgnitionOn'
},
CHARGE_OVERRIDE: {
CHARGE_NOW: 'CHARGE_NOW',
CANCEL_OVERRIDE: 'CANCEL_OVERRIDE'
},
CHARGING_PROFILE_MODE: {
DEFAULT_IMMEDIATE: 'DEFAULT_IMMEDIATE',
IMMEDIATE: 'IMMEDIATE',
DEPARTURE_BASED: 'DEPARTURE_BASED',
RATE_BASED: 'RATE_BASED',
PHEV_AFTER_MIDNIGHT: 'PHEV_AFTER_MIDNIGHT'
},
CHARGING_PROFILE_RATE: {
OFFPEAK: 'OFFPEAK',
MIDPEAK: 'MIDPEAK',
PEAK: 'PEAK'
},
DIAGNOSTICS: {
ENGINE_COOLANT_TEMP: 'ENGINE COOLANT TEMP',
ENGINE_RPM: 'ENGINE RPM',
LAST_TRIP_FUEL_ECONOMY: 'LAST TRIP FUEL ECONOMY',
EV_ESTIMATED_CHARGE_END: 'EV ESTIMATED CHARGE END',
EV_BATTERY_LEVEL: 'EV BATTERY LEVEL',
OIL_LIFE: 'OIL LIFE',
EV_PLUG_VOLTAGE: 'EV PLUG VOLTAGE',
LIFETIME_FUEL_ECON: 'LIFETIME FUEL ECON',
HOTSPOT_CONFIG: 'HOTSPOT CONFIG',
LIFETIME_FUEL_USED: 'LIFETIME FUEL USED',
ODOMETER: 'ODOMETER',
HOTSPOT_STATUS: 'HOTSPOT STATUS',
LIFETIME_EV_ODOMETER: 'LIFETIME EV ODOMETER',
EV_PLUG_STATE: 'EV PLUG STATE',
EV_CHARGE_STATE: 'EV CHARGE STATE',
TIRE_PRESSURE: 'TIRE PRESSURE',
AMBIENT_AIR_TEMPERATURE: 'AMBIENT AIR TEMPERATURE',
LAST_TRIP_DISTANCE: 'LAST TRIP DISTANCE',
INTERM_VOLT_BATT_VOLT: 'INTERM VOLT BATT VOLT',
GET_COMMUTE_SCHEDULE: 'GET COMMUTE SCHEDULE',
GET_CHARGE_MODE: 'GET CHARGE MODE',
EV_SCHEDULED_CHARGE_START: 'EV SCHEDULED CHARGE START',
FUEL_TANK_INFO: 'FUEL TANK INFO',
HANDS_FREE_CALLING: 'HANDS FREE CALLING',
ENERGY_EFFICIENCY: 'ENERGY EFFICIENCY',
VEHICLE_RANGE: 'VEHICLE RANGE',
}
}
constructor(onstar) {
this.onstar = onstar;
}
async getAccountVehicles() {
return this.onstar.getAccountVehicles();
}
async startVehicle() {
return this.onstar.start();
}
async cancelStartVehicle() {
return this.onstar.cancelStart();
}
async alert({action = [Commands.CONSTANTS.ALERT_ACTION.FLASH],
delay = 0, duration = 1, override = []}) {
return this.onstar.alert({
action,
delay,
duration,
override
});
}
async cancelAlert() {
return this.onstar.cancelAlert();
}
async lockDoor({delay = 0}) {
return this.onstar.lockDoor({delay});
}
async unlockDoor({delay = 0}) {
return this.onstar.unlockDoor({delay});
}
async chargeOverride({mode = Commands.CONSTANTS.CHARGE_OVERRIDE.CHARGE_NOW}) {
return this.onstar.chargeOverride({mode});
}
async cancelChargeOverride({mode = Commands.CONSTANTS.CHARGE_OVERRIDE.CANCEL_OVERRIDE}) {
return this.onstar.chargeOverride({mode});
}
async getChargingProfile() {
return this.onstar.getChargingProfile();
}
async setChargingProfile() {
return this.onstar.setChargingProfile();
}
async getLocation() {
return this.onstar.location();
}
async diagnostics({diagnosticItem = [
Commands.CONSTANTS.DIAGNOSTICS.ODOMETER,
Commands.CONSTANTS.DIAGNOSTICS.TIRE_PRESSURE,
Commands.CONSTANTS.DIAGNOSTICS.AMBIENT_AIR_TEMPERATURE,
Commands.CONSTANTS.DIAGNOSTICS.LAST_TRIP_DISTANCE
]}) {
return this.onstar.diagnostics({diagnosticItem});
}
}
module.exports = Commands;

View File

@ -10,9 +10,6 @@ class Diagnostic {
d => _.has(d, 'value') && _.has(d, 'unit') d => _.has(d, 'value') && _.has(d, 'unit')
); );
this.diagnosticElements = _.map(validEle, e => new DiagnosticElement(e)); this.diagnosticElements = _.map(validEle, e => new DiagnosticElement(e));
const converted = _.map(_.filter(this.diagnosticElements, e => e.isConvertible),
e => DiagnosticElement.convert(e));
this.diagnosticElements.push(... converted);
} }
hasElements() { hasElements() {
@ -27,29 +24,6 @@ class Diagnostic {
} }
class DiagnosticElement { class DiagnosticElement {
/**
*
* @param {DiagnosticElement} element
*/
static convert(element) {
const {name, unit, value} = element;
const convertedUnit = Measurement.convertUnit(unit);
return new DiagnosticElement({
name: DiagnosticElement.convertName(name, convertedUnit),
unit: convertedUnit,
value: Measurement.convertValue(value, unit)
})
}
static convertName(name, unit) {
return `${name} ${_.replace(_.toUpper(unit), /\W/g, '')}`;
}
/**
* @param {string} ele.name
* @param {string|number} ele.value
* @param {string} ele.unit
*/
constructor(ele) { constructor(ele) {
this._name = ele.name; this._name = ele.name;
this.measurement = new Measurement(ele.value, ele.unit); this.measurement = new Measurement(ele.value, ele.unit);
@ -67,10 +41,6 @@ class DiagnosticElement {
return this.measurement.unit; return this.measurement.unit;
} }
get isConvertible() {
return this.measurement.isConvertible;
}
toString() { toString() {
return `${this.name}: ${this.measurement.toString()}`; return `${this.name}: ${this.measurement.toString()}`;
} }

View File

@ -5,9 +5,6 @@ const _ = require('lodash');
const Vehicle = require('./vehicle'); const Vehicle = require('./vehicle');
const {Diagnostic} = require('./diagnostic'); const {Diagnostic} = require('./diagnostic');
const MQTT = require('./mqtt'); const MQTT = require('./mqtt');
const Commands = require('./commands');
const logger = require('./logger');
const onstarConfig = { const onstarConfig = {
deviceId: process.env.ONSTAR_DEVICEID || uuidv4(), deviceId: process.env.ONSTAR_DEVICEID || uuidv4(),
@ -15,13 +12,9 @@ const onstarConfig = {
username: process.env.ONSTAR_USERNAME, username: process.env.ONSTAR_USERNAME,
password: process.env.ONSTAR_PASSWORD, password: process.env.ONSTAR_PASSWORD,
onStarPin: process.env.ONSTAR_PIN, onStarPin: process.env.ONSTAR_PIN,
checkRequestStatus: _.get(process.env, 'ONSTAR_SYNC', 'true') === 'true', checkRequestStatus: process.env.ONSTAR_SYNC === "true" || true,
refreshInterval: parseInt(process.env.ONSTAR_REFRESH) || (30 * 60 * 1000), // 30 min refreshInterval: parseInt(process.env.ONSTAR_REFRESH) || (30 * 60 * 1000) // 30 min
requestPollingIntervalSeconds: parseInt(process.env.ONSTAR_POLL_INTERVAL) || 6, // 6 sec default
requestPollingTimeoutSeconds: parseInt(process.env.ONSTAR_POLL_TIMEOUT) || 60, // 60 sec default
allowCommands: _.get(process.env, 'ONSTAR_ALLOW_COMMANDS', 'true') === 'true'
}; };
logger.info('OnStar Config', {onstarConfig});
const mqttConfig = { const mqttConfig = {
host: process.env.MQTT_HOST || 'localhost', host: process.env.MQTT_HOST || 'localhost',
@ -30,165 +23,66 @@ const mqttConfig = {
port: parseInt(process.env.MQTT_PORT) || 1883, port: parseInt(process.env.MQTT_PORT) || 1883,
tls: process.env.MQTT_TLS || false, tls: process.env.MQTT_TLS || false,
prefix: process.env.MQTT_PREFIX || 'homeassistant', prefix: process.env.MQTT_PREFIX || 'homeassistant',
namePrefix: process.env.MQTT_NAME_PREFIX || '',
};
logger.info('MQTT Config', {mqttConfig});
const init = () => new Commands(OnStar.create(onstarConfig));
const getVehicles = async commands => {
logger.info('Requesting vehicles');
const vehiclesRes = await commands.getAccountVehicles();
logger.info('Vehicle request status', {status: _.get(vehiclesRes, 'status')});
const vehicles = _.map(
_.get(vehiclesRes, 'response.data.vehicles.vehicle'),
v => new Vehicle(v)
);
logger.debug('Vehicle request response', {vehicles: _.map(vehicles, v => v.toString())});
return vehicles;
}
const getCurrentVehicle = async commands => {
const vehicles = await getVehicles(commands);
const currentVeh = _.find(vehicles, v => v.vin.toLowerCase() === onstarConfig.vin.toLowerCase());
if (!currentVeh) {
throw new Error(`Configured vehicle VIN ${onstarConfig.vin} not available in account vehicles`);
}
return currentVeh;
}
const connectMQTT = async availabilityTopic => {
const url = `${mqttConfig.tls ? 'mqtts' : 'mqtt'}://${mqttConfig.host}:${mqttConfig.port}`;
const config = {
username: mqttConfig.username,
password: mqttConfig.password,
will: {topic: availabilityTopic, payload: 'false', retain: true}
};
logger.info('Connecting to MQTT', {url, config: _.omit(config, 'password')});
const client = await mqtt.connectAsync(url, config);
logger.info('Connected to MQTT');
return client;
}
const configureMQTT = async (commands, client, mqttHA) => {
if (!onstarConfig.allowCommands)
return;
client.on('message', (topic, message) => {
logger.debug('Subscription message', {topic, message});
const {command, options} = JSON.parse(message);
const cmd = commands[command];
if (!cmd) {
logger.error('Command not found', {command});
return;
}
const commandFn = cmd.bind(commands);
logger.info('Command sent', { command });
commandFn(options || {})
.then(data => {
// TODO refactor the response handling for commands
logger.info('Command completed', { command });
const responseData = _.get(data, 'response.data');
if (responseData) {
logger.info('Command response data', { responseData });
const location = _.get(data, 'response.data.commandResponse.body.location');
if (location) {
const topic = mqttHA.getStateTopic({ name: command });
// TODO create device_tracker entity. MQTT device tracker doesn't support lat/lon and mqtt_json
// doesn't have discovery
client.publish(topic,
JSON.stringify({ latitude: location.lat, longitude: location.long }), { retain: true })
.then(() => logger.info('Published location to topic.', { topic }));
}
}
})
.catch(err=> logger.error('Command error', {command, err}));
});
const topic = mqttHA.getCommandTopic();
logger.info('Subscribed to command topic', {topic});
await client.subscribe(topic);
}; };
let loop;
(async () => { (async () => {
try { try {
const commands = init(); const onStar = OnStar.create(onstarConfig);
const vehicle = await getCurrentVehicle(commands); const client = await mqtt.connectAsync(`${mqttConfig.tls
? 'mqtts' : 'mqtt'}://${mqttConfig.host}:${mqttConfig.port}`,
{ username: mqttConfig.username, password: mqttConfig.password });
const mqttHA = new MQTT(vehicle, mqttConfig.prefix, mqttConfig.namePrefix); console.log('Requesting vehicles.');
const availTopic = mqttHA.getAvailabilityTopic(); const vehiclesRes = await onStar.getAccountVehicles();
const client = await connectMQTT(availTopic); console.log(_.get(vehiclesRes, 'status'));
client.publish(availTopic, 'true', {retain: true}) const vehicles = _.map(
.then(() => logger.debug('Published availability')); _.get(vehiclesRes, 'response.data.vehicles.vehicle'),
await configureMQTT(commands, client, mqttHA); v => new Vehicle(v)
);
console.log('Vehicles returned:');
for (const v of vehicles) {
console.log(v.toString());
}
const mqttHA = new MQTT('homeassistant', vehicles[0].vin);
const configurations = new Map();
const run = async () => { const run = async () => {
const states = new Map(); // Note: the library is set to use only the configured VIN, but using multiple for future proofing.
const v = vehicle; for (const v of vehicles) {
logger.info('Requesting diagnostics'); console.log('Requesting diagnostics:')
const statsRes = await commands.diagnostics({diagnosticItem: v.getSupported()}); const statsRes = await onStar.diagnostics({
logger.info('Diagnostic request status', {status: _.get(statsRes, 'status')}); diagnosticItem: v.getSupported()
const stats = _.map( });
_.get(statsRes, 'response.data.commandResponse.body.diagnosticResponse'), console.log(_.get(statsRes, 'status'));
d => new Diagnostic(d) const stats = _.map(
); _.get(statsRes, 'response.data.commandResponse.body.diagnosticResponse'),
logger.debug('Diagnostic request response', {stats: _.map(stats, s => s.toString())}); d => new Diagnostic(d)
for (const s of stats) {
if (!s.hasElements()) {
continue;
}
// configure once, then set or update states
for (const d of s.diagnosticElements) {
const topic = mqttHA.getConfigTopic(d)
const payload = mqttHA.getConfigPayload(s, d);
configurations.set(topic, {configured: false, payload});
}
const topic = mqttHA.getStateTopic(s);
const payload = mqttHA.getStatePayload(s);
states.set(topic, payload);
}
const publishes = [];
// publish sensor configs
for (let [topic, config] of configurations) {
// configure once
if (!config.configured) {
config.configured = true;
const {payload} = config;
logger.info('Publishing message', {topic, payload});
publishes.push(
client.publish(topic, JSON.stringify(payload), {retain: true})
);
}
}
// update sensor states
for (let [topic, state] of states) {
logger.info('Publishing message', {topic, state});
publishes.push(
client.publish(topic, JSON.stringify(state), {retain: true})
); );
for (const s of stats) {
if (!s.hasElements()) {
continue;
}
// configure, then set state
for (const d of s.diagnosticElements) {
console.log(mqttHA.getConfigTopic(d));
console.log(JSON.stringify(mqttHA.getConfigPayload(s, d)));
await client.publish(mqttHA.getConfigTopic(d), JSON.stringify(mqttHA.getConfigPayload(s, d)));
}
console.log(mqttHA.getStateTopic(s));
console.log(JSON.stringify(mqttHA.getStatePayload(s)));
await client.publish(mqttHA.getStateTopic(s), JSON.stringify(mqttHA.getStatePayload(s)));
}
} }
await Promise.all(publishes);
}; };
const main = async () => run() const main = () => run()
.then(() => logger.info('Updates complete, sleeping.')) .then(() => console.log('Done, sleeping.'))
.catch(e => { .catch(e => console.error(e))
if (e instanceof Error) {
logger.error('Error', {error: _.pick(e, [
'message', 'stack',
'response.status', 'response.statusText', 'response.headers', 'response.data',
'request.method', 'request.body', 'request.contentType', 'request.headers', 'request.url'
])});
} else {
logger.error('Error', {error: e});
}
});
await main(); main();
setInterval(main, onstarConfig.refreshInterval); loop = setInterval(main, onstarConfig.refreshInterval);
} catch (e) { } catch (e) {
logger.error('Main function error.', {error: e}); console.error(e);
} }
})(); })();

View File

@ -1,12 +0,0 @@
const winston = require('winston');
const _ = require('lodash');
const logger = winston.createLogger({
level: _.get(process, 'env.LOG_LEVEL', 'info'),
format: winston.format.simple(),
// format: winston.format.json(),
transports: [new winston.transports.Console({stderrLevels: ['error']})]
})
module.exports = logger;

View File

@ -1,20 +1,9 @@
const _ = require('lodash'); // const convert = require('convert-units');
const convert = require('convert-units');
class Measurement { class Measurement {
static CONVERTABLE_UNITS = [
'°C',
'km',
'kPa',
'km/l(e)',
// Helps with conversion to Gallons.
'lit'
];
constructor(value, unit) { constructor(value, unit) {
this.value = value; this.value = value;
this.unit = Measurement.correctUnitName(unit); this.unit = Measurement.correctUnitName(unit);
this.isConvertible = _.includes(Measurement.CONVERTABLE_UNITS, this.unit);
} }
/** /**
@ -33,12 +22,10 @@ class Measurement {
case 'KPa': case 'KPa':
return 'kPa'; return 'kPa';
case 'kmple': case 'kmple':
return 'km/l(e)'; return 'km/l(e)'; // TODO check on this
case 'volts': case 'volts':
case 'Volts': case 'Volts':
return 'V'; return 'V';
case 'l':
return 'lit';
// these are states // these are states
case 'Stat': case 'Stat':
case 'N/A': case 'N/A':
@ -49,59 +36,20 @@ class Measurement {
} }
} }
/** // TODO this may not be required. Check consuming application.
* /*static convertToImperial(value, unit) {
* @param {string|number} value switch(unit) {
* @param {string} unit case 'Cel':
* @returns {string|number} const val = convert(value).from('C').to('F');
*/ return new Measurement(val, 'F');
static convertValue(value, unit) {
switch (unit) {
case '°C':
value = _.round(convert(value).from('C').to('F'));
break;
case 'km':
value = _.round(convert(value).from('km').to('mi'), 1);
break;
case 'kPa':
value = _.round(convert(value).from('kPa').to('psi'), 1);
break;
case 'km/l(e)':
// km/L = (1.609344 / 3.785411784) * MPG
value = _.round(value / (1.609344 / 3.785411784), 1);
break;
case 'lit':
value = _.round(value / 3.785411784, 1);
break;
}
return value;
}
/**
*
* @param {string} unit
* @returns {string}
*/
static convertUnit(unit) {
switch (unit) {
case '°C':
return '°F';
case 'km':
return 'mi';
case 'kPa':
return 'psi';
case 'km/l(e)':
return 'mpg(e)';
case 'lit':
return 'gal';
default: default:
return unit; return new Measurement(value, unit);
} }
} }*/
toString() { toString() {
return `${this.value}${this.unit}`; return `${this.value}${this.unit}`;
} }
} }
module.exports = Measurement; module.exports = Measurement;

View File

@ -36,11 +36,9 @@ const _ = require('lodash');
* } * }
*/ */
class MQTT { class MQTT {
constructor(vehicle, prefix = 'homeassistant', namePrefix) { constructor(prefix = 'homeassistant', instance = 'XXX') {
this.prefix = prefix; this.prefix = prefix;
this.vehicle = vehicle; this.instance = instance;
this.instance = vehicle.vin;
this.namePrefix = namePrefix
} }
static convertName(name) { static convertName(name) {
@ -58,38 +56,19 @@ class MQTT {
case 'PRIORITY CHARGE INDICATOR': case 'PRIORITY CHARGE INDICATOR':
case 'PRIORITY CHARGE STATUS': case 'PRIORITY CHARGE STATUS':
return 'binary_sensor'; return 'binary_sensor';
case 'getLocation':
return 'device_tracker';
default: default:
return 'sensor'; return 'sensor';
} }
} }
/** /**
* @param {string} name * @param {'sensor'|'binary_sensor'} type
* @returns {string}
*/
addNamePrefix(name) {
if (!this.namePrefix) return name
return `${this.namePrefix} ${name}`
}
/**
* @param {'sensor'|'binary_sensor'|'device_tracker'} type
* @returns {string} * @returns {string}
*/ */
getBaseTopic(type = 'sensor') { getBaseTopic(type = 'sensor') {
return `${this.prefix}/${type}/${this.instance}`; return `${this.prefix}/${type}/${this.instance}`;
} }
getAvailabilityTopic() {
return `${this.prefix}/${this.instance}/available`;
}
getCommandTopic() {
return `${this.prefix}/${this.instance}/command`;
}
/** /**
* *
* @param {DiagnosticElement} diag * @param {DiagnosticElement} diag
@ -141,7 +120,6 @@ class MQTT {
break; break;
default: default:
// coerce to number if possible, API uses strings :eyeroll: // coerce to number if possible, API uses strings :eyeroll:
// eslint-disable-next-line no-case-declarations
const num = _.toNumber(e.value); const num = _.toNumber(e.value);
value = _.isNaN(num) ? e.value : num; value = _.isNaN(num) ? e.value : num;
break; break;
@ -151,46 +129,19 @@ class MQTT {
return state; return state;
} }
mapBaseConfigPayload(diag, diagEl, device_class, name, attr) { mapConfigPayload(diag, diagEl, device_class, name, attr) {
name = name || MQTT.convertFriendlyName(diagEl.name); name = name || MQTT.convertFriendlyName(diagEl.name);
name = this.addNamePrefix(name); // TODO availability
// Generate the unique id from the vin and name
let unique_id = `${this.vehicle.vin}-${diagEl.name}`
unique_id = unique_id.replace(/\s+/g, '-').toLowerCase();
return { return {
device_class, device_class,
name, name,
device: {
identifiers: [this.vehicle.vin],
manufacturer: this.vehicle.make,
model: this.vehicle.year,
name: this.vehicle.toString()
},
availability_topic: this.getAvailabilityTopic(),
payload_available: 'true',
payload_not_available: 'false',
state_topic: this.getStateTopic(diag), state_topic: this.getStateTopic(diag),
unit_of_measurement: diagEl.unit,
value_template: `{{ value_json.${MQTT.convertName(diagEl.name)} }}`, value_template: `{{ value_json.${MQTT.convertName(diagEl.name)} }}`,
json_attributes_topic: _.isUndefined(attr) ? undefined : this.getStateTopic(diag), json_attributes_template: attr
json_attributes_template: attr,
unique_id: unique_id
}; };
} }
mapSensorConfigPayload(diag, diagEl, device_class, name, attr) {
name = name || MQTT.convertFriendlyName(diagEl.name);
return _.extend(
this.mapBaseConfigPayload(diag, diagEl, device_class, name, attr),
{unit_of_measurement: diagEl.unit});
}
mapBinarySensorConfigPayload(diag, diagEl, device_class, name, attr) {
name = name || MQTT.convertFriendlyName(diagEl.name);
return _.extend(
this.mapBaseConfigPayload(diag, diagEl, device_class, name, attr),
{payload_on: true, payload_off: false});
}
/** /**
* *
* @param {Diagnostic} diag * @param {Diagnostic} diag
@ -202,43 +153,31 @@ class MQTT {
case 'LIFETIME ENERGY USED': case 'LIFETIME ENERGY USED':
case 'LIFETIME EFFICIENCY': case 'LIFETIME EFFICIENCY':
case 'ELECTRIC ECONOMY': case 'ELECTRIC ECONOMY':
return this.mapSensorConfigPayload(diag, diagEl, 'energy'); return this.mapConfigPayload(diag, diagEl, 'energy');
case 'INTERM VOLT BATT VOLT': case 'INTERM VOLT BATT VOLT':
case 'EV PLUG VOLTAGE': case 'EV PLUG VOLTAGE':
return this.mapSensorConfigPayload(diag, diagEl, 'voltage'); return this.mapConfigPayload(diag, diagEl, 'voltage');
case 'HYBRID BATTERY MINIMUM TEMPERATURE': case 'HYBRID BATTERY MINIMUM TEMPERATURE':
case 'AMBIENT AIR TEMPERATURE': case 'AMBIENT AIR TEMPERATURE':
case 'AMBIENT AIR TEMPERATURE F': return this.mapConfigPayload(diag, diagEl, 'temperature');
case 'ENGINE COOLANT TEMP':
case 'ENGINE COOLANT TEMP F':
return this.mapSensorConfigPayload(diag, diagEl, 'temperature');
case 'EV BATTERY LEVEL': case 'EV BATTERY LEVEL':
return this.mapSensorConfigPayload(diag, diagEl, 'battery'); return this.mapConfigPayload(diag, diagEl, 'battery');
case 'TIRE PRESSURE LF': case 'TIRE PRESSURE LF':
return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Front', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_FRONT')}} | tojson }}`); return this.mapConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Front', `{{ {"recommendation": value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_FRONT')}} | tojson }}`);
case 'TIRE PRESSURE LF PSI':
return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Front PSI', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_FRONT_PSI')}} | tojson }}`);
case 'TIRE PRESSURE LR': case 'TIRE PRESSURE LR':
return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Rear', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_REAR')}} | tojson }}`); return this.mapConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Rear', `{{ {"recommendation": value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_REAR')}} | tojson }}`);
case 'TIRE PRESSURE LR PSI':
return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Rear PSI', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_REAR_PSI')}} | tojson }}`);
case 'TIRE PRESSURE RF': case 'TIRE PRESSURE RF':
return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Front', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_FRONT')}} | tojson }}`); return this.mapConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Front', `{{ {"recommendation": value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_FRONT')}} | tojson }}`);
case 'TIRE PRESSURE RF PSI':
return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Front PSI', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_FRONT_PSI')}} | tojson }}`);
case 'TIRE PRESSURE RR': case 'TIRE PRESSURE RR':
return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Rear', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_REAR')}} | tojson }}`); return this.mapConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Rear', `{{ {"recommendation": value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_REAR')}} | tojson }}`);
case 'TIRE PRESSURE RR PSI':
return this.mapSensorConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Rear PSI', `{{ {'recommendation': value_json.${MQTT.convertName('TIRE_PRESSURE_PLACARD_REAR_PSI')}} | tojson }}`);
// binary sensor // binary sensor
case 'EV PLUG STATE': // unplugged/plugged case 'EV PLUG STATE': // unplugged/plugged
return this.mapBinarySensorConfigPayload(diag, diagEl, 'plug'); return this.mapConfigPayload(diag, diagEl, 'plug');
case 'EV CHARGE STATE': // not_charging/charging case 'EV CHARGE STATE': // not_charging/charging
return this.mapBinarySensorConfigPayload(diag, diagEl, 'battery_charging'); return this.mapConfigPayload(diag, diagEl, 'battery_charging');
// binary_sensor, but no applicable device_class // binary_sensor, but no applicable device_class
case 'PRIORITY CHARGE INDICATOR': // FALSE/TRUE case 'PRIORITY CHARGE INDICATOR': // FALSE/TRUE
case 'PRIORITY CHARGE STATUS': // NOT_ACTIVE/ACTIVE case 'PRIORITY CHARGE STATUS': // NOT_ACTIVE/ACTIVE
return this.mapBinarySensorConfigPayload(diag, diagEl);
// no device class, camel case name // no device class, camel case name
case 'EV RANGE': case 'EV RANGE':
case 'ODOMETER': case 'ODOMETER':
@ -247,7 +186,7 @@ class MQTT {
case 'LIFETIME MPGE': case 'LIFETIME MPGE':
case 'CHARGER POWER LEVEL': case 'CHARGER POWER LEVEL':
default: default:
return this.mapSensorConfigPayload(diag, diagEl); return this.mapConfigPayload(diag, diagEl);
} }
} }
} }

View File

@ -27,7 +27,7 @@ class Vehicle {
} }
toString() { toString() {
return `${this.year} ${this.make} ${this.model}`; return `${this.year} ${this.make} ${this.model} ${this.vin}`;
} }
} }

View File

@ -1,7 +1,7 @@
const assert = require('assert'); const assert = require('assert');
const _ = require('lodash'); const _ = require('lodash');
const { Diagnostic, DiagnosticElement } = require('../src/diagnostic'); const { Diagnostic } = require('../src/diagnostic');
const apiResponse = require('./diagnostic.sample.json'); const apiResponse = require('./diagnostic.sample.json');
describe('Diagnostics', () => { describe('Diagnostics', () => {
@ -12,13 +12,13 @@ describe('Diagnostics', () => {
it('should parse a diagnostic response', () => { it('should parse a diagnostic response', () => {
assert.strictEqual(d.name, 'AMBIENT AIR TEMPERATURE'); assert.strictEqual(d.name, 'AMBIENT AIR TEMPERATURE');
assert.strictEqual(d.diagnosticElements.length, 2); assert.strictEqual(d.diagnosticElements.length, 1);
}); });
it('should toString() correctly', () => { it('should toString() correctly', () => {
const output = d.toString().trimEnd(); const output = d.toString().trimEnd();
const lines = output.split(/\r\n|\r|\n/); const lines = output.split(/\r\n|\r|\n/);
assert.strictEqual(lines.length, 3); assert.strictEqual(lines.length, 2);
assert.strictEqual(lines[0], 'AMBIENT AIR TEMPERATURE:'); assert.strictEqual(lines[0], 'AMBIENT AIR TEMPERATURE:');
}); });
}); });
@ -28,19 +28,15 @@ describe('Diagnostics', () => {
it('should parse a diagnostic element', () => { it('should parse a diagnostic element', () => {
assert.strictEqual(d.name, 'TIRE PRESSURE'); assert.strictEqual(d.name, 'TIRE PRESSURE');
assert.ok(_.isArray(d.diagnosticElements)); assert.ok(_.isArray(d.diagnosticElements));
assert.strictEqual(d.diagnosticElements.length, 12); assert.strictEqual(d.diagnosticElements.length, 6);
}); });
it('should toString() correctly', () => { it('should toString() correctly', () => {
const output = d.toString().trimEnd(); const output = d.toString().trimEnd();
const lines = output.split(/\r\n|\r|\n/); const lines = output.split(/\r\n|\r|\n/);
assert.strictEqual(lines.length, 13); assert.strictEqual(lines.length, 7);
assert.strictEqual(lines[0], 'TIRE PRESSURE:'); assert.strictEqual(lines[0], 'TIRE PRESSURE:');
assert.strictEqual(lines[1], ' TIRE PRESSURE LF: 240.0kPa'); assert.strictEqual(lines[1], ' TIRE PRESSURE LF: 240.0kPa');
}); });
it('should strip non-alpha chars', () => {
assert.strictEqual(DiagnosticElement.convertName('TEMP', '°F'), 'TEMP F');
});
}); });
}); });

View File

@ -3,13 +3,11 @@ const _ = require('lodash');
const { Diagnostic } = require('../src/diagnostic'); const { Diagnostic } = require('../src/diagnostic');
const MQTT = require('../src/mqtt'); const MQTT = require('../src/mqtt');
const Vehicle = require('../src/vehicle');
const apiResponse = require('./diagnostic.sample.json'); const apiResponse = require('./diagnostic.sample.json');
describe('MQTT', () => { describe('MQTT', () => {
let mqtt; let mqtt;
let vehicle = new Vehicle({make: 'foo', model: 'bar', vin: 'XXX', year: 2020}); beforeEach(() => mqtt = new MQTT());
beforeEach(() => mqtt = new MQTT(vehicle));
it('should set defaults', () => { it('should set defaults', () => {
assert.strictEqual(mqtt.prefix, 'homeassistant'); assert.strictEqual(mqtt.prefix, 'homeassistant');
@ -28,27 +26,8 @@ describe('MQTT', () => {
assert.strictEqual(MQTT.convertFriendlyName('FOO BAR'), 'Foo Bar'); assert.strictEqual(MQTT.convertFriendlyName('FOO BAR'), 'Foo Bar');
}); });
it('should determine sensor types', () => {
assert.strictEqual(MQTT.determineSensorType('EV CHARGE STATE'), 'binary_sensor');
assert.strictEqual(MQTT.determineSensorType('EV PLUG STATE'), 'binary_sensor');
assert.strictEqual(MQTT.determineSensorType('PRIORITY CHARGE INDICATOR'), 'binary_sensor');
assert.strictEqual(MQTT.determineSensorType('PRIORITY CHARGE STATUS'), 'binary_sensor');
assert.strictEqual(MQTT.determineSensorType('getLocation'), 'device_tracker');
assert.strictEqual(MQTT.determineSensorType('foo'), 'sensor');
assert.strictEqual(MQTT.determineSensorType(''), 'sensor');
});
describe('topics', () => { describe('topics', () => {
let d; let d;
it('should generate availability topic', () => {
assert.strictEqual(mqtt.getAvailabilityTopic(), 'homeassistant/XXX/available');
});
it('should generate command topic', () => {
assert.strictEqual(mqtt.getCommandTopic(), 'homeassistant/XXX/command');
});
describe('sensor', () => { describe('sensor', () => {
beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[0]'))); beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[0]')));
@ -56,7 +35,7 @@ describe('MQTT', () => {
assert.strictEqual(mqtt.getConfigTopic(d), 'homeassistant/sensor/XXX/ambient_air_temperature/config'); assert.strictEqual(mqtt.getConfigTopic(d), 'homeassistant/sensor/XXX/ambient_air_temperature/config');
}); });
it('should generate state topics', () => { it('should generate state topics', () => {
assert.strictEqual(mqtt.getStateTopic(d), 'homeassistant/sensor/XXX/ambient_air_temperature/state'); assert.strictEqual(mqtt.getStateTopic(d, d.diagnosticElements[0]), 'homeassistant/sensor/XXX/ambient_air_temperature/state');
}); });
}); });
@ -77,52 +56,17 @@ describe('MQTT', () => {
beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[0]'))); beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[0]')));
it('should generate config payloads', () => { it('should generate config payloads', () => {
assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[0]), { assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[0]), {
availability_topic: 'homeassistant/XXX/available',
device: {
identifiers: [
'XXX'
],
manufacturer: 'foo',
model: 2020,
name: '2020 foo bar'
},
device_class: 'temperature', device_class: 'temperature',
json_attributes_template: undefined, json_attributes_template: undefined,
name: 'Ambient Air Temperature', name: 'Ambient Air Temperature',
payload_available: 'true',
payload_not_available: 'false',
state_topic: 'homeassistant/sensor/XXX/ambient_air_temperature/state', state_topic: 'homeassistant/sensor/XXX/ambient_air_temperature/state',
unique_id: 'xxx-ambient-air-temperature',
json_attributes_topic: undefined,
unit_of_measurement: '°C', unit_of_measurement: '°C',
value_template: '{{ value_json.ambient_air_temperature }}' value_template: '{{ value_json.ambient_air_temperature }}'
}); });
assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[1]), {
availability_topic: 'homeassistant/XXX/available',
device: {
identifiers: [
'XXX'
],
manufacturer: 'foo',
model: 2020,
name: '2020 foo bar'
},
device_class: 'temperature',
json_attributes_template: undefined,
name: 'Ambient Air Temperature F',
payload_available: 'true',
payload_not_available: 'false',
state_topic: 'homeassistant/sensor/XXX/ambient_air_temperature/state',
unique_id: 'xxx-ambient-air-temperature-f',
json_attributes_topic: undefined,
unit_of_measurement: '°F',
value_template: '{{ value_json.ambient_air_temperature_f }}'
});
}); });
it('should generate state payloads', () => { it('should generate state payloads', () => {
assert.deepStrictEqual(mqtt.getStatePayload(d), { assert.deepStrictEqual(mqtt.getStatePayload(d), {
ambient_air_temperature: 15, ambient_air_temperature: 15
ambient_air_temperature_f: 59
}); });
}); });
}); });
@ -131,33 +75,19 @@ describe('MQTT', () => {
beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[3]'))); beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[3]')));
it('should generate config payloads', () => { it('should generate config payloads', () => {
assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[1]), { assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[1]), {
availability_topic: 'homeassistant/XXX/available', device_class: undefined,
device: { json_attributes_template: undefined,
identifiers: [ name: 'Priority Charge Indicator',
'XXX' state_topic: 'homeassistant/binary_sensor/XXX/ev_charge_state/state',
], unit_of_measurement: undefined,
manufacturer: 'foo', value_template: '{{ value_json.priority_charge_indicator }}'
model: 2020,
name: '2020 foo bar'
},
device_class: undefined,
json_attributes_template: undefined,
name: 'Priority Charge Indicator',
payload_available: 'true',
payload_not_available: 'false',
payload_off: false,
payload_on: true,
state_topic: 'homeassistant/binary_sensor/XXX/ev_charge_state/state',
unique_id: 'xxx-priority-charge-indicator',
json_attributes_topic: undefined,
value_template: '{{ value_json.priority_charge_indicator }}'
}); });
}); });
it('should generate state payloads', () => { it('should generate state payloads', () => {
assert.deepStrictEqual(mqtt.getStatePayload(d), { assert.deepStrictEqual(mqtt.getStatePayload(d), {
ev_charge_state: false, ev_charge_state: false,
priority_charge_indicator: false, priority_charge_indicator: false,
priority_charge_status: false priority_charge_status: false
}); });
}); });
}); });
@ -166,23 +96,10 @@ describe('MQTT', () => {
beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[8]'))); beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[8]')));
it('should generate payloads with an attribute', () => { it('should generate payloads with an attribute', () => {
assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[0]), { assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[0]), {
availability_topic: 'homeassistant/XXX/available',
device: {
identifiers: [
'XXX'
],
manufacturer: 'foo',
model: 2020,
name: '2020 foo bar'
},
device_class: 'pressure', device_class: 'pressure',
json_attributes_template: "{{ {'recommendation': value_json.tire_pressure_placard_front} | tojson }}", json_attributes_template: '{{ {"recommendation": value_json.tire_pressure_placard_front} | tojson }}',
name: 'Tire Pressure: Left Front', name: 'Tire Pressure: Left Front',
payload_available: 'true',
payload_not_available: 'false',
state_topic: 'homeassistant/sensor/XXX/tire_pressure/state', state_topic: 'homeassistant/sensor/XXX/tire_pressure/state',
unique_id: 'xxx-tire-pressure-lf',
json_attributes_topic: 'homeassistant/sensor/XXX/tire_pressure/state',
unit_of_measurement: 'kPa', unit_of_measurement: 'kPa',
value_template: '{{ value_json.tire_pressure_lf }}' value_template: '{{ value_json.tire_pressure_lf }}'
}); });

View File

@ -36,6 +36,6 @@ describe('Vehicle', () => {
}); });
it('should toString() correctly', () => { it('should toString() correctly', () => {
assert.strictEqual(v.toString(), '2020 Chevrolet Bolt EV') assert.strictEqual(v.toString(), '2020 Chevrolet Bolt EV foobarVIN')
}); });
}); });