protocol/sdo_server.js

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

const Protocol = require('./protocol');
const { DataObject, Eds } = require('../eds');
const { AccessType } = require('../types');
const { SdoCode, SdoTransfer, ClientCommand, ServerCommand } = require('./sdo');
const calculateCrc = require('../functions/crc');
const rawToType = require('../functions/raw_to_type');
const { deprecate } = require('util');

/**
 * CANopen SDO protocol handler (Server).
 *
 * The service data object (SDO) protocol uses a client-server structure where
 * a client can initiate the transfer of data from the server's object
 * dictionary. An SDO is transfered as a sequence of segments with basic
 * error checking.
 *
 * @param {Eds} eds - parent device.
 * @see CiA301 'Service data object (SDO)' (ยง7.2.4)
 * @implements {Protocol}
 */
class SdoServer extends Protocol {
    constructor(eds) {
        super(eds);
        this.transfers = {};
        this._blockSize = 127;
    }

    /**
     * Number of segments per block when serving block transfers.
     *
     * @type {number}
     */
    get blockSize() {
        return this._blockSize;
    }

    /**
     * Set the number of segments per block when serving block transfers.
     *
     * @param {number} value - block size [1-127].
     * @since 6.0.0
     */
    setBlockSize(value) {
        if (value < 1 || value > 127)
            throw RangeError('blockSize must be in range [1-127]');

        this._blockSize = value;
    }

    /**
     * Start the module.
     *
     * @override
     */
    start() {
        if(!this.started) {
            this.transfers = {};
            for (const client of this.eds.getSdoServerParameters())
                this._addClient(client);

            this.addEdsCallback('newSdoClient',
                (client) => this._addClient(client));

            this.addEdsCallback('removeSdoClient',
                (client) => this._removeClient(client));

            super.start();
        }
    }

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

            for (const client of this.eds.getSdoServerParameters())
                this._removeClient(client);

            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;
     * @override
     */
    receive({ id, data }) {
        // Handle transfers as a server (local object dictionary)
        const client = this.transfers[id];
        if (client === undefined)
            return;

        if (client.blockTransfer) {
            // Block transfer in progress
            if ((data[0] & 0x7f) === client.blockSequence + 1) {
                client.data = Buffer.concat([client.data, data.slice(1)]);
                client.blockSequence++;
                if (data[0] & 0x80) {
                    client.blockFinished = true;
                    client.blockTransfer = false;
                }
            }

            if (client.blockSequence > 127) {
                this._abortTransfer(client, SdoCode.BAD_BLOCK_SEQUENCE);
                return;
            }

            // Awknowledge block
            if (client.blockFinished
                || client.blockSequence == this.blockSize) {

                const header = (ServerCommand.BLOCK_DOWNLOAD << 5)
                    | (2 << 0); // Block download response

                const sendBuffer = Buffer.alloc(8);
                sendBuffer.writeUInt8(header);
                sendBuffer.writeInt8(client.blockSequence, 1);
                sendBuffer.writeUInt8(this.blockSize, 2);

                this.send(client.cobId, sendBuffer);

                // Reset sequence
                client.blockSequence = 0;
            }

            client.refresh();
            return;
        }

        switch (data[0] >> 5) {
            case ClientCommand.DOWNLOAD_SEGMENT:
                this._downloadSegment(client, data);
                break;
            case ClientCommand.DOWNLOAD_INITIATE:
                this._downloadInitiate(client, data);
                break;
            case ClientCommand.UPLOAD_INITIATE:
                this._uploadInitiate(client, data);
                break;
            case ClientCommand.UPLOAD_SEGMENT:
                this._uploadSegment(client, data);
                break;
            case ClientCommand.ABORT:
                client.reject();
                break;
            case ClientCommand.BLOCK_UPLOAD:
                switch (data[0] & 0x3) {
                    case 0:
                        // Initiate upload
                        this._blockUploadInitiate(client, data);
                        break;
                    case 1:
                        // End upload
                        this._blockUploadEnd(client);
                        break;
                    case 2:
                        // Confirm block
                        this._blockUploadConfirm(client, data);
                        break;
                    case 3:
                        // Start upload
                        this._blockUploadProcess(client);
                        break;
                }
                break;
            case ClientCommand.BLOCK_DOWNLOAD:
                this._blockDownload(client, data);
                break;
            default:
                this._abortTransfer(client, SdoCode.BAD_COMMAND);
                break;
        }
    }

    /**
     * Add an SDO client.
     *
     * @param {object} args - SDO client parameters.
     * @param {number} args.cobIdTx - COB-ID server -> client.
     * @param {number} args.cobIdRx - COB-ID client -> server.
     * @private
     */
    _addClient({ cobIdTx, cobIdRx }) {
        this.transfers[cobIdRx] = new SdoTransfer({ cobId: cobIdTx });
    }

    /**
     * Remove an SDO client.
     *
     * @param {object} args - SDO client parameters.
     * @param {number} args.cobIdRx - COB-ID client -> server.
     * @private
     */
    _removeClient({ cobIdRx }) {
        const transfer = this.transfers[cobIdRx];
        if(transfer) {
            if (transfer.active)
                this._abortTransfer(transfer, SdoCode.DEVICE_STATE);

            delete this.transfers[cobIdRx];
        }
    }

    /**
     * Handle ClientCommand.DOWNLOAD_INITIATE.
     *
     * @param {SdoTransfer} client - SDO context.
     * @param {Buffer} data - message data.
     * @fires Protocol#message
     * @private
     */
    _downloadInitiate(client, data) {
        client.index = data.readUInt16LE(1);
        client.subIndex = data.readUInt8(3);

        if (data[0] & 0x02) {
            // Expedited client
            let entry = this.eds.getEntry(client.index);
            if (entry === undefined) {
                this._abortTransfer(client, SdoCode.OBJECT_UNDEFINED);
                return;
            }

            if (entry.subNumber > 0) {
                entry = entry[client.subIndex];
                if (entry === undefined) {
                    this._abortTransfer(client, SdoCode.BAD_SUB_INDEX);
                    return;
                }
            }

            if (entry.accessType == AccessType.CONSTANT
                || entry.accessType == AccessType.READ_ONLY) {
                this._abortTransfer(client, SdoCode.READ_ONLY);
                return;
            }

            const count = (data[0] & 1) ? (4 - ((data[0] >> 2) & 3)) : 4;
            const raw = Buffer.alloc(count);
            data.copy(raw, 0, 4, count + 4);

            const value = rawToType(raw, entry.dataType);
            if (entry.highLimit !== undefined && value > entry.highLimit) {
                this._abortTransfer(client, SdoCode.VALUE_HIGH);
                return;
            }

            if (entry.lowLimit !== undefined && value < entry.lowLimit) {
                this._abortTransfer(client, SdoCode.VALUE_LOW);
                return;
            }

            entry.raw = raw;

            const sendBuffer = Buffer.alloc(8);
            sendBuffer.writeUInt8(ServerCommand.DOWNLOAD_INITIATE << 5);
            sendBuffer.writeUInt16LE(client.index, 1);
            sendBuffer.writeUInt8(client.subIndex, 3);

            this.send(client.cobId, sendBuffer);
        }
        else {
            // Segmented client
            client.data = Buffer.alloc(0);
            client.size = 0;
            client.toggle = 0;

            const sendBuffer = Buffer.alloc(8);
            sendBuffer.writeUInt8(ServerCommand.DOWNLOAD_INITIATE << 5);
            sendBuffer.writeUInt16LE(client.index, 1);
            sendBuffer.writeUInt8(client.subIndex, 3);

            this.send(client.cobId, sendBuffer);

            client.start();
        }
    }

    /**
     * Handle ClientCommand.UPLOAD_INITIATE.
     *
     * @param {SdoTransfer} client - SDO context.
     * @param {Buffer} data - message data.
     * @fires Protocol#message
     * @private
     */
    _uploadInitiate(client, data) {
        client.index = data.readUInt16LE(1);
        client.subIndex = data.readUInt8(3);

        let entry = this.eds.getEntry(client.index);
        if (entry === undefined) {
            this._abortTransfer(client, SdoCode.OBJECT_UNDEFINED);
            return;
        }

        if (entry.subNumber > 0) {
            entry = entry[client.subIndex];
            if (entry === undefined) {
                this._abortTransfer(client, SdoCode.BAD_SUB_INDEX);
                return;
            }
        }

        if (entry.accessType == AccessType.WRITE_ONLY) {
            this._abortTransfer(client, SdoCode.WRITE_ONLY);
            return;
        }

        if (entry.size <= 4) {
            // Expedited client
            const sendBuffer = Buffer.alloc(8);
            const header = (ServerCommand.UPLOAD_INITIATE << 5)
                | ((4 - entry.size) << 2)
                | 0x2;

            sendBuffer.writeUInt8(header, 0);
            sendBuffer.writeUInt16LE(client.index, 1);
            sendBuffer.writeUInt8(client.subIndex, 3);

            entry.raw.copy(sendBuffer, 4);

            if (entry.size < 4)
                sendBuffer[0] |= ((4 - entry.size) << 2) | 0x1;

            this.send(client.cobId, sendBuffer);
        }
        else {
            // Segmented client
            client.data = Buffer.from(entry.raw);
            client.size = 0;
            client.toggle = 0;

            const sendBuffer = Buffer.alloc(8);
            const header = (ServerCommand.UPLOAD_INITIATE << 5) | 0x1;

            sendBuffer.writeUInt8(header, 0);
            sendBuffer.writeUInt16LE(client.index, 1);
            sendBuffer.writeUInt8(client.subIndex, 3);
            sendBuffer.writeUInt32LE(client.data.length, 4);

            this.send(client.cobId, sendBuffer);

            client.start();
        }
    }

    /**
     * Handle ClientCommand.UPLOAD_SEGMENT.
     *
     * @param {SdoTransfer} client - SDO context.
     * @param {Buffer} data - message data.
     * @fires Protocol#message
     * @private
     */
    _uploadSegment(client, data) {
        if ((data[0] & 0x10) != (client.toggle << 4)) {
            this._abortTransfer(client, SdoCode.TOGGLE_BIT);
            return;
        }

        const sendBuffer = Buffer.alloc(8);
        let count = Math.min(7, (client.data.length - client.size));
        client.data.copy(
            sendBuffer, 1, client.size, client.size + count);

        let header = (client.toggle << 4) | (7 - count) << 1;
        if (client.size == client.data.length) {
            header |= 1;
            client.resolve();
        }

        sendBuffer.writeUInt8(header, 0);
        client.toggle ^= 1;
        client.size += count;

        this.send(client.cobId, sendBuffer);

        client.refresh();
    }

    /**
     * Handle ClientCommand.DOWNLOAD_SEGMENT.
     *
     * @param {SdoTransfer} client - SDO context.
     * @param {Buffer} data - message data.
     * @fires Protocol#message
     * @private
     */
    _downloadSegment(client, data) {
        if ((data[0] & 0x10) != (client.toggle << 4)) {
            this._abortTransfer(client, SdoCode.TOGGLE_BIT);
            return;
        }

        const count = (7 - ((data[0] >> 1) & 0x7));
        const payload = data.slice(1, count + 1);
        const size = client.data.length + count;

        client.data = Buffer.concat([client.data, payload], size);

        if (data[0] & 1) {
            let entry = this.eds.getEntry(client.index);
            if (entry === undefined) {
                this._abortTransfer(client, SdoCode.OBJECT_UNDEFINED);
                return;
            }

            if (entry.subNumber > 0) {
                entry = entry[client.subIndex];
                if (entry === undefined) {
                    this._abortTransfer(client, SdoCode.BAD_SUB_INDEX);
                    return;
                }
            }

            if (entry.accessType == AccessType.CONSTANT
                || entry.accessType == AccessType.READ_ONLY) {
                this._abortTransfer(client, SdoCode.READ_ONLY);
                return;
            }

            const raw = Buffer.alloc(size);
            client.data.copy(raw);

            const value = rawToType(raw, entry.dataType);
            if (entry.highLimit !== undefined && value > entry.highLimit) {
                this._abortTransfer(client, SdoCode.VALUE_HIGH);
                return;
            }

            if (entry.lowLimit !== undefined && value < entry.lowLimit) {
                this._abortTransfer(client, SdoCode.VALUE_LOW);
                return;
            }

            entry.raw = raw;

            client.resolve();
        }

        const sendBuffer = Buffer.alloc(8);
        const header = (ServerCommand.DOWNLOAD_SEGMENT << 5)
            | (client.toggle << 4);

        sendBuffer.writeUInt8(header);
        client.toggle ^= 1;

        this.send(client.cobId, sendBuffer);

        client.refresh();
    }

    /**
     * Download a data block.
     *
     * Sub-blocks are scheduled using setInterval to avoid blocking during
     * large transfers.
     *
     * @param {SdoTransfer} client - SDO context.
     * @fires Protocol#message
     * @private
     */
    _blockUploadProcess(client) {
        if (client.blockInterval) {
            // Re-schedule timer
            clearInterval(client.blockInterval);
        }

        client.blockInterval = setInterval(() => {
            if (!client.active) {
                // Transfer was interrupted
                clearInterval(client.blockInterval);
                client.blockInterval = null;
                return;
            }

            const sendBuffer = Buffer.alloc(8);
            const offset = 7 * (client.blockSequence
                + (client.blockCount * client.blockSize));

            sendBuffer[0] = ++client.blockSequence;
            if ((offset + 7) >= client.data.length) {
                sendBuffer[0] |= 0x80; // Last block
                client.blockFinished = true;
            }

            client.data.copy(sendBuffer, 1, offset, offset + 7);
            this.send(client.cobId, sendBuffer);

            client.refresh();

            if (client.blockFinished
                || client.blockSequence >= client.blockSize) {
                clearInterval(client.blockInterval);
                client.blockInterval = null;
            }
        });
    }

    /**
     * Handle ClientCommand.BLOCK_UPLOAD.
     *
     * @param {SdoTransfer} client - SDO context.
     * @param {Buffer} data - message data.
     * @fires Protocol#message
     * @private
     */
    _blockUploadInitiate(client, data) {
        client.index = data.readUInt16LE(1);
        client.subIndex = data.readUInt8(3);
        client.blockSize = data.readUInt32LE(4);
        client.blockCrc = !!(data[0] & (1 << 2));

        let entry = this.eds.getEntry(client.index);
        if (entry === undefined) {
            this._abortTransfer(client, SdoCode.OBJECT_UNDEFINED);
            return;
        }

        if (entry.subNumber > 0) {
            entry = entry[client.subIndex];
            if (entry === undefined) {
                this._abortTransfer(client, SdoCode.BAD_SUB_INDEX);
                return;
            }
        }

        if (entry.accessType == AccessType.WRITE_ONLY) {
            this._abortTransfer(client, SdoCode.WRITE_ONLY);
            return;
        }

        client.data = Buffer.from(entry.raw);
        client.size = 0;
        client.blockCount = 0;
        client.blockSequence = 0;
        client.blockFinished = false;

        // Confirm transfer
        const header = (ServerCommand.BLOCK_UPLOAD << 5)
            | (1 << 2)      // CRC supported
            | (1 << 1);     // Data size indicated

        const sendBuffer = Buffer.alloc(8);
        sendBuffer.writeUInt8(header);
        sendBuffer.writeUInt16LE(client.index, 1);
        sendBuffer.writeUInt8(client.subIndex, 3);
        sendBuffer.writeUInt16LE(client.data.length, 4);

        this.send(client.cobId, sendBuffer);

        client.start();
    }

    /**
     * Handle ClientCommand.BLOCK_UPLOAD.
     *
     * @param {SdoTransfer} client - SDO context.
     * @param {Buffer} data - message data.
     * @fires Protocol#message
     * @private
     */
    _blockUploadConfirm(client, data) {
        // Check that all sub-blocks were received.
        if (data[1] === client.blockSequence) {
            client.blockCount += 1;
            client.blockSequence = 0;

            if (client.blockFinished) {
                // End block upload
                const sendBuffer = Buffer.alloc(8);

                let header = (ServerCommand.BLOCK_UPLOAD << 5)
                    | (1 << 0); // End block upload

                const emptyBytes = client.data.length % 7;
                if (emptyBytes)
                    header |= (7 - emptyBytes) << 2;

                sendBuffer.writeUInt8(header);

                // Write CRC (if supported)
                if (client.blockCrc) {
                    const crcValue = calculateCrc(client.data);
                    sendBuffer.writeUInt16LE(crcValue, 1);
                }

                this.send(client.cobId, sendBuffer);

                client.refresh();
                return;
            }
        }

        // Update block size for next transfer.
        client.blockSize = data[2];
        if (client.blockSize < 1 || client.blockSize > 127) {
            this._abortTransfer(client, SdoCode.BAD_BLOCK_SIZE);
            return;
        }

        // Upload next block
        this._blockUploadProcess(client);
    }

    /**
     * Handle ClientCommand.BLOCK_UPLOAD.
     *
     * @param {SdoTransfer} client - SDO context.
     * @fires Protocol#message
     * @private
     */
    _blockUploadEnd(client) {
        client.resolve();
    }

    /**
     * Handle ClientCommand.BLOCK_DOWNLOAD.
     *
     * @param {SdoTransfer} client - SDO context.
     * @param {Buffer} data - message data.
     * @fires Protocol#message
     * @private
     */
    _blockDownload(client, data) {
        if (client.blockFinished) {
            // Number of bytes that do not contain segment data
            const count = (data[0] >> 2) & 7;
            if (count) {
                const size = client.data.length - count;
                client.data = client.data.slice(0, size);
            }

            // Check size
            if (client.data.length < client.size) {
                this._abortTransfer(client, SdoCode.DATA_SHORT);
                return;
            }

            if (client.data.length > client.size) {
                this._abortTransfer(client, SdoCode.DATA_LONG);
                return;
            }

            // Check CRC (if supported)
            if (client.blockCrc) {
                const crcValue = data.readUInt16LE(1);
                if (crcValue !== calculateCrc(client.data)) {
                    this._abortTransfer(client, SdoCode.BAD_BLOCK_CRC);
                    return;
                }
            }

            // Get entry
            let entry = this.eds.getEntry(client.index);
            if (entry === undefined) {
                this._abortTransfer(client, SdoCode.OBJECT_UNDEFINED);
                return;
            }

            if (entry.subNumber > 0) {
                entry = entry[client.subIndex];
                if (entry === undefined) {
                    this._abortTransfer(client, SdoCode.BAD_SUB_INDEX);
                    return;
                }
            }

            // Check that the entry has write access
            if (entry.accessType == AccessType.CONSTANT
                || entry.accessType == AccessType.READ_ONLY) {
                this._abortTransfer(client, SdoCode.READ_ONLY);
                return;
            }

            // Check value limits
            const value = rawToType(client.data, entry.dataType);
            if (entry.highLimit !== undefined && value > entry.highLimit) {
                this._abortTransfer(client, SdoCode.VALUE_HIGH);
                return;
            }

            if (entry.lowLimit !== undefined && value < entry.lowLimit) {
                this._abortTransfer(client, SdoCode.VALUE_LOW);
                return;
            }

            // Write new data
            entry.raw = client.data;

            // End transfer
            const header = (ServerCommand.BLOCK_DOWNLOAD << 5)
                | (1 << 0); // End block download

            const sendBuffer = Buffer.alloc(8);
            sendBuffer.writeUInt8(header);

            this.send(client.cobId, sendBuffer);

            client.resolve();
        }
        else {
            // Initiate block transfer
            client.index = data.readUInt16LE(1);
            client.subIndex = data.readUInt8(3);
            client.size = data.readUInt32LE(4);
            client.data = Buffer.alloc(0);
            client.blockTransfer = true;
            client.blockSequence = 0;
            client.blockFinished = false;
            client.blockCrc = !!(data[0] & (1 << 2));

            // Confirm transfer
            const header = (ServerCommand.BLOCK_DOWNLOAD << 5)
                | (1 << 2); // CRC supported

            const sendBuffer = Buffer.alloc(8);
            sendBuffer.writeUInt8(header);
            sendBuffer.writeUInt16LE(client.index, 1);
            sendBuffer.writeUInt8(client.subIndex, 3);
            sendBuffer.writeUInt8(this.blockSize, 4);

            this.send(client.cobId, sendBuffer);

            client.start();
        }
    }

    /**
     * Abort a transfer.
     *
     * @param {SdoTransfer} transfer - SDO context.
     * @param {SdoCode} code - SDO abort code.
     * @fires Protocol#message
     * @private
     */
    _abortTransfer(transfer, code) {
        const sendBuffer = Buffer.alloc(8);
        sendBuffer.writeUInt8(0x80);
        sendBuffer.writeUInt16LE(transfer.index, 1);
        sendBuffer.writeUInt8(transfer.subIndex, 3);
        sendBuffer.writeUInt32LE(code, 4);
        this.send(transfer.cobId, sendBuffer);
        transfer.reject(code);
    }
}

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

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

/**
 * Get an SDO client parameter entry.
 *
 * @param {number} clientId - server COB-ID of the entry to get.
 * @returns {DataObject | null} the matching entry.
 * @deprecated Use {@link Eds#getSdoServerParameters} instead.
 * @function
 */
SdoServer.prototype.getClient = deprecate(
    function (clientId) {
        for (let [index, entry] of this.eds.entries()) {
            index = parseInt(index, 16);
            if (index < 0x1200 || index > 0x127F)
                continue;

            if (entry[3] !== undefined && entry[3].value === clientId)
                return entry;
        }

        return null;
    }, 'SdoServer.getClient() is deprecated. Use Eds.getSdoServerParameters() instead.');

/**
 * Add an SDO server parameter entry.
 *
 * @param {number} clientId - client COB-ID to add.
 * @param {number} cobIdTx - Sdo COB-ID for outgoing messages (to client).
 * @param {number} cobIdRx - Sdo COB-ID for incoming messages (from client).
 * @deprecated Use {@link Eds#addSdoServerParameter} instead.
 * @function
 */
SdoServer.prototype.addClient = deprecate(
    function (clientId, cobIdTx=0x580, cobIdRx=0x600) {
        if((cobIdTx & 0x7F) == 0x0)
            cobIdTx |= clientId;

        if((cobIdRx & 0x7F) == 0x0)
            cobIdRx |= clientId;

        this.eds.addSdoServerParameter(clientId, cobIdTx, cobIdRx);
    }, 'SdoServer.addClient() is deprecated. Use Eds.addSdoServerParameter() instead.');

/**
 * Remove an SDO server parameter entry.
 *
 * @param {number} clientId - client COB-ID of the entry to remove.
 * @deprecated Use {@link Eds#removeSdoServerParameter} instead.
 * @function
 */
SdoServer.prototype.removeClient = deprecate(
    function (clientId) {
        this.eds.removeSdoServerParameter(clientId);
    }, 'SdoServer.removeClient() is deprecated. Use Eds.removeSdoServerParameter() instead.');

module.exports = exports = { SdoServer };