Skip to content
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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 222 additions & 68 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Copy link
Owner

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 awaited

Copy link
Author

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?

Copy link
Owner

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.

});


// 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');
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can move this within the getLocation case

Copy link
Author

Choose a reason for hiding this comment

The 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);
Copy link
Owner

Choose a reason for hiding this comment

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

Expand All @@ -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()
Expand All @@ -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);
}
})();
Loading
Loading