protocol/sdo.js

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

const EventEmitter = require('events');

/**
 * CANopen abort codes.
 *
 * @enum {string}
 * @see CiA301 'Protocol SDO abort transfer' (§7.2.4.3.17)
 * @memberof SdoError
 */
const SdoCode = {
    /** Toggle bit not altered. */
    TOGGLE_BIT: 0x05030000,

    /** SDO protocol timed out. */
    TIMEOUT: 0x05040000,

    /** Command specifier not valid or unknown. */
    BAD_COMMAND: 0x05040001,

    /** Invalid block size in block mode. */
    BAD_BLOCK_SIZE: 0x05040002,

    /** Invalid sequence number in block mode. */
    BAD_BLOCK_SEQUENCE: 0x05040003,

    /** CRC error in block mode. */
    BAD_BLOCK_CRC: 0x05040004,

    /** Out of memory. */
    OUT_OF_MEMORY: 0x05040005,

    /** Unsupported access to an object. */
    UNSUPPORTED_ACCESS: 0x06010000,

    /** Attempt to read a write only object. */
    WRITE_ONLY: 0x06010001,

    /** Attempt to write a read only object. */
    READ_ONLY: 0x06010002,

    /** Object does not exist. */
    OBJECT_UNDEFINED: 0x06020000,

    /** Object cannot be mapped to the PDO. */
    OBJECT_NOT_MAPPABLE: 0x06040041,

    /** Number and length of object to be mapped exceeds PDO length. */
    MAP_LENGTH: 0x06040042,

    /** General parameter incompatibility reasons. */
    PARAMETER_INCOMPATIBILITY: 0x06040043,

    /** General internal incompatibility in device. */
    DEVICE_INCOMPATIBILITY: 0x06040047,

    /** Access failed due to hardware error. */
    HARDWARE_ERROR: 0x06060000,

    /** Data type does not match: length of service parameter does not match. */
    BAD_LENGTH: 0x06070010,

    /** Data type does not match: length of service parameter too high. */
    DATA_LONG: 0x06070012,

    /** Data type does not match: length of service parameter too short. */
    DATA_SHORT: 0x06070013,

    /** Sub index does not exist. */
    BAD_SUB_INDEX: 0x06090011,

    /** Invalid value for download parameter. */
    BAD_VALUE: 0x06090030,

    /** Value range of parameter written too high. */
    VALUE_HIGH: 0x06090031,

    /** Value range of parameter written too low. */
    VALUE_LOW: 0x06090032,

    /** Maximum value is less than minimum value. */
    RANGE_ERROR: 0x06090036,

    /** Resource not available: SDO connection. */
    SDO_NOT_AVAILBLE: 0x060A0023,

    /** General error. */
    GENERAL_ERROR: 0x08000000,

    /** Data cannot be transferred or stored to application. */
    DATA_TRANSFER: 0x08000020,

    /**
     * Data cannot be transferred or stored to application because of local
     * control
     */
    LOCAL_CONTROL: 0x08000021,

    /**
     * Data cannot be transferred or stored to application because of present
     * device state.
     */
    DEVICE_STATE: 0x08000022,

    /** Object dictionary not present or dynamic generation failed. */
    OD_ERROR: 0x08000023,

    /** No data available. */
    NO_DATA: 0x08000024,
};

/**
 * CANopen SDO 'Client Command Specifier' codes.
 *
 * @enum {number}
 * @see CiA301 'SDO protocols' (§7.2.4.3)
 * @private
 */
const ClientCommand = {
    DOWNLOAD_SEGMENT: 0,
    DOWNLOAD_INITIATE: 1,
    UPLOAD_INITIATE: 2,
    UPLOAD_SEGMENT: 3,
    ABORT: 4,
    BLOCK_UPLOAD: 5,
    BLOCK_DOWNLOAD: 6,
};

/**
 * CANopen SDO 'Server Command Specifier' codes.
 *
 * @enum {number}
 * @see CiA301 'SDO protocols' (§7.2.4.3)
 * @private
 */
const ServerCommand = {
    UPLOAD_SEGMENT: 0,
    DOWNLOAD_SEGMENT: 1,
    UPLOAD_INITIATE: 2,
    DOWNLOAD_INITIATE: 3,
    ABORT: 4,
    BLOCK_DOWNLOAD: 5,
    BLOCK_UPLOAD: 6,
};


/**
 * Return the error message associated with an AbortCode.
 *
 * @param {SdoCode} code - message to lookup.
 * @returns {string} abort code string.
 * @private
 */
function codeToString(code) {
    switch (code) {
        case SdoCode.TOGGLE_BIT:
            return 'Toggle bit not altered';
        case SdoCode.TIMEOUT:
            return 'SDO protocol timed out';
        case SdoCode.BAD_COMMAND:
            return 'Command specifier not valid or unknown';
        case SdoCode.BAD_BLOCK_SIZE:
            return 'Invalid block size in block mode';
        case SdoCode.BAD_BLOCK_SEQUENCE:
            return 'Invalid sequence number in block mode';
        case SdoCode.BAD_BLOCK_CRC:
            return 'CRC error in block mode';
        case SdoCode.OUT_OF_MEMORY:
            return 'Out of memory';
        case SdoCode.UNSUPPORTED_ACCESS:
            return 'Unsupported access to an object';
        case SdoCode.WRITE_ONLY:
            return 'Attempt to read a write only object';
        case SdoCode.READ_ONLY:
            return 'Attempt to write a read only object';
        case SdoCode.OBJECT_UNDEFINED:
            return 'Object does not exist';
        case SdoCode.OBJECT_NOT_MAPPABLE:
            return 'Object cannot be mapped to the PDO';
        case SdoCode.MAP_LENGTH:
            return 'Number and length of object to be mapped exceeds PDO length';
        case SdoCode.PARAMETER_INCOMPATIBILITY:
            return 'General parameter incompatibility reasons';
        case SdoCode.DEVICE_INCOMPATIBILITY:
            return 'General internal incompatibility in device';
        case SdoCode.HARDWARE_ERROR:
            return 'Access failed due to hardware error';
        case SdoCode.BAD_LENGTH:
            return 'Data type does not match: length of service parameter does not match';
        case SdoCode.DATA_LONG:
            return 'Data type does not match: length of service parameter too high';
        case SdoCode.DATA_SHORT:
            return 'Data type does not match: length of service parameter too short';
        case SdoCode.BAD_SUB_INDEX:
            return 'Sub index does not exist';
        case SdoCode.BAD_VALUE:
            return 'Invalid value for download parameter';
        case SdoCode.VALUE_HIGH:
            return 'Value range of parameter written too high';
        case SdoCode.VALUE_LOW:
            return 'Value range of parameter written too low';
        case SdoCode.RANGE_ERROR:
            return 'Maximum value is less than minimum value';
        case SdoCode.SDO_NOT_AVAILBLE:
            return 'Resource not available: SDO connection';
        case SdoCode.GENERAL_ERROR:
            return 'General error';
        case SdoCode.DATA_TRANSFER:
            return 'Data cannot be transferred or stored to application';
        case SdoCode.LOCAL_CONTROL:
            return 'Data cannot be transferred or stored to application because of local control';
        case SdoCode.DEVICE_STATE:
            return 'Data cannot be transferred or stored to application because of present device state';
        case SdoCode.OD_ERROR:
            return 'Object dictionary not present or dynamic generation failed';
        case SdoCode.NO_DATA:
            return 'No data available';
        default:
            return `Unknown error (0x${code.toString(16).padStart(8, '0')})`;
    }
}

/**
 * Represents an SDO transfer error.
 *
 * @param {SdoCode} code - error code.
 * @param {number} index - object index.
 * @param {number} subIndex - object subIndex.
 */
class SdoError extends Error {
    constructor(code, index, subIndex = null) {
        const message = codeToString(code);

        let tag = index;
        if (typeof index === 'number')
            tag = `0x${index.toString(16)}`;
        if (subIndex !== null)
            tag += `.${subIndex.toString()}`;

        super(`${message} [${tag}]`);

        this.code = code;
        this.name = this.constructor.name;
        Error.captureStackTrace(this, this.constructor);
    }
}

/**
 * Represents an SDO transfer.
 *
 * @private
 */
class SdoTransfer extends EventEmitter {
    constructor(args) {
        super();

        this._resolve = args.resolve;
        this._reject = args.reject;
        this.index = args.index;
        this.subIndex = args.subIndex;
        this.timeout = args.timeout;
        this.cobId = args.cobId;
        this.data = args.data || Buffer.alloc(0);
        this.size = this.data.length;

        this.active = false;
        this.toggle = 0;
        this.timer = null;
        this.blockInterval = null;
        this.blockDownload = false;
        this.blockFinished = false;
        this.blockSequence = 0;
        this.blockCrc = false;
    }

    /** Reset internal values. */
    reset() {
        clearTimeout(this.timer);
        clearInterval(this.blockInterval);
        this.active = false;
        this.timer = null;
        this.blockInterval = null;
        this.blockTransfer = false;
        this.blockFinished = false;
        this.blockSequence = 0;
        this.blockCrc = false;
    }

    /** Begin the transfer timeout. */
    start() {
        this.active = true;
        if (this.timeout) {
            this.timer = setTimeout(
                () => this.abort(SdoCode.TIMEOUT), this.timeout);
        }
    }

    /** Refresh the transfer timeout. */
    refresh() {
        if (!this.timeout || this.timer == null)
            return;

        this.timer.refresh();
    }

    /**
     * Complete the transfer and resolve its promise.
     *
     * @param {Buffer | undefined} data - return data.
     */
    resolve(data) {
        this.reset();
        if (this._resolve)
            this._resolve(data);
    }

    /**
     * Complete the transfer and reject its promise.
     *
     * @param {SdoCode} code - SDO abort code.
     */
    reject(code) {
        this.reset();
        if (this._reject)
            this._reject(new SdoError(code, this.index, this.subIndex));
    }

    /**
     * Abort the transfer.
     *
     * @param {SdoCode} code - SDO abort code.
     */
    abort(code) {
        this.emit('abort', code);
    }
}

module.exports = exports = {
    ClientCommand,
    ServerCommand,
    SdoCode,
    SdoError,
    SdoTransfer,
};