protocol/pdo.js

/**
 * @file Implements the CANopen Process Data Object (PDO) protocol.
 * @author Wilkins White
 * @copyright 2024 Daxbot
 */

const Protocol = require('./protocol');
const { DataObject, Eds, EdsError } = require('../eds');
const { deprecate } = require('util');

/**
 * CANopen PDO protocol handler.
 *
 * The process data object (PDO) protocol follows a producer-consumer structure
 * where one device broadcasts data that can be consumed by any device on the
 * network. Unlike the SDO protocol, PDO transfers are performed with no
 * protocol overhead.
 *
 * @param {Eds} eds - Eds object.
 * @see CiA301 "Process data objects (PDO)" (ยง7.2.2)
 * @implements {Protocol}
 */
class Pdo extends Protocol {
    constructor(eds) {
        super(eds);

        this.receiveMap = {};
        this.transmitMap = {};
        this.eventTimers = {};
        this.events = [];
        this.syncTpdo = {};
        this.syncCobId = null;
        this.updateFlags = {};
    }

    /**
     * Service: PDO write
     *
     * @param {number} cobId - mapped TPDO to send.
     * @fires Protocol#message
     */
    write(cobId) {
        const pdo = this.transmitMap[cobId];
        if (!pdo)
            throw new EdsError(`TPDO 0x${cobId.toString(16)} not mapped.`);

        const data = Buffer.alloc(pdo.dataSize);
        let dataOffset = 0;

        for (const obj of pdo.dataObjects) {
            obj.raw.copy(data, dataOffset);
            dataOffset += obj.raw.length;
        }

        this.send(cobId, data);
    }

    /**
     * Start the module.
     *
     * @override
     */
    start() {
        if(!this.started) {
            const obj1005 = this.eds.getEntry(0x1005);
            if(obj1005)
                this._addEntry(obj1005);

            this.addEdsCallback('newEntry', (obj) => this._addEntry(obj));
            this.addEdsCallback('removeEntry', (obj) => this._removeEntry(obj));

            this.receiveMap = {};
            for (const pdo of this.eds.getReceivePdos())
                this._addRpdo(pdo);

            this.addEdsCallback('newRpdo', (pdo) => this._addRpdo(pdo));
            this.addEdsCallback('removeRpdo', (pdo) => this._removeRpdo(pdo));

            this.transmitMap = {};
            for (const pdo of this.eds.getTransmitPdos())
                this._addTpdo(pdo);

            this.addEdsCallback('newTpdo', (pdo) => this._addTpdo(pdo));
            this.addEdsCallback('removeTpdo', (pdo) => this._removeTpdo(pdo));

            super.start();
        }
    }

    /**
     * Stop the module.
     *
     * @override
     */
    stop() {
        if(this.started) {
            this.removeEdsCallback('newEntry');
            this.removeEdsCallback('removeEntry');

            const obj1005 = this.eds.getEntry(0x1005);
            if(obj1005)
                this._removeEntry(obj1005);

            this.removeEdsCallback('newRpdo');
            this.removeEdsCallback('removeRpdo');

            for (const pdo of this.eds.getReceivePdos())
                this._removeRpdo(pdo);

            this.removeEdsCallback('newTpdo');
            this.removeEdsCallback('removeTpdo');

            for (const pdo of this.eds.getTransmitPdos())
                this._removeTpdo(pdo);

            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 Pdo#pdo
     * @override
     */
    receive({ id, data }) {
        if ((id & 0x7FF) === this.syncCobId) {
            const counter = data[1];
            for (const pdo of Object.values(this.syncTpdo)) {
                if (pdo.started) {
                    if (pdo.transmissionType == 0) {
                        // Acyclic - send only if data changed
                        this.write(pdo.cobId, true);
                    }
                    else if (++pdo.counter >= pdo.transmissionType) {
                        // Cyclic - send every 'n' sync objects
                        this.write(pdo.cobId);
                        pdo.counter = 0;
                    }
                }
                else if (counter >= pdo.syncStart) {
                    pdo.started = true;
                    pdo.counter = 0;
                }
            }
            return;
        }

        const pdo = this.receiveMap[id];
        if(pdo) {
            let dataOffset = 0;

            let updated = false;
            for (const obj of pdo.dataObjects) {
                const size = obj.size;
                if (data.length < dataOffset + size)
                    continue;

                const lastValue = obj.value;
                data.copy(obj.raw, 0, dataOffset, dataOffset + size);
                dataOffset += obj.raw.length;

                if (!updated && lastValue !== obj.value)
                    updated = true;
            }

            if (updated)
                this._emitPdo(pdo);
        }
    }

    /**
     * Listens for new Eds entries.
     *
     * @param {DataObject} entry - new entry.
     * @private
     */
    _addEntry(entry) {
        if(entry.index === 0x1005) {
            this.addUpdateCallback(entry, (obj) => this._parse1005(obj));
            this._parse1005(entry);
        }
    }

    /**
     * Listens for removed Eds entries.
     *
     * @param {DataObject} entry - removed entry.
     * @private
     */
    _removeEntry(entry) {
        if(entry.index === 0x1005) {
            this.removeUpdateCallback(entry);
            this._clear1005();
        }
    }

    /**
     * Called when 0x1005 (COB-ID SYNC) is updated.
     *
     * @param {DataObject} entry - updated DataObject.
     * @private
     */
    _parse1005(entry) {
        const value = entry.value;
        const rtr = (value >> 29) & 0x1;
        const cobId = value & 0x7FF;

        if(rtr != 0x1)
            this.syncCobId = cobId;
        else
            this._clear1005();
    }

    /**
     * Called when 0x1005 (COB-ID SYNC) is removed.
     *
     * @private
     */
    _clear1005() {
        this.syncCobId = null;
    }

    /**
     * Add an RPDO.
     *
     * @param {object} pdo - PDO data.
     * @private
     */
    _addRpdo(pdo) {
        this.receiveMap[pdo.cobId] = pdo;
    }

    /**
     * Remove an RPDO.
     *
     * @param {object} pdo - PDO data.
     * @private
     */
    _removeRpdo(pdo) {
        delete this.receiveMap[pdo.cobId];
    }

    /**
     * Add a TPDO.
     *
     * @param {object} pdo - PDO data.
     * @private
     */
    _addTpdo(pdo) {
        this.transmitMap[pdo.cobId] = pdo;

        if (pdo.transmissionType < 0xF1) {
            // Sent on SYNC
            if (!pdo.syncStart) {
                pdo.started = true;
                pdo.counter = 0;
            }

            this.syncTpdo[pdo.cobId] = pdo;
        }
        else if (pdo.transmissionType == 0xFE) {
            if (pdo.eventTime > 0) {
                // Send on a timer
                const timer = setInterval(
                    () => this.write(pdo.cobId), pdo.eventTime);

                this.eventTimers[pdo.cobId] = timer;
            }
            else if (pdo.inhibitTime > 0) {
                // Send on update, but no faster than the inhibit time
                this.updateFlags[pdo.cobId] = false;
                this.eventTimers[pdo.cobId] = setInterval(() => {
                    if(this.updateFlags[pdo.cobId]) {
                        this.updateFlags[pdo.cobId] = false;
                        this.write(pdo.cobId);
                    }
                }, pdo.inhibitTime);

                for (const obj of pdo.dataObjects) {
                    const key = pdo.cobId.toString(16) + ':' + obj.key;
                    const callback = () => {
                        this.updateFlags[pdo.cobId] = true;
                    };

                    this.addUpdateCallback(obj, callback, key);
                }
            }
            else {
                // Send immediately on value change
                for (const obj of pdo.dataObjects) {
                    const key = pdo.cobId.toString(16) + ':' + obj.key;
                    const callback = () => {
                        this.write(pdo.cobId);
                    };

                    this.addUpdateCallback(obj, callback, key);
                }
            }
        }
    }

    /**
     * Remove a TPDO.
     *
     * @param {object} pdo - PDO data.
     * @private
     */
    _removeTpdo(pdo) {
        if (pdo.transmissionType < 0xF1) {
            delete this.syncTpdo[pdo.cobId];
        }
        else if (pdo.transmissionType == 0xFE) {
            if (pdo.eventTime > 0) {
                clearInterval(this.eventTimers[pdo.cobId]);
                delete this.eventTimers[pdo.cobId];
            }
            else if (pdo.inhibitTime > 0) {
                clearInterval(this.eventTimers[pdo.cobId]);
                delete this.eventTimers[pdo.cobId];
                delete this.updateFlags[pdo.cobId];

                for (const obj of pdo.dataObjects) {
                    const key = pdo.cobId.toString(16) + ':' + obj.key;
                    this.removeUpdateCallback(obj, key);
                }
            }
            else {
                for (const obj of pdo.dataObjects) {
                    const key = pdo.cobId.toString(16) + ':' + obj.key;
                    this.removeUpdateCallback(obj, key);
                }
            }
        }

        delete this.transmitMap[pdo.cobId];
    }

    /**
     * Emit a PDO object.
     *
     * @param {object} pdo - object to emit.
     * @fires Pdo#pdo
     * @private
     */
    _emitPdo(pdo) {
        /**
         * New Pdo data is available.
         *
         * @event Pdo#pdo
         * @type {object}
         * @property {number} cobId - object identifier.
         * @property {number} transmissionType - transmission type.
         * @property {number} inhibitTime - minimum time between updates.
         * @property {Array<DataObject>} dataObjects - mapped objects.
         */
        this.emit('pdo', pdo);
    }
}

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

/**
 * Initialize the device and audit the object dictionary.
 *
 * @deprecated Use {@link Pdo#start} instead.
 * @function
 */
Pdo.prototype.init = deprecate(
    function () {
        this.start();
    }, 'Pdo.init() is deprecated. Use Pdo.start() instead.');

/**
 * Get a RPDO communication parameter entry.
 *
 * @param {number} cobId - COB-ID used by the RPDO.
 * @returns {DataObject | null} the matching entry.
 * @deprecated Use {@link Eds#getReceivePdos} instead.
 * @function
 */
Pdo.prototype.getReceive = deprecate(
    function (cobId) {
        for (let [index, entry] of this.eds.entries()) {
            index = parseInt(index, 16);
            if (index < 0x1400 || index > 0x15FF)
                continue;

            if (entry[1] !== undefined && entry[1].value === cobId)
                return entry;
        }

        return null;
    }, 'Pdo.getReceive() is deprecated. Use Eds.getReceivePdos() instead.');

/**
 * Create a new RPDO communication/mapping parameter entry.
 *
 * @param {number} cobId - COB-ID used by the RPDO.
 * @param {Array<DataObject>} entries - entries to map.
 * @param {object} args - optional arguments.
 * @param {number} [args.type=254] - transmission type.
 * @param {number} [args.inhibitTime=0] - minimum time between writes.
 * @param {number} [args.eventTime=0] - how often to send timer based PDOs.
 * @param {number} [args.syncStart=0] - initial counter value for sync based PDOs.
 * @deprecated Use {@link Eds#addReceivePdo} instead.
 * @function
 */
Pdo.prototype.addReceive = deprecate(
    function (cobId, entries, args = {}) {
        args.cobId = cobId;
        args.dataObjects = entries;
        this.eds.addReceivePdo(args);
    }, 'Pdo.addReceive() is deprecated. Use Eds.addReceivePdo() instead.');

/**
 * Remove a RPDO communication/mapping parameter entry.
 *
 * @param {number} cobId - COB-ID used by the RPDO.
 * @deprecated Use {@link Eds#removeReceivePdo} instead.
 * @function
 */
Pdo.prototype.removeReceive = deprecate(
    function (cobId) {
        this.eds.removeReceivePdo(cobId);
    }, 'Pdo.removeReceive() is deprecated. Use Eds.removeReceivePdo() instead.');

/**
 * Get a TPDO communication parameter entry.
 *
 * @param {number} cobId - COB-ID used by the TPDO.
 * @returns {DataObject | null} the matching entry.
 * @deprecated Use {@link Eds#getTransmitPdos} instead.
 * @function
 */
Pdo.prototype.getTransmit = deprecate(
    function (cobId) {
        for (let [index, entry] of this.eds.entries()) {
            index = parseInt(index, 16);
            if (index < 0x1800 || index > 0x19FF)
                continue;

            if (entry[1] !== undefined && entry[1].value === cobId)
                return entry;
        }

        return null;
    }, 'Pdo.getTransmit() is deprecated. Use Eds.getTransmitPdos() instead.');

/**
 * Create a new TPDO communication/mapping parameter entry.
 *
 * @param {number} cobId - COB-ID used by the TPDO.
 * @param {Array<DataObject>} entries - entries to map.
 * @param {object} args - optional arguments.
 * @param {number} [args.type=254] - transmission type.
 * @param {number} [args.inhibitTime=0] - minimum time between writes.
 * @param {number} [args.eventTime=0] - how often to send timer based PDOs.
 * @param {number} [args.syncStart=0] - initial counter value for sync based PDOs.
 * @deprecated Use {@link Eds#addTransmitPdo} instead.
 * @function
 */
Pdo.prototype.addTransmit = deprecate(
    function (cobId, entries, args = {}) {
        args.cobId = cobId;
        args.dataObjects = entries;
        this.eds.addTransmitPdo(args);
    }, 'Pdo.addTransmit() is deprecated. Use Eds.addTransmitPdo() instead.');

/**
 * Remove a TPDO communication/mapping parameter entry.
 *
 * @param {number} cobId - COB-ID used by the TPDO.
 * @deprecated Use {@link Eds#removeTransmitPdo} instead.
 * @function
 */
Pdo.prototype.removeTransmit = deprecate(
    function (cobId) {
        this.eds.removeTransmitPdo(cobId);
    }, 'Pdo.removeTransmit() is deprecated. Use Eds.removeTransmitPdo() instead.');

module.exports = exports = { Pdo };