danielwippermann/resol-vbus

View on GitHub
src/connection-customizer.js

Summary

Maintainability
F
3 days
Test Coverage
/*! resol-vbus | Copyright (c) 2013-present, Daniel Wippermann | MIT license */

const {
    applyDefaultOptions,
    isNumber,
    isObject,
} = require('./utils');

const Customizer = require('./customizer');



class ConnectionCustomizer extends Customizer {

    /**
     * Constructs a new ConnectionCustomizer instance and optionally initializes its
     * members with the given values.
     *
     * @constructs
     * @augments Customizer
     * @param {object} [options] Initialization values for this instance's members
     * @param {number} [options.connection] {@link ConnectionCustomizer#connection}
     * @param {number} [options.maxRounds] {@link ConnectionCustomizer#maxRounds}
     * @param {number} [options.triesPerValue] {@link ConnectionCustomizer#triesPerValue}
     * @param {number} [options.timeoutPerValue] {@link ConnectionCustomizer#timeoutPerValue}
     * @param {number} [options.masterTimeout] {@link ConnectionCustomizer#masterTimeout}
     *
     * @classdesc
     * A ConnectionCustomizer uses an established connection to a device
     * to transfer sets of configuration values over it.
     */
    constructor(options) {
        super(options);

        applyDefaultOptions(this, options, /** @lends ConnectionCustomizer.prototype */ {

            /**
            * The connection to use for transfer of the configuration values.
            * @type {Connection}
            */
            connection: null,

            /**
            * Maximum number of optimization rounds for {@link transceiveConfiguration}.
            * @type {number}
            * @default 10
            */
            maxRounds: 10,

            /**
            * Amount of retries to transceive one value.
            * Between two tries the VBus is released and then re-acquired.
            * @type {number}
            * @default 2
            */
            triesPerValue: 2,

            /**
            * Timeout in milliseconds after which the transceive times out.
            * @type {number}
            * @default 30000
            */
            timeoutPerValue: 30000,

            /**
            * Interval in milliseconds in which
            * the VBus master is contacted to reissue the VBus clearance.
            * @type {number}
            * @default 8000
            */
            masterTimeout: 8000,

        });
    }

    /**
     * Load a set of configuration values from a device.
     *
     * See {@link Customizer#loadConfiguration} for details.
     */
    async _loadConfiguration(configuration, options) {
        options = {
            action: 'get',
            ...options,
        };

        const callback = (config, round) => {
            if (options.optimize) {
                return this._optimizeLoadConfiguration(config);
            } else {
                if (round === 1) {
                    for (const value of configuration) {
                        value.pending = true;
                    }

                    return configuration;
                } else {
                    return config;
                }
            }
        };

        return this.transceiveConfiguration(options, callback);
    }

    /**
     * Save a set of configuration values to a device.
     *
     * See {@link Customizer#saveConfiguration} for details.
     */
    async _saveConfiguration(newConfiguration, oldConfigurstion, options) {
        options = {
            action: 'set',
            actionOptions: {
                save: true,
            },
            ...options,
        };

        const callback = (config, round) => {
            if (options.optimize) {
                if (round === 1) {
                    return this._optimizeSaveConfiguration(newConfiguration, oldConfigurstion);
                } else {
                    return this._optimizeSaveConfiguration(newConfiguration, config);
                }
            } else {
                if (round === 1) {
                    for (const value of newConfiguration) {
                        value.pending = true;
                    }

                    return newConfiguration;
                } else {
                    return config;
                }
            }
        };

        return this.transceiveConfiguration(options, callback);
    }

    /**
     * Transceives a controller configuration set, handling timeouts, retries etc.
     *
     * @param {object} options Options
     * @param {number} [options.maxRounds] {@link ConnectionCustomizer#maxRounds}
     * @param {number} [options.triesPerValue] {@link ConnectionCustomizer#triesPerValue}
     * @param {number} [options.timeoutPerValue] {@link ConnectionCustomizer#timeoutPerValue}
     * @param {number} [options.masterTimeout] {@link ConnectionCustomizer#masterTimeout}
     * @param {string} options.action Action to perform, can be `'get'` or `'set'`.
     * @param {object} [options.actionOptions] Options object to forward to the action to perform.
     * @param {function} [options.reportProgress] Callback to inform about progress.
     * @param {function} [options.checkCanceled] Callback to check whether the operation should be canceled.
     * @param {function} optimizerCallback Callback to optimize configuration between rounds.
     * @return {object} Promise that resolves to the configuration or `null` on timeout.
     */
    async transceiveConfiguration(options, optimizerCallback) {
        if (typeof options === 'function') {
            optimizerCallback = options;
            options = null;
        }

        options = {
            maxRounds: this.maxRounds,
            triesPerValue: this.triesPerValue,
            timeoutPerValue: this.timeoutPerValue,
            masterTimeout: this.masterTimeout,
            action: null,
            actionOptions: null,
            reportProgress: null,
            checkCanceled: null,
            ...options,
        };

        const { connection } = this;
        const address = this.deviceAddress;

        async function check() {
            if (options.checkCanceled) {
                if (await options.checkCanceled()) {
                    throw new Error('Canceled');
                }
            }

            await connection.createConnectedPromise();
        }

        let config = null;

        const state = {
            masterAddress: null,
            masterLastContacted: null,
        };

        const reportProgress = function(progress) {
            if (options.reportProgress) {
                options.reportProgress(progress);
            }
        };

        for (let round = 1; round <= options.maxRounds; round++) {
            await check();

            reportProgress({
                message: 'OPTIMIZING_VALUES',
                round,
            });

            config = await optimizerCallback(config, round);

            await check();

            const pendingValues = config.filter((value) => {
                return value.pending;
            });

            if (pendingValues.length > 0) {
                for (let index = 0; index < pendingValues.length; index++) {
                    const valueInfo = pendingValues [index++];

                    let reportProgress;
                    if (options.reportProgress) {
                        reportProgress = (progress) => {
                            progress = {
                                ...progress,
                                valueId: valueInfo.valueId,
                                valueIndex: valueInfo.valueIndex,
                                valueIdHash: valueInfo.valueIdHash,
                                valueNr: index,
                                valueCount: pendingValues.length,
                            };

                            return options.reportProgress(progress);
                        };
                    }

                    await check();

                    const datagram = await this.transceiveValue(valueInfo, valueInfo.value, {
                        triesPerValue: options.triesPerValue,
                        timeoutPerValue: options.timeoutPerValue,
                        action: options.action,
                        actionOptions: options.actionOptions,
                        reportProgress,
                    }, state);

                    valueInfo.pending = false;
                    valueInfo.transceived = !!datagram;

                    if (datagram) {
                        valueInfo.value = datagram.value;
                    }
                }
            } else {
                break;
            }
        }

        if (state.masterLastContacted !== null) {
            reportProgress({
                message: 'RELEASING_BUS',
            });

            await connection.releaseBus(address);
        }

        return config;
    }

    /**
     * Transceive a controller value over this connection, handling
     * timeouts, retries etc.
     *
     * @param {object|number} valueInfoOrIndex Value info object or value index
     * @param {number} valueInfo.valueIndex Value index
     * @param {number} valueInfo.valueIdHash Value ID hash
     * @param {number} value Value
     * @param {object} options Options
     * @param {number} options.triesPerValue {@link ConnectionCustomizer#triesPerValue}
     * @param {number} options.timeoutPerValue {@link ConnectionCustomizer#timeoutPerValue}
     * @param {number} options.masterTimeout {@link ConnectionCustomizer#masterTimeout}
     * @param {string} options.action Action to perform, can be `'get'` or `'set'`.
     * @param {object} [options.actionOptions] Options object to forward to the action to perform.
     * @param {function} [options.reportProgress] Callback to inform about progress.
     * @param {function} [options.checkCanceled] Callback to check whether the operation should be canceled.
     * @param {object} state State to share between multiple calls to this method.
     * @returns {object} Promise that resolves with the datagram received or `null` on timeout.
     */
    async transceiveValue(valueInfo, value, options, state) {
        if (!isObject(valueInfo)) {
            valueInfo = {
                valueIndex: valueInfo,
            };
        }

        if (state === undefined) {
            state = {};
        }

        options = {
            triesPerValue: this.triesPerValue,
            timeoutPerValue: this.timeoutPerValue,
            masterTimeout: this.masterTimeout,
            action: null,
            actionOptions: null,
            reportProgress: null,
            checkCanceled: null,
            ...options,
        };

        state = applyDefaultOptions(state, state, {
            masterAddress: this.deviceAddress,
            masterLastContacted: Date.now(),
        });

        let isTimedOut = false;

        let timer = setTimeout(() => {
            timer = null;
            isTimedOut = true;
        }, options.timeoutPerValue);

        try {
            const { connection } = this;
            const address = this.deviceAddress;

            let result;

            async function check() {
                if (isTimedOut) {
                    result = null;
                    return false;
                }

                if (options.checkCanceled) {
                    if (await options.checkCanceled()) {
                        throw new Error('Canceled');
                    }
                }

                await connection.createConnectedPromise();

                return true;
            }

            for (let tries = 1; tries <= options.triesPerValue; tries++) {
                const reportProgress = function(message) {
                    if (options.reportProgress) {
                        options.reportProgress({
                            message,
                            tries,
                            valueIndex: valueInfo.valueIndex,
                            valueInfo,
                        });
                    }
                };

                if (!await check()) {
                    break;
                }

                if ((tries > 1) && (state.masterLastContacted !== null)) {
                    reportProgress('RELEASING_BUS');

                    state.masterLastContacted = null;

                    await connection.releaseBus(state.masterAddress);
                }

                if (!await check()) {
                    break;
                }

                if ((state.masterLastContacted === null) && (options.masterTimeout !== null)) {
                    reportProgress('WAITING_FOR_FREE_BUS');

                    const datagram = await connection.waitForFreeBus();  // TODO: optional timeout?

                    if (datagram) {
                        state.masterAddress = datagram.sourceAddress;
                    } else {
                        state.masterAddress = null;
                    }
                }

                if (!await check()) {
                    break;
                }

                let contactMaster;
                if (state.masterAddress === null) {
                    contactMaster = false;
                } else if (state.masterAddress === address) {
                    contactMaster = false;
                } else if (state.masterLastContacted === null) {
                    contactMaster = true;
                } else if ((Date.now() - state.masterLastContacted) >= options.masterTimeout) {
                    contactMaster = true;
                } else {
                    contactMaster = false;
                }
                if (contactMaster) {
                    reportProgress('CONTACTING_MASTER');

                    state.masterLastContacted = Date.now();

                    await connection.getValueById(state.masterAddress, 0, {
                        timeout: 500,
                        tries: 1,
                    });
                }

                if (!await check()) {
                    break;
                }

                if (state.masterAddress === address) {
                    state.masterLastContacted = Date.now();
                }

                if (isNumber(valueInfo.valueIndex)) {
                    // nop
                } else if (isNumber(valueInfo.valueIdHash)) {
                    reportProgress('LOOKING_UP_VALUE');

                    const datagram = await connection.getValueIdByIdHash(address, valueInfo.valueIdHash, options.actionOptions);

                    if (datagram && datagram.valueId) {
                        valueInfo.valueIndex = datagram.valueId;
                    }
                }

                if (!await check()) {
                    break;
                }

                if (state.masterAddress === address) {
                    state.masterLastContacted = Date.now();
                }

                if (!isNumber(valueInfo.valueIndex)) {
                    result = null;
                } else if (options.action === 'get') {
                    reportProgress('GETTING_VALUE');

                    result = await connection.getValueById(address, valueInfo.valueIndex, options.actionOptions);
                } else if (options.action === 'set') {
                    reportProgress('SETTING_VALUE');

                    result = await connection.setValueById(address, valueInfo.valueIndex, value, options.actionOptions);
                } else {
                    throw new Error('Unknown action "' + options.action + '"');
                }

                if (result) {
                    break;
                }
            }

            return result;
        } finally {
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }
        }
    }
}


Object.assign(ConnectionCustomizer.prototype, /** @lends ConnectionCustomizer.prototype */ {

    /**
     * The connection to use for transfer of the configuration values.
     * @type {Connection}
     */
    connection: null,

    /**
     * Maximum number of optimization rounds for {@link transceiveConfiguration}.
     * @type {number}
     * @default 10
     */
    maxRounds: 10,

    /**
     * Amount of retries to transceive one value.
     * Between two tries the VBus is released and then re-acquired.
     * @type {number}
     * @default 2
     */
    triesPerValue: 2,

    /**
     * Timeout in milliseconds after which the transceive times out.
     * @type {number}
     * @default 30000
     */
    timeoutPerValue: 30000,

    /**
     * Interval in milliseconds in which
     * the VBus master is contacted to reissue the VBus clearance.
     * @type {number}
     * @default 8000
     */
    masterTimeout: 8000,

});



module.exports = ConnectionCustomizer;