Add Home Assistant discoverable MQTT configs.
This commit is contained in:
parent
f39961eae3
commit
ed2c38f53f
3
HA-MQTT.md
Normal file
3
HA-MQTT.md
Normal file
@ -0,0 +1,3 @@
|
||||
Sample configs for MQTT Home Assistant integration.
|
||||
|
||||
- Utility meter that resets for monthly LIFETIME ENERGY USED
|
@ -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).
|
@ -31,6 +31,7 @@
|
||||
"uuid": "^8.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "^8.2.1"
|
||||
"mocha": "^8.2.1",
|
||||
"nyc": "^15.1.0"
|
||||
}
|
||||
}
|
||||
|
@ -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()}`;
|
||||
}
|
||||
|
@ -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}`;
|
||||
|
177
src/mqtt.js
177
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;
|
95
test/mqtt.spec.js
Normal file
95
test/mqtt.spec.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user