Add Home Assistant discoverable MQTT configs.

This commit is contained in:
Michael Woods 2020-11-30 22:52:38 -05:00
parent f39961eae3
commit ed2c38f53f
7 changed files with 318 additions and 16 deletions

3
HA-MQTT.md Normal file
View File

@ -0,0 +1,3 @@
Sample configs for MQTT Home Assistant integration.
- Utility meter that resets for monthly LIFETIME ENERGY USED

View File

@ -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).

View File

@ -31,6 +31,7 @@
"uuid": "^8.3.1"
},
"devDependencies": {
"mocha": "^8.2.1"
"mocha": "^8.2.1",
"nyc": "^15.1.0"
}
}

View File

@ -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()}`;
}

View File

@ -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}`;

View File

@ -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;

95
test/mqtt.spec.js Normal file
View File

@ -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
});
});
});
});
});