diff --git a/HA-MQTT.md b/HA-MQTT.md new file mode 100644 index 0000000..7ecc33f --- /dev/null +++ b/HA-MQTT.md @@ -0,0 +1,3 @@ +Sample configs for MQTT Home Assistant integration. + +- Utility meter that resets for monthly LIFETIME ENERGY USED \ No newline at end of file diff --git a/README.md b/README.md index 8d1c8b6..85525a8 100644 --- a/README.md +++ b/README.md @@ -1 +1,6 @@ - +# onstar2mqtt +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. + +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. +### Home Assistant configuration templates +Auto discovery is enabled, for further integrations see [HA-MQTT.md](HA-MQTT.md). \ No newline at end of file diff --git a/package.json b/package.json index ca9b15d..73d5bf9 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "uuid": "^8.3.1" }, "devDependencies": { - "mocha": "^8.2.1" + "mocha": "^8.2.1", + "nyc": "^15.1.0" } } diff --git a/src/diagnostic.js b/src/diagnostic.js index 26ad6b3..90c79a3 100644 --- a/src/diagnostic.js +++ b/src/diagnostic.js @@ -2,6 +2,9 @@ const _ = require('lodash'); const Measurement = require('./measurement'); +/** + * + */ class Diagnostic { constructor(diagResponse) { this.name = diagResponse.name; @@ -12,11 +15,8 @@ class Diagnostic { this.diagnosticElements = _.map(validEle, e => new DiagnosticElement(e)); } - elementsToString(diag) { - const validEle = _.filter(diag, d => _.has(d, 'value') && _.has(d, 'unit')); - let output = ''; - _.forEach(validEle, e => output += ` ${e.name} ${e.value}${e.unit}\n`); - return output; + hasElements() { + return this.diagnosticElements.length >= 1; } toString() { @@ -28,10 +28,22 @@ class Diagnostic { class DiagnosticElement { constructor(ele) { - this.name = ele.name; + this._name = ele.name; this.measurement = new Measurement(ele.value, ele.unit); } + get name() { + return this._name; + } + + get value() { + return this.measurement.value; + } + + get unit() { + return this.measurement.unit; + } + toString() { return `${this.name}: ${this.measurement.toString()}`; } diff --git a/src/measurement.js b/src/measurement.js index 02bc380..0eec7f5 100644 --- a/src/measurement.js +++ b/src/measurement.js @@ -1,4 +1,4 @@ -const convert = require('convert-units'); +// const convert = require('convert-units'); class Measurement { constructor(value, unit) { @@ -6,23 +6,32 @@ class Measurement { this.unit = Measurement.correctUnitName(unit); } + /** + * Would be nice if GM used sane unit labels. + * @param {string} unit + * @returns {string} + */ static correctUnitName(unit) { switch (unit) { - case 'Cel': return 'C'; + case 'Cel': return '°C'; case 'kwh': return 'kWh'; case 'KM': return 'km'; case 'KPa': return 'kPa'; - case 'kmple': return 'km/l e'; // TODO check on this - case 'volts': return 'V'; + case 'kmple': return 'km/l(e)'; // TODO check on this + case 'volts': + case 'Volts': + return 'V'; + // these are states case 'Stat': case 'N/A': - return ''; + return undefined; + default: return unit; } } // TODO this may not be required. Check consuming application. - static convertToImperial(value, unit) { + /*static convertToImperial(value, unit) { switch(unit) { case 'Cel': const val = convert(value).from('C').to('F'); @@ -30,7 +39,7 @@ class Measurement { default: return new Measurement(value, unit); } - } + }*/ toString() { return `${this.value}${this.unit}`; diff --git a/src/mqtt.js b/src/mqtt.js index 2707baf..885c541 100644 --- a/src/mqtt.js +++ b/src/mqtt.js @@ -1,4 +1,181 @@ +const _ = require('lodash'); +/** + * Supports Home Assistant MQTT Discovery (https://www.home-assistant.io/docs/mqtt/discovery/) + * + * Supplies sensor configuration data and initialize sensors in HA. + * + * Topic format: prefix/type/instance/name + * Examples: + * - homeassistant/sensor/VIN/TIRE_PRESSURE/state -- Diagnostic + * - payload: { + * TIRE_PRESSURE_LF: 244.0, + * TIRE_PRESSURE_LR: 240.0, + * TIRE_PRESSURE_PLACARD_FRONT: 262.0, + * TIRE_PRESSURE_PLACARD_REAR: 262.0, + * TIRE_PRESSURE_RF: 240.0, + * TIRE_PRESSURE_RR: 236.0, + * } + * - homeassistant/sensor/VIN/TIRE_PRESSURE_LF/config -- Diagnostic Element + * - payload: { + * device_class: "pressure", + * name: "Tire Pressure: Left Front", + * state_topic: "homeassistant/sensor/VIN/TIRE_PRESSURE/state", + * unit_of_measurement: "kPa", + * value_template: "{{ value_json.TIRE_PRESSURE_LF }}", + * json_attributes_template: "{{ {'recommendation': value_json.TIRE_PRESSURE_PLACARD_FRONT} | tojson }}" + * } + * - homeassistant/sensor/VIN/TIRE_PRESSURE_RR/config -- Diagnostic Element + * - payload: { + * device_class: "pressure", + * name: "Tire Pressure: Right Rear", + * state_topic: "homeassistant/sensor/VIN/TIRE_PRESSURE/state", + * unit_of_measurement: "kPa", + * value_template: "{{ value_json.TIRE_PRESSURE_RR }}", + * json_attributes_template: "{{ {'recommendation': value_json.TIRE_PRESSURE_PLACARD_REAR} | tojson }}" + * } + */ class MQTT { - + constructor(prefix = 'homeassistant', instance = 'XXX') { + this.prefix = prefix; + this.instance = instance; + } + + static convertName(name) { + return _.toLower(_.replace(name, / /g, '_')); + } + + static convertFriendlyName(name) { + return _.startCase(_.lowerCase(name)); + } + + /** + * @param {'sensor'|'binary_sensor'} type + * @returns {string} + */ + getBaseTopic(type = 'sensor') { + return `${this.prefix}/${type}/${this.instance}`; + } + + /** + * + * @param {DiagnosticElement} diagnostic + */ + getConfigTopic(diagnostic) { + return `${this.getBaseTopic()}/${MQTT.convertName(diagnostic.name)}/config`; + } + + /** + * + * @param {DiagnosticElement} diagEl + * @param {'sensor'|'binary_sensor'} sensorType + */ + getStateTopic(diagEl, sensorType = 'sensor') { + return `${this.getBaseTopic(sensorType)}/${MQTT.convertName(diagEl.name)}/state`; + } + + /** + * + * @param {Diagnostic} diagnostic + * @param {DiagnosticElement} diagnosticElement + */ + getConfigPayload(diagnostic, diagnosticElement) { + return this.getConfigMapping(diagnostic, diagnosticElement); + } + + /** + * Return the state payload for this diagnostic + * @param {Diagnostic} diagnostic + */ + getStatePayload(diagnostic) { + const state = {}; + _.forEach(diagnostic.diagnosticElements, e => { + // massage the binary_sensor values + let value; + switch(e.name) { + case 'EV PLUG STATE': // unplugged/plugged + value = e.value === 'plugged'; + break; + case 'EV CHARGE STATE': // not_charging/charging + value = e.value === 'charging'; + break; + case 'PRIORITY CHARGE INDICATOR': // FALSE/TRUE + value = e.value === 'TRUE'; + break; + case 'PRIORITY CHARGE STATUS': // NOT_ACTIVE/ACTIVE + value = e.value === 'ACTIVE'; + break; + default: + // coerce to number if possible, API uses strings :eyeroll: + const num = _.toNumber(e.value); + value = _.isNaN(num) ? e.value : num; + break; + } + state[MQTT.convertName(e.name)] = value; + }); + return state; + } + + mapConfigPayload(diag, diagEl, device_class, name, sensorType = 'sensor', attr) { + name = name || MQTT.convertFriendlyName(diagEl.name); + // TODO availability + return { + device_class, + name, + state_topic: this.getStateTopic(diag, sensorType), + unit_of_measurement: diagEl.unit, + value_template: `{{ value_json.${MQTT.convertName(diagEl.name)} }`, + json_attributes_template: attr + }; + } + + /** + * + * @param {Diagnostic} diag + * @param {DiagnosticElement} diagEl + */ + getConfigMapping(diag, diagEl) { + // TODO: this sucks, find a better way to map these diagnostics and their elements for discovery. + switch (diagEl.name) { + case 'LIFETIME ENERGY USED': + case 'LIFETIME EFFICIENCY': + case 'ELECTRIC ECONOMY': + return this.mapConfigPayload(diag, diagEl, 'energy'); + case 'INTERM VOLT BATT VOLT': + case 'EV PLUG VOLTAGE': + return this.mapConfigPayload(diag, diagEl, 'voltage'); + case 'HYBRID BATTERY MINIMUM TEMPERATURE': + case 'AMBIENT AIR TEMPERATURE': + return this.mapConfigPayload(diag, diagEl, 'temperature'); + case 'EV BATTERY LEVEL': + return this.mapConfigPayload(diag, diagEl, 'battery'); + case 'TIRE PRESSURE LF': + return this.mapConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Front', 'sensor', "{{ {'recommendation': value_json.TIRE_PRESSURE_PLACARD_FRONT} | tojson }}"); + case 'TIRE PRESSURE LR': + return this.mapConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Left Rear', 'sensor', "{{ {'recommendation': value_json.TIRE_PRESSURE_PLACARD_FRONT} | tojson }}"); + case 'TIRE PRESSURE RF': + return this.mapConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Front', 'sensor', "{{ {'recommendation': value_json.TIRE_PRESSURE_PLACARD_REAR} | tojson }}"); + case 'TIRE PRESSURE RR': + return this.mapConfigPayload(diag, diagEl, 'pressure', 'Tire Pressure: Right Rear', 'sensor', "{{ {'recommendation': value_json.TIRE_PRESSURE_PLACARD_REAR} | tojson }}"); + // binary sensor + case 'EV PLUG STATE': // unplugged/plugged + return this.mapConfigPayload(diag, diagEl, 'plug', undefined, 'binary_sensor'); + case 'EV CHARGE STATE': // not_charging/charging + return this.mapConfigPayload(diag, diagEl, 'battery_charging', undefined, 'binary_sensor'); + case 'PRIORITY CHARGE INDICATOR': // FALSE/TRUE + case 'PRIORITY CHARGE STATUS': // NOT_ACTIVE/ACTIVE + return this.mapConfigPayload(diag, diagEl, undefined, undefined, 'binary_sensor'); + // no device class, camel case name + case 'EV RANGE': + case 'ODOMETER': + case 'LAST TRIP TOTAL DISTANCE': + case 'LAST TRIP ELECTRIC ECON': + case 'LIFETIME MPGE': + case 'CHARGER POWER LEVEL': + default: + return this.mapConfigPayload(diag, diagEl); + } + } } + +module.exports = MQTT; \ No newline at end of file diff --git a/test/mqtt.spec.js b/test/mqtt.spec.js new file mode 100644 index 0000000..acc2f1e --- /dev/null +++ b/test/mqtt.spec.js @@ -0,0 +1,95 @@ +const assert = require('assert'); +const _ = require('lodash'); + +const { Diagnostic } = require('../src/diagnostic'); +const MQTT = require('../src/mqtt'); +const apiResponse = require('./diagnostic.sample.json'); + +describe('MQTT', () => { + let mqtt; + beforeEach(() => mqtt = new MQTT()); + + it('should set defaults', () => { + assert.strictEqual(mqtt.prefix, 'homeassistant'); + assert.strictEqual(mqtt.instance, 'XXX'); + }); + + it('should convert names for mqtt topics', () => { + assert.strictEqual(MQTT.convertName('foo bar'), 'foo_bar'); + assert.strictEqual(MQTT.convertName('foo bar bazz'), 'foo_bar_bazz'); + assert.strictEqual(MQTT.convertName('FOO BAR'), 'foo_bar'); + assert.strictEqual(MQTT.convertName('FOO BAR bazz'), 'foo_bar_bazz'); + }); + + it('should convert names to be human readable', () => { + assert.strictEqual(MQTT.convertFriendlyName('foo bar'), 'Foo Bar'); + assert.strictEqual(MQTT.convertFriendlyName('FOO BAR'), 'Foo Bar'); + }); + + describe('topics', () => { + let d; + describe('sensor', () => { + beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[0]'))); + + it('should generate config topics', () => { + assert.strictEqual(mqtt.getConfigTopic(d), 'homeassistant/sensor/XXX/ambient_air_temperature/config'); + }); + it('should generate state topics', () => { + assert.strictEqual(mqtt.getStateTopic(d), 'homeassistant/sensor/XXX/ambient_air_temperature/state'); + }); + }); + + describe('binary_sensor', () => { + beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[3]'))); + it('should generate config topics', () => { + assert.strictEqual(mqtt.getConfigTopic(d), 'homeassistant/sensor/XXX/ev_charge_state/config'); + }); + it('should generate state topics', () => { + assert.strictEqual(mqtt.getStateTopic(d.diagnosticElements[1]), 'homeassistant/sensor/XXX/priority_charge_indicator/state'); + }); + }); + }); + + describe('payloads', () => { + let d; + describe('sensor', () => { + beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[0]'))); + it('should generate config payloads', () => { + assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[0]), { + device_class: 'temperature', + json_attributes_template: undefined, + name: 'Ambient Air Temperature', + state_topic: 'homeassistant/sensor/XXX/ambient_air_temperature/state', + unit_of_measurement: '°C', + value_template: '{{ value_json.ambient_air_temperature }' + }); + }); + it('should generate state payloads', () => { + assert.deepStrictEqual(mqtt.getStatePayload(d), { + ambient_air_temperature: 15 + }); + }); + }); + + describe('binary_sensor', () => { // TODO maybe not needed, payloads not diff + beforeEach(() => d = new Diagnostic(_.get(apiResponse, 'commandResponse.body.diagnosticResponse[3]'))); + it('should generate config payloads', () => { + assert.deepStrictEqual(mqtt.getConfigPayload(d, d.diagnosticElements[1]), { + device_class: undefined, + json_attributes_template: undefined, + name: 'Priority Charge Indicator', + state_topic: 'homeassistant/binary_sensor/XXX/ev_charge_state/state', + unit_of_measurement: undefined, + value_template: '{{ value_json.priority_charge_indicator }' + }); + }); + it('should generate state payloads', () => { + assert.deepStrictEqual(mqtt.getStatePayload(d), { + ev_charge_state: false, + priority_charge_indicator: false, + priority_charge_status: false + }); + }); + }); + }); +});