-
Notifications
You must be signed in to change notification settings - Fork 42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Refactor response handling.. and more (parses manually sent 'diagnostics' commands) #338
base: main
Are you sure you want to change the base?
Changes from 5 commits
2346fff
ce81288
81e8269
22e0bc2
ad6509e
cbab4b0
56ac444
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,7 +8,6 @@ const MQTT = require('./mqtt'); | |
const Commands = require('./commands'); | ||
const logger = require('./logger'); | ||
|
||
|
||
const onstarConfig = { | ||
deviceId: process.env.ONSTAR_DEVICEID || uuidv4(), | ||
vin: process.env.ONSTAR_VIN, | ||
|
@@ -70,42 +69,241 @@ const connectMQTT = async availabilityTopic => { | |
return client; | ||
} | ||
|
||
const configureMQTT = async (commands, client, mqttHA) => { | ||
const configureMQTT = async (vehicle, commands, client, mqttHA) => { | ||
|
||
if (!onstarConfig.allowCommands) | ||
return; | ||
const diagavailTopic = mqttHA.getDiagnosticAvailabilityTopic(); | ||
const configurations = new Map(); | ||
|
||
// publish an HA auto discovery topic for the device_tracker | ||
client.publish(mqttHA.getConfigTopic({ name: "getLocation" }), | ||
JSON.stringify(mqttHA.getConfigPayload( {name: "getLocation"}, null)), {retain: true}); | ||
|
||
|
||
// Concept: other than "getLocation" and "diagnostics", the other commands could be considered | ||
// buttons (and published as such to MQTT/HA.) Then, the RESPONSE to the button presses | ||
// could be published as MQTT events. | ||
// | ||
// So, publish an MQTT button called "startVehicle" that has a config including: | ||
// - button: | ||
// command_topic: "homeassistant/VIN/command" <-- use the generic command topic to KISS | ||
// payload_press: "{ 'command' : 'startVehicle' }" | ||
// retain: false | ||
// | ||
// Then, so the user can check to see if the car was actually started: | ||
// - event: | ||
// state_topic: "homeassistant/VIN/events/startvehicle" // note all lowercase | ||
// event_types: | ||
// - "success" | ||
// - "failure" | ||
// // something else for attributes so specific err info can be copied | ||
// | ||
// Why not use a switch? Because buttons/events better represent the paradigm presented | ||
// by onstar. A switch is a state that can be monitored, but onstar doesn't allow us | ||
// to check if the car is currently running to turn off the "startVehicle" switch. We can | ||
// only send the command (button) and see the immediate response (event.) | ||
// | ||
// The buttons and events will have to have similar, but different, names. For example, | ||
// the button would be startVehicle and the event would be startVehicle-result. (I'm not | ||
// thrilled with that. Can an autodiscovery config topic have payloads for different | ||
// types of entities? The state topics would absolutely have to be different... and that | ||
// would really break the paradigm in mqtt.js.) | ||
// | ||
// ... and it turns out that MQTT-events are broken. That's fine. To keep things easier | ||
// for automations, just use a single "command_result" event. Within that event, stuff | ||
// all kinds of data, including the command sent, the success/failure state, a timestamp, | ||
// a friendly name, and even a friendly message that can be directly copied to a notification. | ||
|
||
const buttonCommands = [ | ||
{ cmd: 'startVehicle', friendlyName: "Start", icon: "mdi:car-key" }, | ||
{ cmd: 'cancelStartVehicle', friendlyName: "Cancel Start", icon: "mdi:car-off" }, | ||
{ cmd: 'alert', friendlyName: "Flash Lights and Honk Horn", icon: "mdi:alarm-light" }, | ||
{ cmd: 'alertFlash', friendlyName: "Flash Lights", icon: "mdi:alarm-light" }, | ||
{ cmd: 'alertHonk', friendlyName: "Honk Horn", icon: "mdi:alarm-light" }, | ||
{ cmd: 'lockDoor', friendlyName: "Lock Doors", icon: "mdi:car-door-lock" }, | ||
{ cmd: 'unlockDoor', friendlyName: "Unlock Doors", icon: "mdi:car-door" }, | ||
{ cmd: 'getLocation', friendlyName: "Get Location", icon: "mdi:map-marker", noevent: true } | ||
]; | ||
|
||
// map it to an array of objects to make things easier | ||
|
||
const button_discovery = []; | ||
buttonCommands.forEach(cmd => { | ||
const [ ctopic, cpayload ] = mqttHA.getButtonConfig(cmd.cmd); | ||
if (cmd.icon) | ||
cpayload.icon = cmd.icon; | ||
if (cmd.friendlyName) | ||
cpayload.name = MQTT.convertFriendlyName(cpayload.name); | ||
|
||
logger.info('topic: ' + ctopic); | ||
logger.info('payload: ' + JSON.stringify(cpayload)); | ||
button_discovery.push( | ||
client.publish(ctopic, JSON.stringify(cpayload), {retain: true}) | ||
); | ||
// just publish a single comment event and stick all the results in there | ||
const [ etopic, epayload ] = mqttHA.getEventConfig('command_result'); | ||
button_discovery.push( | ||
client.publish(etopic, JSON.stringify(epayload), {retain: true})); | ||
|
||
Promise.all(button_discovery); | ||
}); | ||
|
||
|
||
// TODO: publish autodiscovery for a "number" representing the timer used for auto-polling | ||
// diagnostics. (This will have to subscribe to the message as well as publish to it.) | ||
|
||
// Ideally, would publish the autodiscovery topics for all the diagnostics here.... | ||
// HOWEVER, until I see a diagnostics response, I don't know what topics to create! I can figure out | ||
// what diagnosticItems the vehicle supports, but there's no 1:1 relationship between that and the stuff that | ||
// the mqtt.js creates from the returned JSON. Therefore, leave that diagnostics discovery topic stuff | ||
// alone. | ||
|
||
// get the mqtt client listening for messages... | ||
client.on('message', (topic, message) => { | ||
logger.debug('Subscription message', {topic, message}); | ||
const {command, options} = JSON.parse(message); | ||
var {command, options} = JSON.parse(message); | ||
const cmd = commands[command]; | ||
if (!cmd) { | ||
logger.error('Command not found', {command}); | ||
return; | ||
} | ||
const cmdObj = buttonCommands.find(( c ) => c.cmd === command); | ||
const cmdFriendlyName = (cmdObj && cmdObj.friendlyName) ? cmdObj.friendlyName : command; | ||
|
||
const commandFn = cmd.bind(commands); | ||
// special case: if a "diagnostics" command is used and no options are specified, default to all supported diagnostic items | ||
if ((command == 'diagnostics') && !options) { | ||
options = { diagnosticItem: vehicle.getSupported() }; | ||
} | ||
|
||
logger.info('Command sent', { command }); | ||
commandFn(options || {}) | ||
commandFn(options || {}) // this will always throw a RequestError on failure, so "success" can be assumed outside the catch block | ||
.then(data => { | ||
// TODO refactor the response handling for commands | ||
// TODONE 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 })); | ||
} | ||
} | ||
logger.debug(`Status: ${data.status}, response: ` + JSON.stringify(data.response.data)); | ||
const location = _.get(data, 'response.data.commandResponse.body.location'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can move this within the getLocation case There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lol. It's outside the case because lint was complaining about it being inside the case (unless enclose the case in {} ) |
||
switch (command) { | ||
case 'getLocation': | ||
client.publish(mqttHA.getStateTopic({ name: command }), | ||
JSON.stringify({ latitude: Number(location.lat), longitude: Number(location.long) }), { retain: true }) | ||
.then(() => logger.info('Published device_tracker topic.')); | ||
break; | ||
|
||
case 'diagnostics': | ||
{ | ||
const publishes = []; | ||
// mark diagnostics as available | ||
publishes.push( | ||
client.publish(diagavailTopic, 'true', {retain: true}) | ||
); | ||
|
||
|
||
const diag = _.get(data, 'response.data.commandResponse.body.diagnosticResponse'); | ||
const states = new Map(); | ||
|
||
logger.info('got diagnostic response'); | ||
const stats = _.map(diag, d => new Diagnostic(d)); | ||
logger.debug('Diagnostic request response', {stats: _.map(stats, s => s.toString())}); | ||
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); | ||
// this resets "configured" and the payload every single time a diag response comes back!!! | ||
// configurations.set(topic, {configured: false, payload}); | ||
|
||
// this, however, will only set if it doesn't exist yet, and therefore leave 'configured' unchanged | ||
if (!configurations.has(topic)) | ||
configurations.set(topic, {configured: false, payload}); | ||
} | ||
const topic = mqttHA.getStateTopic(s); | ||
const payload = mqttHA.getStatePayload(s); | ||
states.set(topic, payload); | ||
} | ||
|
||
// publish sensor configs | ||
for (let [topic, config] of configurations) { | ||
// configure once | ||
if (!config.configured) { | ||
config.configured = true; | ||
const {payload} = config; | ||
logger.debug('Publishing discovery topic: ', {topic}); | ||
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}) | ||
); | ||
} | ||
Promise.all(publishes); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same here, Promise.all batches multiple into one but you still need to handle the one promise |
||
} | ||
break; | ||
|
||
default: | ||
// this will be a "success" response to a command other than diagnostics or location. | ||
// (Any failures or timeouts throw exceptions, so we know it's a success.) | ||
// response.data.commandResponse.body may contain useful information that varies depending on | ||
// the command. From testing: | ||
// start/cancelStart - has elements for cabin preconditioning, start time, cabin temp, etc - but none of it has valid data (for my car) | ||
// lock/unlock - null body | ||
// getChargingProfile's body has chargingProfile.chargeMode (IMMEDIATE) and chargingProfile.rateType (PEAK) | ||
// setChargingProfile - null body | ||
// alert/cancelAlert - null body | ||
|
||
// So, the only thing that might have a useful response is getChargingProfile. | ||
// Build up a single "command_result" event with anything the user might want in | ||
// the payload. The "event_type" is required for MQTT events (even if they are | ||
// broken at the moment, assume they might work some day.) | ||
var resultEvent = { event_type: "success", | ||
friendlyName : cmdFriendlyName, | ||
command, | ||
timestamp : new Date(Date.now()).toISOString(), | ||
friendlyMessage : `The "${cmdFriendlyName}" command was successful.` | ||
}; | ||
if (command == "getChargingProfile") | ||
resultEvent.response = _.get(data, 'response.data.commandResponse.body'); | ||
client.publish(mqttHA.getEventStateTopic('command_result'), JSON.stringify(resultEvent), {retain: false}); | ||
} // switch | ||
}) | ||
.catch(err=> logger.error('Command error', {command, err})); | ||
.catch(err => { | ||
if (err instanceof Error) { | ||
logger.error('Error', {error: _.pick(err, [ | ||
'message', 'stack', | ||
'response.status', 'response.statusText', 'response.headers', 'response.data', | ||
'request.method', 'request.body', 'request.contentType', 'request.headers', 'request.url' | ||
])}); | ||
// see commands above on the contents of resultEvent. | ||
// publish that the command failed. | ||
const topic = mqttHA.getEventStateTopic('command_result'); | ||
var resultEvent = { event_type: "failure", | ||
friendlyName : cmdFriendlyName, | ||
command, | ||
timestamp : new Date(Date.now()).toISOString(), | ||
friendlyMessage : `The "${cmdFriendlyName}" command failed.` | ||
}; | ||
client.publish(topic, JSON.stringify(resultEvent), {retain: false}); | ||
// in addition, if this was a diagnostics command, mark all the diagnostic entities as unavailable... | ||
if (command == "diagnostics") | ||
client.publish(diagavailTopic, 'false', {retain: true}) | ||
} else { | ||
logger.error('Error', {error: err}); | ||
} | ||
}); | ||
|
||
}); | ||
const topic = mqttHA.getCommandTopic(); | ||
logger.info('Subscribed to command topic', {topic}); | ||
logger.info('Subscribing to command topic', {topic}); | ||
await client.subscribe(topic); | ||
}; | ||
|
||
|
@@ -119,57 +317,12 @@ const configureMQTT = async (commands, client, mqttHA) => { | |
const client = await connectMQTT(availTopic); | ||
client.publish(availTopic, 'true', {retain: true}) | ||
.then(() => logger.debug('Published availability')); | ||
await configureMQTT(commands, client, mqttHA); | ||
|
||
await configureMQTT(vehicle, commands, client, mqttHA); | ||
|
||
const configurations = new Map(); | ||
const run = async () => { | ||
const states = new Map(); | ||
const v = vehicle; | ||
logger.info('Requesting diagnostics'); | ||
const statsRes = await commands.diagnostics({diagnosticItem: v.getSupported()}); | ||
logger.info('Diagnostic request status', {status: _.get(statsRes, 'status')}); | ||
const stats = _.map( | ||
_.get(statsRes, 'response.data.commandResponse.body.diagnosticResponse'), | ||
d => new Diagnostic(d) | ||
); | ||
logger.debug('Diagnostic request response', {stats: _.map(stats, s => s.toString())}); | ||
|
||
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}) | ||
); | ||
} | ||
await Promise.all(publishes); | ||
client.publish(mqttHA.getCommandTopic(), JSON.stringify({command : 'diagnostics'})); | ||
}; | ||
|
||
const main = async () => run() | ||
|
@@ -190,5 +343,6 @@ const configureMQTT = async (commands, client, mqttHA) => { | |
setInterval(main, onstarConfig.refreshInterval); | ||
} catch (e) { | ||
logger.error('Main function error.', {error: e}); | ||
logger.error('Stack: ' + e.stack); | ||
} | ||
})(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this Promise.all isn't doing anything, not being returned nor
await
edThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not at all familiar with javascript async code and just followed the model that was used elsewhere. ;) I was under the impression (only from context) that "Promise.all(list)" would ensure everything in the "list" was completed. Would it be better to just call client.publish() multiple times without using promises?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The all() does that, but nothing is using the one promise to represent the many. If a publish were to fail (without a
.catch()
), it may throw an unhandled rejection exception and crash. Not really the biggest deal here, docker will restart the service and these actions are mostly fire and forget.Removing the promise collection and .all() would be fine.