Compare commits

..

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

22 changed files with 848 additions and 10016 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.3.4
- name: Docker meta - name: Docker meta
id: docker_meta id: docker_meta
uses: docker/metadata-action@v4 uses: crazy-max/ghaction-docker-meta@v3.3.0
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.5
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.2.0
- 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.9.0
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.5.0
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.3.4
- name: Docker meta - name: Docker meta
id: docker_meta id: docker_meta
uses: docker/metadata-action@v4 uses: crazy-max/ghaction-docker-meta@v3.3.0
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.2.0
- 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.9.0
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.5.0
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,11 @@
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/"] COPY ["package-lock.json", "/app/"]
RUN npm ci --omit=dev --no-fund RUN npm install --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

@ -13,7 +13,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,7 +51,7 @@ 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, this is 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 and screenshots see [HA-MQTT.md](HA-MQTT.md).
@ -67,3 +67,7 @@ MQTT auto discovery is enabled. For further integrations and screenshots see [HA
`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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 97 KiB

10108
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.1.0",
"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"
}, },
@ -29,23 +28,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.21",
"onstarjs": "^2.3.16", "onstarjs": "^2.2.1",
"uuid": "^9.0.0", "uuid": "^8.3.2"
"winston": "^3.8.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.19.1", "mocha": "^9.0.0",
"@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

@ -109,10 +109,6 @@ class Commands {
return this.onstar.setChargingProfile(); return this.onstar.setChargingProfile();
} }
async getLocation() {
return this.onstar.location();
}
async diagnostics({diagnosticItem = [ async diagnostics({diagnosticItem = [
Commands.CONSTANTS.DIAGNOSTICS.ODOMETER, Commands.CONSTANTS.DIAGNOSTICS.ODOMETER,
Commands.CONSTANTS.DIAGNOSTICS.TIRE_PRESSURE, Commands.CONSTANTS.DIAGNOSTICS.TIRE_PRESSURE,

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

@ -1,3 +1,4 @@
const OnStar = require('onstarjs'); const OnStar = require('onstarjs');
const mqtt = require('async-mqtt'); const mqtt = require('async-mqtt');
const uuidv4 = require('uuid').v4; const uuidv4 = require('uuid').v4;
@ -6,8 +7,6 @@ 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 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 +14,10 @@ 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 allowCommands: _.toLower(_.get(process, 'env.ONSTAR_ALLOW_COMMANDS', 'true')) === 'true'
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,109 +26,72 @@ 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)); let loop, commands, vehicles;
const getVehicles = async commands => { const init = async () => {
logger.info('Requesting vehicles'); commands = new Commands(OnStar.create(onstarConfig));
console.log('Requesting vehicles.');
const vehiclesRes = await commands.getAccountVehicles(); const vehiclesRes = await commands.getAccountVehicles();
logger.info('Vehicle request status', {status: _.get(vehiclesRes, 'status')}); console.log(_.get(vehiclesRes, 'status'));
const vehicles = _.map( vehicles = _.map(
_.get(vehiclesRes, 'response.data.vehicles.vehicle'), _.get(vehiclesRes, 'response.data.vehicles.vehicle'),
v => new Vehicle(v) v => new Vehicle(v)
); );
logger.debug('Vehicle request response', {vehicles: _.map(vehicles, v => v.toString())}); console.log('Vehicles returned:');
return vehicles; for (const v of vehicles) {
console.log(v.toString());
}
} }
const getCurrentVehicle = async commands => { const connectMQTT = async () => {
const vehicles = await getVehicles(commands); const mqttHA = new MQTT(vehicles[0], 'homeassistant');
const currentVeh = _.find(vehicles, v => v.vin.toLowerCase() === onstarConfig.vin.toLowerCase()); const availTopic = mqttHA.getAvailabilityTopic();
if (!currentVeh) { const client = await mqtt.connectAsync(`${mqttConfig.tls
throw new Error(`Configured vehicle VIN ${onstarConfig.vin} not available in account vehicles`); ? 'mqtts' : 'mqtt'}://${mqttConfig.host}:${mqttConfig.port}`, {
}
return currentVeh;
}
const connectMQTT = async availabilityTopic => {
const url = `${mqttConfig.tls ? 'mqtts' : 'mqtt'}://${mqttConfig.host}:${mqttConfig.port}`;
const config = {
username: mqttConfig.username, username: mqttConfig.username,
password: mqttConfig.password, password: mqttConfig.password,
will: {topic: availabilityTopic, payload: 'false', retain: true} will: {topic: availTopic, 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;
if (onstarConfig.allowCommands) {
client.on('message', (topic, message) => { client.on('message', (topic, message) => {
logger.debug('Subscription message', {topic, message}); console.log(`Subscription message: ${topic} ${message}`);
const {command, options} = JSON.parse(message); const {command, options} = JSON.parse(message);
const cmd = commands[command]; const commandFn = commands[command].bind(commands);
if (!cmd) {
logger.error('Command not found', {command});
return;
}
const commandFn = cmd.bind(commands);
logger.info('Command sent', { command });
commandFn(options || {}) commandFn(options || {})
.then(data => { .then(() => console.log(`Command completed: ${command}`))
// TODO refactor the response handling for commands .catch(err=> console.error(`Command error: ${command} ${err}`));
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(); const topic = mqttHA.getCommandTopic();
logger.info('Subscribed to command topic', {topic}); console.log(`Subscribed to: ${topic}`);
await client.subscribe(topic); await client.subscribe(topic);
}
await client.publish(availTopic, 'true', {retain: true});
return {mqttHA, client};
}; };
(async () => { (async () => {
try { try {
const commands = init(); await init();
const vehicle = await getCurrentVehicle(commands);
const mqttHA = new MQTT(vehicle, mqttConfig.prefix, mqttConfig.namePrefix); const {mqttHA, client} = await connectMQTT();
const availTopic = mqttHA.getAvailabilityTopic();
const client = await connectMQTT(availTopic);
client.publish(availTopic, 'true', {retain: true})
.then(() => logger.debug('Published availability'));
await configureMQTT(commands, client, mqttHA);
const configurations = new Map(); const configurations = new Map();
const run = async () => { const run = async () => {
const states = new Map(); const states = new Map();
const v = vehicle; const v = vehicles[0];
logger.info('Requesting diagnostics'); console.log('Requesting diagnostics:')
const statsRes = await commands.diagnostics({diagnosticItem: v.getSupported()}); const statsRes = await commands.diagnostics({
logger.info('Diagnostic request status', {status: _.get(statsRes, 'status')}); diagnosticItem: v.getSupported()
});
console.log(_.get(statsRes, 'status'));
const stats = _.map( const stats = _.map(
_.get(statsRes, 'response.data.commandResponse.body.diagnosticResponse'), _.get(statsRes, 'response.data.commandResponse.body.diagnosticResponse'),
d => new Diagnostic(d) d => new Diagnostic(d)
); );
logger.debug('Diagnostic request response', {stats: _.map(stats, s => s.toString())});
for (const s of stats) { for (const s of stats) {
if (!s.hasElements()) { if (!s.hasElements()) {
@ -149,46 +108,30 @@ const configureMQTT = async (commands, client, mqttHA) => {
const payload = mqttHA.getStatePayload(s); const payload = mqttHA.getStatePayload(s);
states.set(topic, payload); states.set(topic, payload);
} }
const publishes = []; // publish configs
// publish sensor configs
for (let [topic, config] of configurations) { for (let [topic, config] of configurations) {
// configure once // configure once
if (!config.configured) { if (!config.configured) {
config.configured = true; config.configured = true;
const {payload} = config; const {payload} = config;
logger.info('Publishing message', {topic, payload}); console.log(`${topic} ${JSON.stringify(payload)}`);
publishes.push( await client.publish(topic, JSON.stringify(payload), {retain: true});
client.publish(topic, JSON.stringify(payload), {retain: true})
);
} }
} }
// update sensor states // update states
for (let [topic, state] of states) { for (let [topic, state] of states) {
logger.info('Publishing message', {topic, state}); console.log(`${topic} ${JSON.stringify(state)}`);
publishes.push( await client.publish(topic, JSON.stringify(state), {retain: true});
client.publish(topic, JSON.stringify(state), {retain: true})
);
} }
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(); await 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,55 +36,16 @@ class Measurement {
} }
} }
/** // TODO this may not be required. Check consuming application.
* /*static convertToImperial(value, unit) {
* @param {string|number} value
* @param {string} unit
* @returns {string|number}
*/
static convertValue(value, unit) {
switch(unit) { switch(unit) {
case '°C': case 'Cel':
value = _.round(convert(value).from('C').to('F')); const val = convert(value).from('C').to('F');
break; return new Measurement(val, 'F');
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}`;

View File

@ -36,11 +36,10 @@ const _ = require('lodash');
* } * }
*/ */
class MQTT { class MQTT {
constructor(vehicle, prefix = 'homeassistant', namePrefix) { constructor(vehicle, prefix = 'homeassistant') {
this.prefix = prefix; this.prefix = prefix;
this.vehicle = vehicle; this.vehicle = vehicle;
this.instance = vehicle.vin; this.instance = vehicle.vin;
this.namePrefix = namePrefix
} }
static convertName(name) { static convertName(name) {
@ -58,24 +57,13 @@ 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') {
@ -141,7 +129,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;
@ -153,10 +140,6 @@ class MQTT {
mapBaseConfigPayload(diag, diagEl, device_class, name, attr) { mapBaseConfigPayload(diag, diagEl, device_class, name, attr) {
name = name || MQTT.convertFriendlyName(diagEl.name); name = name || MQTT.convertFriendlyName(diagEl.name);
name = this.addNamePrefix(name);
// 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,
@ -172,8 +155,7 @@ class MQTT {
state_topic: this.getStateTopic(diag), state_topic: this.getStateTopic(diag),
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_topic: _.isUndefined(attr) ? undefined : this.getStateTopic(diag),
json_attributes_template: attr, json_attributes_template: attr
unique_id: unique_id
}; };
} }
@ -208,28 +190,17 @@ class MQTT {
return this.mapSensorConfigPayload(diag, diagEl, 'voltage'); return this.mapSensorConfigPayload(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':
case 'ENGINE COOLANT TEMP':
case 'ENGINE COOLANT TEMP F':
return this.mapSensorConfigPayload(diag, diagEl, 'temperature'); return this.mapSensorConfigPayload(diag, diagEl, 'temperature');
case 'EV BATTERY LEVEL': case 'EV BATTERY LEVEL':
return this.mapSensorConfigPayload(diag, diagEl, 'battery'); return this.mapSensorConfigPayload(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.mapSensorConfigPayload(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.mapSensorConfigPayload(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.mapSensorConfigPayload(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.mapSensorConfigPayload(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.mapBinarySensorConfigPayload(diag, diagEl, 'plug');

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

@ -28,16 +28,6 @@ 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;
@ -92,37 +82,14 @@ describe('MQTT', () => {
payload_available: 'true', payload_available: 'true',
payload_not_available: 'false', 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, 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
}); });
}); });
}); });
@ -148,7 +115,6 @@ describe('MQTT', () => {
payload_off: false, payload_off: false,
payload_on: true, payload_on: true,
state_topic: 'homeassistant/binary_sensor/XXX/ev_charge_state/state', state_topic: 'homeassistant/binary_sensor/XXX/ev_charge_state/state',
unique_id: 'xxx-priority-charge-indicator',
json_attributes_topic: undefined, json_attributes_topic: undefined,
value_template: '{{ value_json.priority_charge_indicator }}' value_template: '{{ value_json.priority_charge_indicator }}'
}); });
@ -181,7 +147,6 @@ describe('MQTT', () => {
payload_available: 'true', payload_available: 'true',
payload_not_available: 'false', 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', 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 }}'