device.js

/**
 * @file Implements a CANopen device
 * @author Wilkins White
 * @copyright 2024 Daxbot
 */

const EventEmitter = require('events');
const { deprecate } = require('util');

const { Emcy } = require('./protocol/emcy');
const { Lss } = require('./protocol/lss');
const { Nmt, NmtState } = require('./protocol/nmt');
const { Pdo } = require('./protocol/pdo');
const { SdoClient } = require('./protocol/sdo_client');
const { SdoServer } = require('./protocol/sdo_server');
const { Sync } = require('./protocol/sync');
const { Time } = require('./protocol/time');
const { Eds, EdsError } = require('./eds');

/**
 * A CANopen device.
 *
 * This class represents a single addressable device (or node) on the bus.
 *
 * @param {object} args - arguments.
 * @param {Eds} args.eds - the device's electronic data sheet.
 * @param {number} [args.id] - device identifier [1-127].
 * @param {boolean} [args.loopback] - enable loopback mode.
 * @param {boolean} [args.enableLss] - enable layer setting services.
 */
class Device extends EventEmitter {
    constructor(args = {}) {
        super();
        this._stateListener = null;
        this._resetListener = null;

        if (typeof args.eds === 'string')
            this.eds = Eds.fromFile(args.eds);
        else
            this.eds = args.eds || new Eds();

        if (!Eds.isEds(this.eds))
            throw new EdsError('bad Eds');

        this.protocol = {
            emcy: new Emcy(this.eds),
            lss: new Lss(this.eds),
            nmt: new Nmt(this.eds),
            pdo: new Pdo(this.eds),
            sdoClient: new SdoClient(this.eds),
            sdoServer: new SdoServer(this.eds),
            sync: new Sync(this.eds),
            time: new Time(this.eds),
        };

        for (const obj of Object.values(this.protocol))
            obj.addListener('message', (m) => this.emit('message', m));

        if (args.id !== undefined) {
            if (args.id < 1 || args.id > 0x7F)
                throw RangeError('id must be in range [1-127]');

            this.id = args.id;
        }

        if (args.loopback) {
            this.addListener('message', (m) => {
                /* We use setImmediate here to allow the method that called
                 * send() to run to completion before receive() is processed.
                 */
                setImmediate(() => this.receive(m));
            });
        }

        if (args.enableLss === undefined)
            args.enableLss = this.eds.lssSupported;

        if (args.enableLss) {
            this.lss.addListener('changeDeviceId', (id) => this.id = id);
            this.lss.start();
        }
    }

    /**
     * Accessor for version 5 Eds DataObjects. Do not use.
     *
     * @type {object}
     * @deprecated Use {@link Eds#entries} instead.
     */
    get dataObjects() {
        return this.eds.dataObjects;
    }

    /**
     * The device id.
     *
     * @type {number}
     */
    get id() {
        return this._id;
    }

    set id(value) {
        this._id = value;
        this.nmt.deviceId = value;
    }

    /**
     * The Nmt state.
     *
     * @type {NmtState}
     */
    get state() {
        return this.nmt.state;
    }

    /**
     * The Emcy module.
     *
     * @type {Emcy}
     */
    get emcy() {
        return this.protocol.emcy;
    }

    /**
     * The Lss module.
     *
     * @type {Lss}
     */
    get lss() {
        return this.protocol.lss;
    }

    /**
     * The Nmt module.
     *
     * @type {Nmt}
     */
    get nmt() {
        return this.protocol.nmt;
    }

    /**
     * The Pdo module.
     *
     * @type {Pdo}
     */
    get pdo() {
        return this.protocol.pdo;
    }

    /**
     * The Sdo (client) module.
     *
     * @type {SdoClient}
     */
    get sdo() {
        return this.protocol.sdoClient;
    }

    /**
     * The Sdo (server) module.
     *
     * @type {SdoClient}
     */
    get sdoServer() {
        return this.protocol.sdoServer;
    }

    /**
     * The Sync module.
     *
     * @type {Sync}
     */
    get sync() {
        return this.protocol.sync;
    }

    /**
     * The Time module.
     *
     * @type {Time}
     */
    get time() {
        return this.protocol.time;
    }

    /**
     * Manufacturer hardware version (Object 0x)
     */

    /**
     * Call with each incoming CAN message.
     *
     * @param {object} message - CAN frame.
     * @param {number} message.id - CAN message identifier.
     * @param {Buffer} message.data - CAN message data;
     * @param {number} message.len - CAN message length in bytes.
     */
    receive(message) {
        if (message.id == 0x0) {
            // Reserve COB-ID 0x0 for NMT
            this.nmt.receive(message);
        }
        else {
            for (const obj of Object.values(this.protocol))
                obj.receive(message);
        }
    }

    /**
     * Initialize the device and audit the object dictionary.
     *
     * @since 6.0.0
     */
    start() {
        if (!this.id)
            throw new Error('id must be set');

        if (!this._resetListener) {
            this._resetListener = (resetEds) => this._reset(resetEds);
            this.nmt.addListener('reset', this._resetListener);
        }

        if (!this._stateListener) {
            this._stateListener = (state) => this._changeState(state);
            this.nmt.addListener('changeState', this._stateListener);
        }

        this.nmt.start();
    }

    /**
     * Cleanup timers and shutdown the device.
     *
     * @since 6.0.0
     */
    stop() {
        try {
            this.nmt.removeListener('reset', this._resetListener);
            this._resetListener = null;

            this.nmt.removeListener('changeState', this._stateListener);
            this._stateListener = null;
        }
        catch (e) { /* ignore */ }

        for (const obj of Object.values(this.protocol))
            obj.stop();
    }

    /**
     * Map a remote node's EDS file on to this Device.
     *
     * This method provides an easy way to set up communication with another
     * device. Most EDS transmit/producer entries will be mapped to their local
     * receive/consumer analogues. Note that this method will heavily modify
     * the Device's internal EDS file.
     *
     * This may be called multiple times to map more than one EDS.
     *
     * @param {object} args - method arguments.
     * @param {number} args.id - the remote node's CAN identifier.
     * @param {Eds | string} args.eds - the server's EDS.
     * @param {number} [args.dataStart] - start index for SDO entries.
     * @param {boolean} [args.skipEmcy] - Skip EMCY producer -> consumer.
     * @param {boolean} [args.skipNmt] - Skip NMT producer -> consumer.
     * @param {boolean} [args.skipPdo] - Skip PDO transmit -> receive.
     * @param {boolean} [args.skipSdo] - Skip SDO server -> client.
     * @since 6.0.0
     */
    mapRemoteNode(args = {}) {
        let eds = args.eds;
        if (typeof eds === 'string')
            eds = Eds.fromFile(eds);

        if (!args.skipEmcy) {
            // Map EMCY producer -> consumer
            const cobId = eds.getEmcyCobId();
            if (cobId)
                this.eds.addEmcyConsumer(cobId);
        }

        if (!args.skipNmt) {
            // Map heartbeat producer -> consumer
            const ms = eds.getHeartbeatProducerTime();
            if (ms) {
                if (!args.id)
                    throw new ReferenceError('id required to map NMT');

                this.eds.addHeartbeatConsumer(args.id, ms * 2);
            }
        }

        if (!args.skipSdo) {
            for (const client of eds.getSdoServerParameters()) {

                const clientId = client.deviceId;
                if (clientId > 0 && clientId !== this.id)
                    continue;

                if (!args.id)
                    throw new ReferenceError('id required to map SDO');

                const cobIdTx = client.cobIdRx; // client -> server
                const cobIdRx = client.cobIdTx; // server -> client

                this.eds.addSdoClientParameter(args.id, cobIdTx, cobIdRx);
            }
        }

        if (!args.skipPdo) {
            let dataIndex = args.dataStart || 0x2000;
            if (dataIndex < 0x2000)
                throw new RangeError('dataStart must be >= 0x2000');

            const mapped = [];
            for (const pdo of eds.getTransmitPdos()) {

                const dataObjects = [];
                for (let obj of pdo.dataObjects) {
                    // Find the next open SDO index
                    while (this.eds.getEntry(dataIndex) !== undefined) {
                        if (dataIndex >= 0xFFFF)
                            throw new RangeError('dataIndex must be <= 0xFFFF');

                        dataIndex += 1;
                    }

                    // If this is a subObject, then get the parent instead
                    const subIndex = obj.subIndex;
                    if (subIndex !== null)
                        obj = eds.getEntry(obj.index);

                    if (mapped.includes(obj.index))
                        continue; // Already mapped

                    mapped.push(obj.index);

                    // Add data object to device EDS
                    this.eds.addEntry(dataIndex, obj);
                    for (let j = 1; j < obj.subNumber; ++j)
                        this.eds.addSubEntry(dataIndex, j, obj[j]);

                    // Prepare to map the new data object
                    if (subIndex) {
                        dataObjects.push(
                            this.eds.getSubEntry(dataIndex, subIndex));
                    }
                    else {
                        dataObjects.push(
                            this.eds.getEntry(dataIndex));
                    }
                }

                pdo.dataObjects = dataObjects;
                this.eds.addReceivePdo(pdo);
            }
        }
    }

    /**
     * Get the value of an EDS entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @returns {number | bigint | string | Date} entry value.
     */
    getValue(index) {
        const entry = this.eds.getEntry(index);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index} does not exist`);
        }

        return entry.value;
    }

    /**
     * Get the value of an EDS sub-entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @param {number} subIndex - sub-object index.
     * @returns {number | bigint | string | Date} entry value.
     */
    getValueArray(index, subIndex) {
        const entry = this.eds.getSubEntry(index, subIndex);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index}[${subIndex}] does not exist`);
        }

        return entry.value;
    }

    /**
     * Get the raw value of an EDS entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @returns {Buffer} entry data.
     */
    getRaw(index) {
        const entry = this.eds.getEntry(index);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index} does not exist`);
        }

        return entry.raw;
    }

    /**
     * Get the raw value of an EDS sub-entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @param {number} subIndex - sub-object index.
     * @returns {Buffer} entry data.
     */
    getRawArray(index, subIndex) {
        const entry = this.eds.getSubEntry(index, subIndex);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index}[${subIndex}] does not exist`);
        }

        return entry.raw;
    }

    /**
     * Get the scale factor of an EDS entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @returns {number | bigint | string | Date} entry value.
     */
    getScale(index) {
        const entry = this.eds.getEntry(index);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index} does not exist`);
        }

        return entry.scaleFactor;
    }

    /**
     * Get the scale factor of an EDS sub-entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @param {number} subIndex - sub-object index.
     * @returns {number | bigint | string | Date} entry value.
     */
    getScaleArray(index, subIndex) {
        const entry = this.eds.getSubEntry(index, subIndex);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index}[${subIndex}] does not exist`);
        }

        return entry.scaleFactor;
    }

    /**
     * Set the value of an EDS entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @param {number | bigint | string | Date} value - value to set.
     */
    setValue(index, value) {
        const entry = this.eds.getEntry(index);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index} does not exist`);
        }

        entry.value = value;
    }

    /**
     * Set the value of an EDS sub-entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @param {number} subIndex - array sub-index to set;
     * @param {number | bigint | string | Date} value - value to set.
     */
    setValueArray(index, subIndex, value) {
        const entry = this.eds.getSubEntry(index, subIndex);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index}[${subIndex}] does not exist`);
        }

        entry.value = value;
    }

    /**
     * Set the raw value of an EDS entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @param {Buffer} raw - raw Buffer to set.
     */
    setRaw(index, raw) {
        const entry = this.eds.getEntry(index);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index} does not exist`);
        }

        entry.raw = raw;
    }

    /**
     * Set the raw value of an EDS sub-entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @param {number} subIndex - sub-object index.
     * @param {Buffer} raw - raw Buffer to set.
     */
    setRawArray(index, subIndex, raw) {
        const entry = this.eds.getSubEntry(index, subIndex);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index}[${subIndex}] does not exist`);
        }

        entry.raw = raw;
    }

    /**
     * Set the scale factor of an EDS entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @param {number} scaleFactor - value to set.
     * @since 6.0.0
     */
    setScale(index, scaleFactor) {
        const entry = this.eds.getEntry(index);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index} does not exist`);
        }

        entry.scaleFactor = scaleFactor;
    }

    /**
     * Set the scale factor of an EDS sub-entry.
     *
     * @param {number | string} index - index or name of the entry.
     * @param {number} subIndex - array sub-index to set;
     * @param {number} scaleFactor - value to set.
     * @since 6.0.0
     */
    setScaleArray(index, subIndex, scaleFactor) {
        const entry = this.eds.getSubEntry(index, subIndex);
        if (!entry) {
            if (typeof index === 'number')
                index = '0x' + index.toString(16);

            throw new EdsError(`entry ${index}[${subIndex}] does not exist`);
        }

        entry.scaleFactor = scaleFactor;
    }

    /**
     * Reset the Device.
     *
     * @param {boolean} [resetEds] - if true, then perform an Eds reset.
     * @listens Nmt#reset
     * @private
     */
    _reset(resetEds = false) {
        if (resetEds)
            this.eds.reset();

        setImmediate(() => {
            // Stop all modules
            this.stop();

            // Re-start Nmt and transition to PRE_OPERATIONAL
            this.start();
        });
    }

    /**
     * Called on Nmt#changeState
     *
     * @param {NmtState} state - new nmt state.
     * @listens Nmt#changeState
     * @private
     */
    _changeState(state) {
        switch (state) {
            case NmtState.PRE_OPERATIONAL:
                // Start all...
                this.emcy.start();
                this.sdo.start();
                this.sdoServer.start();
                this.sync.start();
                this.time.start();

                // ... except Pdo
                this.pdo.stop();
                break;

            case NmtState.OPERATIONAL:
                // Start all
                this.emcy.start();
                this.sdo.start();
                this.sdoServer.start();
                this.sync.start();
                this.time.start();
                this.pdo.start();
                break;

            case NmtState.INITIALIZING:
            case NmtState.STOPPED:
                // Stop all except Nmt
                this.emcy.stop();
                this.sdo.stop();
                this.sdoServer.stop();
                this.sync.stop();
                this.time.stop();
                this.pdo.stop();
                break;
        }
    }
}

////////////////////////////////// Deprecated //////////////////////////////////

/**
 * Initialize the device and audit the object dictionary. Additionally this
 * method will enable deprecated Device level events.
 *
 * @deprecated Use {@link Device#start} instead.
 * @function
 */
Device.prototype.init = deprecate(
    function () {
        this.emcy.addListener('emergency', ({ cobId, em }) => {
            /**
             * Emcy object consumed (deprecated).
             *
             * This event needs to be enabled by calling
             * {@link Device#init} before it will fire.
             *
             * @event Device#emergency
             * @deprecated Use {@link Emcy#event:emergency} instead.
             */
            this.emit('emergency', (cobId & 0xF), em);
        });

        this.nmt.addListener('reset', (resetNode) => {
            if (resetNode) {
                /**
                 * NMT reset node (deprecated).
                 *
                 * This event needs to be enabled by calling
                 * {@link Device#init} before it will fire.
                 *
                 * @event Device#nmtResetNode
                 * @deprecated Use {@link Nmt#event:reset} instead.
                 */
                this.emit('nmtResetNode');
            }
            else {
                /**
                 * NMT reset communication (deprecated).
                 *
                 * This event needs to be enabled by calling
                 * {@link Device#init} before it will fire.
                 *
                 * @event Device#nmtResetCommunication
                 * @deprecated Use {@link Nmt#event:reset} instead.
                 */
                this.emit('nmtResetCommunication');
            }

            this._reset(resetNode);
        });

        this.nmt.addListener('changeState', (state) => {
            /**
             * NMT state changed (deprecated).
             *
             * This event needs to be enabled by calling
             * {@link Device#init} before it will fire.
             *
             * @event Device#nmtChangeState
             * @deprecated Use {@link Nmt#event:changeState} or {@link Nmt#event:heartbeat} instead.
             */
            this.emit('nmtChangeState', this.deviceId, state);
            this._changeState(state);
        });

        this.nmt.addListener('heartbeat', ({ deviceId, state }) => {
            this.emit('nmtChangeState', deviceId, state);
        });

        this.nmt.addListener('timeout', (deviceId) => {
            /**
             * NMT consumer timeout (deprecated).
             *
             * This event needs to be enabled by calling
             * {@link Device#init} before it will fire.
             *
             * @event Device#nmtChangeState
             * @deprecated Use {@link Nmt#event:timeout} instead.
             */
            this.emit('nmtTimeout', deviceId);
        });

        this.pdo.addListener('pdo', (pdo) => {
            /**
             * PDO received (deprecated).
             *
             * This event needs to be enabled by calling
             * {@link Device#init} before it will fire.
             *
             * @event Device#pdo
             * @deprecated Use {@link Pdo#event:pdo} instead.
             */
            this.emit('pdo', pdo.dataObjects, pdo.cobId);
        });

        this.sync.addListener('sync', (count) => {
            /**
             * Sync object consumed (deprecated).
             *
             * This event needs to be enabled by calling
             * {@link Device#init} before it will fire.
             *
             * @event Device#sync
             * @deprecated Use {@link Sync#event:sync} instead.
             */
            this.emit('sync', count);
        });

        this.time.addListener('time', (date) => {
            /**
             * Time object consumed (deprecated).
             *
             * This event needs to be enabled by calling
             * {@link Device#init} before it will fire.
             *
             * @event Device#time
             * @deprecated Use {@link Time#event:time} instead.
             */
            this.emit('time', date);
        });

        if (this.lss) {
            this.lss.addListener('changeMode', (mode) => {
                /**
                 * Change of LSS mode (deprecated).
                 *
                 * This event needs to be enabled by calling
                 * {@link Device#init} before it will fire.
                 *
                 * @event Device#lssChangeMode
                 * @deprecated Use {@link Lss#event:changeMode} instead.
                 */
                this.emit('lssChangeMode', mode);
            });

            this.lss.addListener('changeDeviceId', (id) => {
                /**
                 * Change of device id (deprecated).
                 *
                 * This event needs to be enabled by calling
                 * {@link Device#init} before it will fire.
                 *
                 * @event Device#lssChangeDeviceId
                 * @deprecated Use {@link Lss#event:changeDeviceId} instead.
                 */
                this.emit('lssChangeDeviceId', id);
            });
        }

        this.emcy.deviceId = this.id;

        for (const obj of Object.values(this.protocol)) {
            if (typeof obj.init === 'function')
                obj.init();
        }

    }, 'Device.init() is deprecated. Use Device.start() instead.');

/**
 * Set the send function.
 *
 * This method has been deprecated. Add a listener for the 'message' event
 * instead.
 *
 * @param {Function} send - send function.
 * @deprecated Use {@link https://nodejs.org/api/events.html#emitteroneventname-listener|Device.on('message')} instead.
 * @function
 */
Device.prototype.setTransmitFunction = deprecate(
    function (send) {
        this.addListener('message', send);
    }, "Device.setTransmitFunction() is deprecated. Use Device.on('message') instead.");

/**
 * Old name for mapRemoteNode() that was available in the development version.
 *
 * @deprecated
 * @ignore
 */
Device.prototype.mapEds = deprecate(
    function (args) {
        if (args.serverId !== undefined)
            args.id = args.serverId;

        this.mapRemoteNode(args);
    }, 'Device.mapEds() is deprecated. Use Device.mapRemoteNode() instead.');

module.exports = exports = Device;