Source: lib/CometVisuClient.js

/* CometVisuClient.js 
 * 
 * copyright (c) 2010-2017, Christian Mayer and the CometVisu contributers.
 * 
 * This program is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation; either version 3 of the License, or (at your option)
 * any later version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA
 */


/**
 * The JavaScript library that implements the CometVisu protocol.
 *
 * @module lib/CometVisuClient
 * @exports ComentVisuClient
 * @requires dependencies/jquery
 * @author Christan Mayer
 * @author Tobias Bräutigam
 * @since 0.5.3 (initial contribution) 0.10.0 (major refactoring)
 */
define( ['jquery'], function( $ ) {
  "use strict";

  // ////////////////////////////////////////////////////////////////////////
  // module global static variables and methods:

  var
  // used for backwards compability
    backendNameAliases = {
      'cgi-bin' : 'default',
      'oh'      : 'openhab',
      'oh2'     : 'openhab2'
    },
  // setup of the different known backends
    backends = {
      'default' : {
        name      : 'default',
        baseURL   : '/cgi-bin/',
        transport : 'long-polling',
        resources : {
          login     : 'l',
          read      : 'r',
          write     : 'w',
          rrd       : 'rrdfetch'
        },
        maxConnectionAge: 60 * 1000, // in milliseconds - restart if last read is older
        maxDataAge: 3200 * 1000, // in milliseconds - reload all data when last successful read is older (should be faster than the index overflow at max data rate, i.e. 2^16 @ 20 tps for KNX TP)
        hooks     : {}
      },
      'openhab' : {
        name          : 'openHAB',
        baseURL       : '/services/cv/',
        // keep the e.g. atmosphere tracking-id if there is one
        resendHeaders : {
          'X-Atmosphere-tracking-id' : undefined
        },
        // fixed headers that are send everytime
        headers       : {
          'X-Atmosphere-Transport' : 'long-polling'
        },
        hooks         : {
          onClose : function() {
            // send an close request to the openHAB server
            var oldValue = this.headers["X-Atmosphere-Transport"];
            this.headers["X-Atmosphere-Transport"] = "close";
            $.ajax({
              url         : this.getResourcePath('read'),
              dataType    : 'json',
              context     : this,
              beforeSend  : this.beforeSend
            });
            if (oldValue != undefined) {
              this.headers["X-Atmosphere-Transport"] = oldValue;
            } else {
              delete this.headers["X-Atmosphere-Transport"];
            }
          }
        }
      }
    },
  // definition of the different supported transport layers (OSI layer 4).
    transportLayers = {
      'long-polling' : function( session ){
        var self = this;
        this.doRestart         = false; // are we currently in a restart, e.g. due to the watchdog
        this.xhr               = false; // the ongoing AJAX request
        this.lastIndex         = -1; // index returned by the last request
        this.retryCounter      = 0; // count number of retries (reset with each valid response)

        /**
         * This function gets called once the communication is established
         * and session information is available.
         *
         * @param json
         * @param connect (boolean) wether to start the connection or not
         * @method handleSession
         */
        this.handleSession = function(json, connect) {
          self.sessionId = json.s;
          self.version = json.v.split('.', 3);

          if (0 < parseInt(self.version[0])
            || 1 < parseInt(self.version[1]))
            alert('ERROR CometVisu Client: too new protocol version ('
              + json.v + ') used!');

          if (connect) {
            this.connect();
          }
        };

        this.connect = function() {
          self.running = true;
          // send first request
          if (session.initialAddresses.length) {
            this.xhr = $.ajax({
              url         : session.getResourcePath("read"),
              dataType    : 'json',
              context     : this,
              data        : 't=0&' + session.buildRequest(session.initialAddresses),
              success     : this.handleReadStart,
              error       : this.handleError,
              beforeSend  : this.beforeSend
            });
          } else {
            // old behaviour -> start full query
            this.xhr = $.ajax({
              url         : session.getResourcePath("read"),
              dataType    : 'json',
              context     : this,
              data        : 't=0&' + session.buildRequest(),
              success     : this.handleRead,
              error       : this.handleError,
              beforeSend  : this.beforeSend
            });
          }
        };
        /**
         * This function gets called once the communication is established
         * and session information is available
         *
         * @method handleRead
         * @param json
         */
        this.handleRead = function(json) {
          if( this.doRestart || (!json && (-1 == this.lastIndex)) ) {
            session.dataReceived = false;
            if (self.running) { // retry initial request
              this.retryCounter++;
              this.xhr = $.ajax({
                url : session.getResourcePath("read"),
                dataType : 'json',
                context : this,
                data : 't=0&' + session.buildRequest(),
                success : this.handleRead,
                error : this.handleError,
                beforeSend : this.beforeSend
              });
              session.watchdog.ping( true );
            }
            return;
          }

          if (json && !this.doRestart) {
            this.lastIndex = json.i;
            var data = json.d;
            this.readResendHeaderValues();
            session.update(data);
            this.retryCounter = 0;
            session.dataReceived = true;
          }

          if (self.running) { // keep the requests going
            this.retryCounter++;
            this.xhr = $.ajax({
              url         : session.getResourcePath("read"),
              dataType    : 'json',
              context     : this,
              data        : 'i=' + this.lastIndex + '&' + session.buildRequest(),
              success     : this.handleRead,
              error       : this.handleError,
              beforeSend  : this.beforeSend
            });
            session.watchdog.ping();
          }
        };

        this.handleReadStart = function(json) {
          if (!json && (-1 == this.lastIndex)) {
            session.dataReceived = false;
            if (self.running) { // retry initial request
              this.xhr = $.ajax({
                url         : session.getResourcePath("read"),
                dataType    : 'json',
                context     : this,
                data        : 't=0&' + session.buildRequest(session.initialAddresses),
                success     : this.handleReadStart,
                error       : this.handleError,
                beforeSend  : this.beforeSend
              });
              session.watchdog.ping();
            }
            return;
          }
          if (json && !this.doRestart) {
            this.readResendHeaderValues();
            session.update(json.d);
            session.dataReceived = true;
          }
          if (self.running) { // keep the requests going, but only
            // request
            // addresses-startPageAddresses
            var diffAddresses = [];
            for (var i = 0; i < session.addresses.length; i++) {
              if ($.inArray(self.addresses[i],
                  session.initialAddresses) < 0)
                diffAddresses.push(session.addresses[i]);
            }
            this.xhr = $.ajax({
              url         : session.getResourcePath("read"),
              dataType    : 'json',
              context     : this,
              data        : 't=0&' + session.buildRequest(diffAddresses),
              success     : this.handleRead,
              error       : this.handleError,
              beforeSend  : this.beforeSend
            });
            session.watchdog.ping();
          }
        };

        /**
         * This function gets called on an error FIXME: this should be a
         * prototype, so that the application developer can override it
         *
         * @method handleError
         * @param xhr
         * @param str
         * @param excptObj
         */
        this.handleError = function(xhr, str, excptObj) {
          if (self.running && xhr.readyState != 4
            && !this.doRestart && xhr.status !== 0) // ignore error when
          // connection is
          // irrelevant
          {
            var readyState = 'UNKNOWN';
            switch (xhr.readyState) {
              case 0:
                readyState = 'UNINITIALIZED';
                break;
              case 1:
                readyState = 'LOADING';
                break;
              case 2:
                readyState = 'LOADED';
                break;
              case 3:
                readyState = 'INTERACTIVE';
                break;
              case 4:
                readyState = 'COMPLETED';
                break;
            }
            alert('Error! Type: "' + str + '" ExceptionObject: "'
              + excptObj + '" readyState: ' + readyState);
          } else if( 'parsererror' === str )
          {
            // make sure that a possible resart loop isn't draining all
            // ressources by limiting it
            var now = new Date();
            if( !this.lastRestartTime )
              this.lastRestartTime = now;
            
            // limit to an interval of at least 1000 ms
            setTimeout( function(){ self.restart( true ) }, Math.max(0,1000 - (now - this.lastRestartTime)) );
          }
        };

        /**
         * manipulates the header of the current ajax query before it is
         * been send to the server
         *
         * @param xhr
         * @method beforeSend
         */
        this.beforeSend = function(xhr) {
          for ( var headerName in this.resendHeaders) {
            if (this.resendHeaders[headerName] != undefined)
              xhr.setRequestHeader(headerName,
                this.resendHeaders[headerName]);
          }
          for ( var headerName in this.headers) {
            if (this.headers[headerName] != undefined)
              xhr.setRequestHeader(headerName, this.headers[headerName]);
          }
        };

        /**
         * read the header values of a response and stores them to the
         * resendHeaders array
         *
         * @method readResendHeaderValues
         */
        this.readResendHeaderValues = function() {
          for ( var headerName in this.resendHeaders) {
            this.resendHeaders[headerName] = this.xhr
              .getResponseHeader(headerName);
          }
        };

        /**
         * Check if the connection is still running.
         */
        this.isConnectionRunning = function() {
          return true;
        }

        /**
         * Restart the read request, e.g. when the watchdog kicks in
         *
         * @method restart
         * @param {bool} doFullReload reload all data and not only restart connection
         */
        this.restart = function( doFullReload ) {
          if( doFullReload )
            this.lastIndex = -1; // reload all data

          self.lastRestartTime = new Date();
          self.doRestart = true;
          self.abort();
          self.handleRead(); // restart
          self.doRestart = false;
        };
        /**
         * Abort the read request properly
         *
         * @method restart
         */
        this.abort = function() {
          if (this.xhr && this.xhr.abort) {
            this.xhr.abort();

            if (session.backend && session.backend.hooks.onClose) {
              session.backend.hooks.onClose.bind(this);
            }
          }
        };
      },
      'sse' : function( session ){
        var self = this;

        /**
         * This function gets called once the communication is established
         * and session information is available
         *
         * @param connect (boolean) wether to start the connection or not
         * @method handleSession
         */
        this.handleSession = function(json, connect) {
          self.sessionId = json.s;
          self.version = json.v.split('.', 3);

          if (0 < parseInt(self.version[0])
            || 1 < parseInt(self.version[1]))
            alert('ERROR CometVisu Client: too new protocol version ('
              + json.v + ') used!');

          if (connect) {
            this.connect();
          }
        };

        /**
         * Establish the SSE connection
         */
        this.connect = function() {
          // send first request
          self.running = true;
          session.dataReceived = false;
          this.eventSource = new EventSource(session
              .getResourcePath("read")
            + "?" + session.buildRequest());
          this.eventSource.addEventListener('message', this.handleMessage,
            false);
          this.eventSource.addEventListener('error', this.handleError,
            false);
          this.eventSource.onerror = function(event) {
            console.log("connection lost");
          };
          this.eventSource.onopen = function(event) {
            console.log("connection established");
          };
          session.watchdog.ping();
        };

        /**
         * Handle messages send from server as Server-Sent-Event
         */
        this.handleMessage = function(e) {
          var json = JSON.parse(e.data);
          var data = json.d;
          session.watchdog.ping();
          session.update(data);
          session.dataReceived = true;
        };

        /**
         * Handle errors
         */
        this.handleError = function(e) {
          if (e.readyState === EventSource.CLOSED) {
            // Connection was closed.
            self.running = false;
            // reconnect
            self.connect();
          }
        };

        /**
         * Check if the connection is still running.
         *
         * @returns {Boolean}
         */
        this.isConnectionRunning = function() {
          return this.eventSource.readyState === EventSource.OPEN;
        };

        /**
         * Restart the read request, e.g. when the watchdog kicks in
         *
         * @method restart
         */
        this.restart = function() {
          self.abort();
          self.connect();
        };

        /**
         * Abort the read request properly
         *
         * @method restart
         */
        this.abort = function() {
          if (self.isConnectionRunning() === true) {
            this.eventSource.close();
          }
        };
      }
    };

  /**
   * The CometVisuClient handles all communication issues to supply the user
   * ob this object with reliable realtime data.
   * Itself it can be seen as the session layer (layer 5) according to the OSI
   * model.
   *
   * @class CometVisuClient
   * @constructor
   * @alias module:cometvisu-client
   * @param {String} backendName - name of the backend (cgi-bin|default|oh|openhab|oh2|openhab2)
   * @param {String} [initPath] - optional path to login ressource
   */
  function CometVisuClient( backendName, initPath ) { // Constructor

    // ////////////////////////////////////////////////////////////////////////
    // private static variables and methods:

    // ... none ...

    // check and fix if the user forgot the "new" keyword
    if (!(this instanceof CometVisuClient)) {
      return new CometVisuClient();
    }

    this.setInitialAddresses = function(addresses) {
      this.initialAddresses = addresses;
    }

    /**
     * manipulates the header of the current ajax query before it is been send to the server
     */
    this.beforeSend = function (xhr) {
      for (var headerName in this.resendHeaders) {
        if (this.resendHeaders[headerName] != undefined)
          xhr.setRequestHeader(headerName, this.resendHeaders[headerName]);
      }
      for (var headerName in this.headers) {
        if (this.headers[headerName] != undefined)
          xhr.setRequestHeader(headerName, this.headers[headerName]);
      }
    }

    /**
     * read the header values of a response and stores them to the resendHeaders array
     * @method readResendHeaderValues
     */
    this.readResendHeaderValues = function () {
      for (var headerName in this.resendHeaders) {
        this.resendHeaders[headerName] = this.xhr.getResponseHeader(headerName);
      }
    }

    // ////////////////////////////////////////////////////////////////////////
    // Definition of the private variables

    var
      self = this,
      backend,
      watchdog = (function () {
        var
          last = new Date(),
          hardLast = last,
          maxConnectionAge,
          maxDataAge,
          aliveCheckFunction = function () {
            var now = new Date();
            if (now - last < maxConnectionAge && self.currentTransport.isConnectionRunning())
              return;
            self.currentTransport.restart(now - hardLast > maxDataAge);
            last = now;
          };
        return {
          start: function (watchdogTimer) {
            maxConnectionAge = backend.maxConnectionAge;
            maxDataAge = backend.maxDataAge;
            setInterval(aliveCheckFunction, watchdogTimer * 1000);
          },
          ping: function (fullReload) {
            last = new Date();
            if (fullReload) {
              hardLast = last;
            }
          }
        };
      })();

    // ////////////////////////////////////////////////////////////////////////
    // Definition of the public variables

    this.addresses = []; // the subscribed addresses
    this.initialAddresses = []; // the addresses which should be loaded
    // before the subscribed addresses
    this.filters = []; // the subscribed filters
    this.user = ''; // the current user
    this.pass = ''; // the current password
    this.device = ''; // the current device ID
    this.running = false; // is the communication running at the moment?
    this.currentTransport; // the currently used transport layer
    this.loginSettings = {
      loggedIn : false,
      callbackAfterLoggedIn : null,
      context : null,
      loginOnlyMode : false // login only for backend configuration, do not start address subscription
    };
    this.dataReceived = false; // needed to be able to check if the incoming update is the initial answer or a successing update

    Object.defineProperty(this, 'backend', {
      get: function () {
        return backend;
      },
      set: function (newBackend) {
        // override default settings
        backend = $.extend({}, backends['default'], newBackend);
        if (backend.transport === 'sse' && backend.transportFallback) {
          if (window.EventSource === undefined) {
            // browser does not support EventSource object => use fallback
            // transport + settings
            $.extend(backend, backend.transportFallback);
          }
        }
        // add trailing slash to baseURL if not set
        if (backend.baseURL && backend.baseURL.substr(-1) !== "/") {
          backend.baseURL += "/";
        }
        self.currentTransport = new transportLayers[backend.transport](self);
      }
    });

    Object.defineProperty(this, 'watchdog', {
      get: function () {
        return watchdog;
      },
      writeable: false
    });

    // ////////////////////////////////////////////////////////////////////////
    // Definition of the private methods

    // ... none ...

    // ////////////////////////////////////////////////////////////////////////
    // Definition of the public methods

    /* return the relative path to a resource on the currently used backend
     *
     * @method getResourcePath
     *
     * @param name
     *          {String} Name of the resource (e.g. login, read, write, rrd)
     * @returns {String} relative path to the resource
     */
    this.getResourcePath = function (name) {
      return backend.baseURL + backend.resources[name];
    };

    /**
     * Subscribe to the addresses in the parameter. The second parameter
     * (filter) is optional
     *
     * @param addresses
     * @param filters
     * @method subscribe
     */
    this.subscribe = function (addresses, filters) {
      var startCommunication = !this.addresses.length; // start when
      // addresses were
      // empty
      this.addresses = addresses ? addresses : [];
      this.filters = filters ? filters : [];

      if (!addresses.length) {
        this.stop(); // stop when new addresses are empty
      }
      else if (startCommunication) {
        if (this.loginSettings.loginOnly === true) {
          // connect to the backend
          this.currentTransport.connect();
          // start the watchdog
          watchdog.start(5);
          this.loginSettings.loginOnly = false;
        }
        else {
          this.login(true);
        }
      }
    };

    /**
     * This function starts the communication by a login and then runs the
     * ongoing communication task
     *
     * @param loginOnly (boolean) if true only login and backend configuration, no subscription to addresses (default: false)
     * @param callback (Function) cakk this function when login is done
     * @param context (Object) context for the callback (this)
     * @method login
     */
    this.login = function (loginOnly, callback, context) {
      if (this.loginSettings.loggedIn === false) {
        this.loginSettings.loginOnly = !!loginOnly;
        this.loginSettings.callbackAfterLoggedIn = callback;
        this.loginSettings.context = context;
        var request = {};
        if ('' !== this.user) {
          request.u = this.user;
        }
        if ('' !== this.pass) {
          request.p = this.pass;
        }
        if ('' !== this.device) {
          request.d = this.device;
        }

        $.ajax({
          url: initPath ? initPath : this.getResourcePath("login"),
          dataType: 'json',
          context: this,
          data: request,
          success: this.handleLogin
        });
      } else if (this.loginSettings.callbackAfterLoggedIn) {
        // call callback immediately
        this.loginSettings.callbackAfterLoggedIn.call(this.loginSettings.context);
        this.loginSettings.callbackAfterLoggedIn = null;
        this.loginSettings.context = null;
      }
    };

    /**
     * Handles login response, applies backend configuration if send by
     * backend and forwards to the configurated transport handleSession
     * function
     *
     * @param json
     */
    this.handleLogin = function (json) {
      // read backend configuration if send by backend
      if (json.c) {
        self.backend = $.extend(self.backend, json.c); // assign itself to run setter
      }
      this.dataReceived = false;
      if (this.loginSettings.loginOnly) {
        this.currentTransport.handleSession(json, false);
      } else {
        this.currentTransport.handleSession(json, true);
        // once the connection is set up, start the watchdog
        watchdog.start(5);
      }
      this.loginSettings.loggedIn = true;
      if (this.loginSettings.callbackAfterLoggedIn) {
        this.loginSettings.callbackAfterLoggedIn.call(this.loginSettings.context);
        this.loginSettings.callbackAfterLoggedIn = null;
        this.loginSettings.context = null;
      }
    };

    /**
     * This function stops an ongoing connection
     *
     * @method stop
     */
    this.stop = function () {
      this.running = false;
      if (this.currentTransport.abort) {
        this.currentTransport.abort();
      }
      this.loginSettings.loggedIn = false;
    };

    /**
     * Build the URL part that contains the addresses and filters
     * @method buildRequest
     * @param addresses
     * @return {String}
     */
    this.buildRequest = function (addresses) {
      addresses = addresses ? addresses : this.addresses;
      var
        requestAddresses = (addresses.length) ? 'a='
        + addresses.join('&a=') : '',
        requestFilters = (this.filters.length) ? 'f='
        + this.filters.join('&f=') : '';
      return 's=' + this.currentTransport.sessionId + '&' + requestAddresses
        + ((addresses.length && this.filters.length) ? '&' : '')
        + requestFilters;
    };

    /**
     * This function sends a value
     * @param address
     * @param value
     * @method write
     */
    this.write = function (address, value) {
      /**
       * ts is a quirk to fix wrong caching on some Android-tablets/Webkit;
       * could maybe selective based on UserAgent but isn't that costly on writes
       */
      var ts = new Date().getTime();
      $.ajax({
        url: this.getResourcePath("write"),
        dataType: 'json',
        context: this,
        data: 's=' + this.currentTransport.sessionId + '&a=' + address + '&v=' + value + '&ts=' + ts
      });
    };

    // ////////////////////////////////////////////////////////////////////////
    // Constructor

    // init default settings
    if (backendNameAliases[backendName]) {
      backendName = backendNameAliases[backendName];
    }

    if (backendName && backendName !== 'default') {
      if ($.isPlainObject(backendName)) {
        // override default settings
        self.backend = $.extend({}, backends['default'], backendName);
      } else if (backends[backendName]) {
        // merge backend settings into default backend
        self.backend = $.extend({}, backends['default'], backends[backendName]);
      }
    } else {
      self.backend = backends['default'];
    }
    CometVisuClient.prototype.update = function (json) {
    };
  }
  return CometVisuClient;
});