/**
* @file Implements the CANopen Network Managements (NMT) protocol.
* @author Wilkins White
* @copyright 2024 Daxbot
*/
const Protocol = require('./protocol');
const { DataObject, Eds } = require('../eds');
const { deprecate } = require('util');
/**
* NMT internal states.
*
* @enum {number}
* @see CiA301 "NMT states" (§7.3.2.2)
*/
const NmtState = {
/** The CANopen device's parameters are set to their power-on values. */
INITIALIZING: 0,
/**
* Communication via SDOs is possible, but PDO communication is not
* allowed. PDO configuration may be performed by the application.
*/
PRE_OPERATIONAL: 127,
/**
* All communication objects are active. Access to certain aspects of the
* application may be limited.
*/
OPERATIONAL: 5,
/** No communication except for node guarding and heartbeat. */
STOPPED: 4,
};
/**
* NMT commands.
*
* @enum {number}
* @see CiA301 "Node control protocols" (§7.2.8.3.1)
* @private
*/
const NmtCommand = {
/** Switch target device to {@link NmtState.OPERATIONAL}. */
ENTER_OPERATIONAL: 1,
/** Switch target device to {@link NmtState.STOPPED}. */
ENTER_STOPPED: 2,
/** Switch target device to {@link NmtState.PRE_OPERATIONAL}. */
ENTER_PRE_OPERATIONAL: 128,
/** Reset the target device. */
RESET_NODE: 129,
/** Reset the target device's communication. */
RESET_COMMUNICATION: 130,
};
/**
* CANopen NMT protocol handler.
*
* The network management (NMT) protocol follows a producer-consumer structure
* where NMT objects are used to initialze, start, monitor, reset, or stop
* nodes. All CANopen devices are considered NMT consumers with one device
* fulfilling the role of NMT producer.
*
* This class implements the NMT node control services and tracks the device's
* current NMT consumer state.
*
* @param {Eds} eds - Eds object.
* @see CiA301 "Network management" (§7.2.8)
* @implements {Protocol}
*/
class Nmt extends Protocol {
constructor(eds) {
super(eds);
this.deviceId = null;
this.consumers = {};
this.heartbeatTimer = null;
this._state = NmtState.INITIALIZING;
}
/**
* Device NMT state.
*
* @type {NmtState}
*/
get state() {
return this._state;
}
/**
* Consumer heartbeat timers (deprecated).
*
* @type {Array}
* @deprecated
*/
get timers() {
let timers = {};
for(const [key, consumer] of Object.entries(this.consumers))
timers[key] = consumer.timer;
return timers;
}
/**
* Get object 0x1017 - Producer heartbeat time.
*
* @type {number}
* @deprecated Use {@link Eds#getHeartbeatProducerTime} instead.
*/
get producerTime() {
return this.getHeartbeatProducerTime();
}
/**
* Set object 0x1017 - Producer heartbeat time.
*
* @type {number}
* @deprecated Use {@link Eds#setHeartbeatProducerTime} instead.
*/
set producerTime(value) {
this.eds.setHeartbeatProducerTime(value);
}
/**
* Set the Nmt state.
*
* @param {NmtState} state - new state.
* @fires Nmt#changeState
*/
setState(state) {
if (state !== this._state) {
this._state = state;
/**
* The Nmt state changed.
*
* @event Nmt#changeState
* @type {NmtState}
*/
this.emit('changeState', state);
}
}
/**
* Get the consumer heartbeat time for a device.
*
* @param {number} deviceId - device COB-ID to get.
* @returns {number | null} the consumer heartbeat time or null.
* @since 5.1.0
*/
getConsumerTime(deviceId) {
const obj1016 = this.eds.getEntry(0x1016);
if (obj1016) {
const maxSubIndex = obj1016[0].value;
for (let i = 1; i <= maxSubIndex; ++i) {
const subObj = obj1016.at(i);
if (!subObj)
continue;
if(deviceId === subObj.raw.readUInt8(2))
return subObj.raw.readUInt16LE(0);
}
}
return null;
}
/**
* Get a device's NMT state.
*
* @param {object} args - arguments.
* @param {number} args.deviceId - CAN identifier (defaults to this device).
* @param {number} args.timeout - How long to wait for a new heartbeat (ms).
* @returns {Promise<NmtState | null>} The node NMT state or null.
* @since 6.0.0
*/
async getNodeState(...args) {
let deviceId, timeout;
if(typeof args[0] === 'object') {
deviceId = args.deviceId;
timeout = args.timeout;
}
else {
deviceId = args[0];
timeout = args[1];
}
if (!deviceId || deviceId === this.deviceId)
return this.state;
if (!timeout && this.consumers[deviceId])
return this.consumers[deviceId].state;
let interval = this.getConsumerTime(deviceId);
if (interval === null)
throw new ReferenceError(`NMT consumer ${deviceId} does not exist`);
if (!timeout)
timeout = (interval * 2);
return new Promise((resolve) => {
const start = Date.now();
let timeoutTimer = null;
let intervalTimer = null;
timeoutTimer = setTimeout(() => {
const heartbeat = this.consumers[deviceId];
if (heartbeat && heartbeat.last > start)
resolve(heartbeat.state);
else
resolve(null);
clearInterval(intervalTimer);
}, timeout);
intervalTimer = setInterval(() => {
const heartbeat = this.consumers[deviceId];
if (heartbeat && heartbeat.last > start) {
clearTimeout(timeoutTimer);
clearInterval(intervalTimer);
resolve(heartbeat.state);
}
}, (interval / 2));
});
}
/**
* Service: start remote node.
*
* Change the state of NMT consumer(s) to NMT state operational.
*
* @param {number} [nodeId] - id of node or 0 for broadcast.
* @see CiA301 "Service start remote node" (§7.2.8.2.1.2)
*/
startNode(nodeId) {
this._sendNmt(nodeId, NmtCommand.ENTER_OPERATIONAL);
}
/**
* Service: stop remote node.
*
* Change the state of NMT consumer(s) to NMT state stopped.
*
* @param {number} [nodeId] - id of node or 0 for broadcast.
* @see CiA301 "Service stop remote node" (§7.2.8.2.1.3)
*/
stopNode(nodeId) {
this._sendNmt(nodeId, NmtCommand.ENTER_STOPPED);
}
/**
* Service: enter pre-operational.
*
* Change the state of NMT consumer(s) to NMT state pre-operational.
*
* @param {number} [nodeId] - id of node or 0 for broadcast.
* @see CiA301 "Service enter pre-operational" (§7.2.8.2.1.4)
*/
enterPreOperational(nodeId) {
this._sendNmt(nodeId, NmtCommand.ENTER_PRE_OPERATIONAL);
}
/**
* Service: reset node.
*
* Reset the application of NMT consumer(s).
*
* @param {number} [nodeId] - id of node or 0 for broadcast.
* @fires Protocol#message
* @see CiA301 "Service reset node" (§7.2.8.2.1.5)
*/
resetNode(nodeId) {
this._sendNmt(nodeId, NmtCommand.RESET_NODE);
}
/**
* Service: reset communication.
*
* Reset communication of NMT consumer(s).
*
* @param {number} [nodeId] - id of node or 0 for broadcast.
* @fires Protocol#message
* @see CiA301 "Service reset communication" (§7.2.8.2.1.6)
*/
resetCommunication(nodeId) {
this._sendNmt(nodeId, NmtCommand.RESET_COMMUNICATION);
}
/**
* Start the module.
*
* @fires Nmt#changeState
* @override
*/
start() {
if(!this.started) {
const obj1016 = this.eds.getEntry(0x1016);
if(obj1016)
this._addEntry(obj1016);
const obj1017 = this.eds.getEntry(0x1017);
if(obj1017)
this._addEntry(obj1017);
this.addEdsCallback('newEntry', (obj) => this._addEntry(obj));
this.addEdsCallback('removeEntry', (obj) => this._removeEntry(obj));
super.start();
this.setState(NmtState.PRE_OPERATIONAL);
}
}
/**
* Stop the module.
*
* @fires Nmt#changeState
* @override
*/
stop() {
if(this.started) {
this.removeEdsCallback('newEntry');
this.removeEdsCallback('removeEntry');
const obj1016 = this.eds.getEntry(0x1016);
if(obj1016)
this._removeEntry(obj1016);
const obj1017 = this.eds.getEntry(0x1017);
if(obj1017)
this._removeEntry(obj1017);
this.setState(NmtState.INITIALIZING);
super.stop();
}
}
/**
* Call when a new CAN message is received.
*
* @param {object} message - CAN frame.
* @param {number} message.id - CAN message identifier.
* @param {Buffer} message.data - CAN message data;
* @fires Nmt#changeState
* @fires Nmt#heartbeat
* @fires Nmt#timeout
* @override
*/
receive({ id, data }) {
if ((id & 0x7FF) == 0x0) {
const nodeId = data[1];
if (nodeId == 0 || nodeId == this.deviceId)
this._handleNmt(data[0]);
}
else if ((id & 0x700) == 0x700) {
const deviceId = id & 0x7F;
const consumer = this.consumers[deviceId];
if (consumer) {
consumer.last = Date.now();
const state = data[0];
const oldState = consumer.state;
if (state !== oldState) {
consumer.state = state;
/**
* A consumer NMT state changed.
*
* @event Nmt#heartbeat
* @type {object}
* @property {number} deviceId - device identifier.
* @property {NmtState} state - new device NMT state.
*/
this.emit('heartbeat', { deviceId, state });
}
if (!consumer.timer) {
// First heartbeat - start timer.
consumer.timer = setTimeout(() => {
consumer.state = null;
consumer.timer = null;
/**
* A consumer heartbeat timed out.
*
* @event Nmt#timeout
* @type {number}
*/
this.emit('timeout', deviceId);
}, consumer.interval);
}
else {
consumer.timer.refresh();
}
}
}
}
/**
* Listens for new Eds entries.
*
* @param {DataObject} entry - new entry.
* @listens Eds#newEntry
* @private
*/
_addEntry(entry) {
switch(entry.index) {
case 0x1016:
this.addUpdateCallback(entry, (obj) => this._parse1016(obj));
this._parse1016(entry);
break;
case 0x1017:
this.addUpdateCallback(entry, (obj) => this._parse1017(obj));
this._parse1017(entry);
break;
}
}
/**
* Listens for removed Eds entries.
*
* @param {DataObject} entry - removed entry.
* @listens Eds#newEntry
* @private
*/
_removeEntry(entry) {
switch(entry.index) {
case 0x1016:
this.removeUpdateCallback(entry);
this._clear1016();
break;
case 0x1017:
this.removeUpdateCallback(entry);
this._clear1017();
break;
}
}
/**
* Called when 0x1016 (Consumer heartbeat time) is updated.
*
* @param {DataObject} entry - updated DataObject.
* @listens DataObject#update
* @private
*/
_parse1016(entry) {
if(!entry)
return;
const subIndex = entry.subIndex;
if(subIndex === null) {
const maxSubIndex = entry[0].value;
for(let i = 1; i <= maxSubIndex; ++i)
this._parse1016(entry.at(i));
}
else if(subIndex > 0) {
const deviceId = entry.raw.readUInt8(2);
if(deviceId > 0) {
if(this.consumers[deviceId])
clearInterval(this.consumers[deviceId].timer);
const heartbeatTime = entry.raw.readUInt16LE();
this.consumers[deviceId] = {
state: null,
interval: heartbeatTime,
timer: null,
};
}
}
}
/**
* Called when 0x1016 (Consumer heartbeat time) is removed.
*
* @private
*/
_clear1016() {
for (const consumer of Object.values(this.consumers))
clearTimeout(consumer.timer);
this.consumers = {};
}
/**
* Called when 0x1017 (Producer heartbeat time) is updated.
*
* @param {DataObject} entry - updated DataObject.
* @listens DataObject#update
* @private
*/
_parse1017(entry) {
// Clear old timer
this._clear1017();
// Start new timer
const producerTime = entry.value;
if (producerTime > 0) {
this.heartbeatTimer = setInterval(
() => this._sendHeartbeat(), producerTime);
}
}
/**
* Called when 0x1017 (Producer heartbeat time) is removed.
*
* @private
*/
_clear1017() {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
/**
* Serve an NMT command object.
*
* @param {number} nodeId - id of node or 0 for broadcast.
* @param {NmtCommand} command - NMT command to serve.
* @fires Protocol#message
* @private
*/
_sendNmt(nodeId, command) {
if (nodeId === undefined) {
// Handle internally and return
this._handleNmt(command);
return;
}
if (!nodeId) {
// Broadcast
this._handleNmt(command);
}
this.send(0x0, Buffer.from([command, nodeId]));
}
/**
* Serve a Heartbeat object.
*
* @fires Protocol#message
* @private
*/
_sendHeartbeat() {
if (this.deviceId && this.deviceId < 0x80)
this.send(0x700 + this.deviceId, Buffer.from([this.state]));
}
/**
* Parse an NMT command.
*
* @param {NmtCommand} command - NMT command to handle.
* @fires Nmt#changeState
* @fires Nmt#reset
* @private
*/
_handleNmt(command) {
switch (command) {
case NmtCommand.ENTER_OPERATIONAL:
this.setState(NmtState.OPERATIONAL);
break;
case NmtCommand.ENTER_STOPPED:
this.setState(NmtState.STOPPED);
break;
case NmtCommand.ENTER_PRE_OPERATIONAL:
this.setState(NmtState.PRE_OPERATIONAL);
break;
case NmtCommand.RESET_NODE:
this.setState(NmtState.INITIALIZING);
this._emitReset(true);
break;
case NmtCommand.RESET_COMMUNICATION:
this.setState(NmtState.INITIALIZING);
this._emitReset(true);
break;
}
}
/**
* Emit the reset event.
*
* @param {boolean} resetNode - true if a full reset was requested.
* @fires Nmt#reset
* @private
*/
_emitReset(resetNode) {
/**
* A reset was requested.
*
* @event Nmt#reset
* @type {boolean}
*/
this.emit('reset', resetNode);
}
}
////////////////////////////////// Deprecated //////////////////////////////////
/**
* Initialize the device and audit the object dictionary.
*
* @deprecated Use {@link Nmt#start} instead.
* @function
*/
Nmt.prototype.init = deprecate(
function () {
const { ObjectType, DataType } = require('../types');
let obj1016 = this.eds.getEntry(0x1016);
if(obj1016 === undefined) {
obj1016 = this.eds.addEntry(0x1016, {
parameterName: 'Consumer heartbeat time',
objectType: ObjectType.ARRAY,
});
}
let obj1017 = this.eds.getEntry(0x1017);
if(obj1017 === undefined) {
obj1017 = this.eds.addEntry(0x1017, {
parameterName: 'Producer heartbeat time',
objectType: ObjectType.VAR,
dataType: DataType.UNSIGNED32,
});
}
this.start();
}, 'Nmt.init() is deprecated. Use Nmt.start() instead.');
/**
* Get an entry from 0x1016 (Consumer heartbeat time).
*
* @param {number} deviceId - device COB-ID of the entry to get.
* @returns {DataObject | null} the matching entry or null.
* @deprecated Use {@link Nmt#getConsumerTime} instead.
* @function
*/
Nmt.prototype.getConsumer = deprecate(
function (deviceId) {
const obj1016 = this.eds.getEntry(0x1016);
if (obj1016) {
const maxSubIndex = obj1016[0].value;
for (let i = 1; i <= maxSubIndex; ++i) {
const subObj = obj1016.at(i);
if (!subObj)
continue;
if (subObj.raw.readUInt8(2) === deviceId)
return subObj;
}
}
return null;
}, 'Nmt.getConsumer() is deprecated. Use Nmt.getConsumerTime() instead.');
/**
* Add an entry to 0x1016 (Consumer heartbeat time).
*
* @param {number} deviceId - device COB-ID to add.
* @param {number} timeout - milliseconds before a timeout is reported.
* @param {number} [subIndex] - sub-index to store the entry, optional.
* @deprecated Use {@link Eds#addHeartbeatConsumer} instead.
* @function
*/
Nmt.prototype.addConsumer = deprecate(
function (deviceId, timeout, subIndex) {
this.eds.addHeartbeatConsumer(deviceId, timeout, { subIndex });
}, 'Nmt.addConsumer() is deprecated. Use Eds.addConsumer() instead.');
/**
* Remove an entry from 0x1016 (Consumer heartbeat time).
*
* @param {number} deviceId - device COB-ID of the entry to remove.
* @deprecated Use {@link Eds#removeHeartbeatConsumer} instead.
* @function
*/
Nmt.prototype.removeConsumer = deprecate(
function (deviceId) {
this.eds.removeHeartbeatConsumer(deviceId);
}, 'Nmt.removeConsumer() is deprecated. Use Eds.removeHeartbeatConsumer() instead.');
module.exports = exports = { NmtState, Nmt };