ads-client.js

/*
ads-client.js

Copyright (c) 2020 Jussi Isotalo <j.isotalo91@gmail.com>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
const PACKAGE_NAME = 'ads-client'

//-------------- Imports --------------
const ADS = require('./ads-client-ads.js')
const net = require('net')
const long = require('long')
const iconv = require('iconv-lite')
const EventEmitter = require('events')

//-------------- Debugs --------------
const debug = require('debug')(PACKAGE_NAME)
const debugD = require('debug')(`${PACKAGE_NAME}:details`)
const debugIO = require('debug')(`${PACKAGE_NAME}:raw-data`)






/**
 * Unofficial Node.js library for connecting to Beckhoff TwinCAT automation systems using ADS protocol
 * 
 * This library is not related to Beckhoff in any way.
 */
class Client extends EventEmitter {

  /**
   * @typedef Settings
   * 
   * NOTE: When adding/removing, update method defaultSettings()
   * 
   * @property {string} targetAmsNetId - Target system AmsNetId, use '127.0.0.1.1.1' if connecting to a local system
   * @property {number} targetAdsPort - Target system ADS port, TwinCAT 3: 851 (1st runtime), 852 (2nd runtime) and so on
   * 
   * @property {boolean} [objectifyEnumerations=true] - If true, read ENUM data types are converted to objects instead of numbers, e.g. {name: 'enumValue', value: 5} instead of 5 - (**default**: true)
   * @property {boolean} [convertDatesToJavascript=true] - If true, PLC DT (DATE_AND_TIME) and DATE types are converted to Javascript dates - Optional (**default**: true)
   * @property {boolean} [readAndCacheSymbols=false] - If true, all PLC symbols are cached during connecting. Otherwise they are read and cached only when needed - Optional (**default**: false)
   * @property {boolean} [readAndCacheDataTypes=false] - If true, all PLC data types are cached during connecting. Otherwise they are read and cached only when needed - Optional (**default**: false)
   * @property {boolean} [disableSymbolVersionMonitoring=false] - If true, PLC symbol version changes aren't monitored and cached symbols and datatypes won't be updated after PLC program download - Optional - Optional (**default**: false)
   * @property {number} [routerTcpPort=48898] - Target ADS router TCP port - Optional (**default**: 48898)
   * @property {string} [routerAddress=127.0.0.1] - Target ADS router IP address/hostname - Optional (**default**: 127.0.0.1)
   * @property {string} [localAddress='(system default)'] - Local IP address to use, use this to change used network interface if required - Optional (**default**: System default)
   * @property {number} [localTcpPort='(system default)'] - Local TCP port to use for outgoing connections - Optional (**default**: System default)
   * @property {string} [localAmsNetId='(from AMS router)'] - Local AmsNetId to use - Optional (**default**: From AMS router)
   * @property {number} [localAdsPort='(from AMS router)'] - Local ADS port to use - Optional (**default**: From AMS router)
   * @property {number} [timeoutDelay=2000] - Time (milliseconds) after connecting to the router or waiting for command response is canceled to timeout - Optional (**default**: 2000 ms)
   * @property {boolean} [hideConsoleWarnings=false] - If true, no warnings are written to console (=nothing is ever written to console) - Optional (**default**: false)
   * @property {boolean} [autoReconnect=true] - If true and connection is lost, the client tries to reconnect automatically - Optional (**default**: true)
   * @property {number} [reconnectInterval=2000] - Time (milliseconds) how often the lost connection is tried to re-establish - Optional (**default**: 2000 ms)
   * @property {number} [checkStateInterval=1000] - Time (milliseconds) how often the system manager state is read to see if connection is OK - Optional (**default**: 1000 ms)
   * @property {number} [connectionDownDelay=5000] - Time (milliseconds) after no successful reading of the system manager state the connection is determined to be lost - Optional (**default**: 5000 ms)
   * @property {boolean} [allowHalfOpen=false] - If true, connect() is successful even if no PLC runtime is found (but target and system manager are available) - Can be useful if it's ok that after connect() the PLC runtime is not immediately available (example: connecting before uploading PLC code and reading data later) - WARNING: If true, reinitializing subscriptions might fail after connection loss.
   * @property {boolean} [disableBigInt=false] - If true, 64 bit integer PLC variables are kept as Buffer objects instead of converting to Javascript BigInt variables (JSON.strigify and libraries that use it have no BigInt support)
   * @property {boolean} [bareClient=false] - If true, only direct ads connection is established (no system manager etc) - Can be used to connect to systems without PLC runtimes etc.
   */


  /**
   * Default settings
   * 
   * @returns {Settings} Default settings
   */
  static defaultSettings() {
    return {
      objectifyEnumerations: true,
      convertDatesToJavascript: true,
      readAndCacheSymbols: false,
      readAndCacheDataTypes: false,
      disableSymbolVersionMonitoring: false,
      routerTcpPort: 48898,
      routerAddress: '127.0.0.1',
      localAddress: null,
      localTcpPort: null,
      localAmsNetId: null,
      localAdsPort: null,
      timeoutDelay: 2000,
      hideConsoleWarnings: false,
      autoReconnect: true,
      reconnectInterval: 2000,
      checkStateInterval: 1000,
      connectionDownDelay: 5000,
      allowHalfOpen: false,
      disableBigInt: false,
      bareClient: false
    }
  }






  /**
   * Constructor for Client
   * 
   * @param {Settings} settings - ADS Client settings as an object, see datatype **Settings** for setting descriptions
   * 
   * @throws {ClientException} If required settings are missing, an error is thrown
   */
  constructor(settings) {
    //Call EventEmitter constructor
    super()

    //Check the required settings
    if (settings.targetAmsNetId == null) {
      throw new ClientException(this, 'Client()', 'Required setting "targetAmsNetId" is missing.')
    }
    if (settings.targetAdsPort == null) {
      throw new ClientException(this, 'Client()', 'Required setting "targetAdsPort" is missing.')
    }

    //Taking the default settings and then adding the user-defined ones
    this.settings = {
      ...Client.defaultSettings(),
      ...settings
    }

    this.settings.targetAmsNetId = this.settings.targetAmsNetId.trim()

    //Loopback address
    if (this.settings.targetAmsNetId.toLowerCase() === 'localhost') {
      this.settings.targetAmsNetId = '127.0.0.1.1.1'
    }

    //Checking that router address is 127.0.0.1 instead of localhost
    //to prevent problem like https://github.com/nodejs/node/issues/40702
    if (this.settings.routerAddress.toLowerCase() === 'localhost') {
      this.settings.routerAddress = '127.0.0.1'
    }

    /**
     * Internal variables - Not intended for external use
     * 
     * @readonly
     */
    this._internals = {
      debugLevel: 0,

      //Socket connection and data receiving
      receiveDataBuffer: Buffer.alloc(0),
      socket: null,

      //Ads communication
      nextInvokeId: 0, //Next invoke ID used for ads request
      amsTcpCallback: null, //Callback used for ams/tcp commands (like port register)
      activeAdsRequests: {}, //Active ADS requests that wait for answer from router
      activeSubscriptions: {}, //Active device notifications
      symbolVersionNotification: null, //Notification handle of the symbol version changed subscription (used to unsubscribe)
      systemManagerStatePoller: { id: 0, timer: null }, //Timer that reads the system manager state (run/config) - This is not available through ADS notifications
      firstStateReadFaultTime: null, //Date when system manager state read failed for the first time
      socketConnectionLostHandler: null, //Handler for socket connection lost event
      socketErrorHandler: null, //Handler for socket error event
      oldSubscriptions: null, //Old subscriptions that were active before connection was lost
      reconnectionTimer: { id: 0, timer: null }, //Timer that tries to reconnect intervally
      portRegisterTimeoutTimer: null, //Timeout timer of registerAdsPort()
    }



    /**
     * 
     * @typedef Metadata
     * @property {object} deviceInfo - Target device info (read after connecting)
     * @property {object} systemManagerState - Target device system manager state (run, config, etc.)
     * @property {object} plcRuntimeState - Target PLC runtime state (run, stop, etc.) (the runtime state of settings.targetAdsPort PLC runtime)
     * @property {object} uploadInfo - Contains information of target data types, symbols and so on
     * @property {number} symbolVersion - Active symbol version at target system. Changes when PLC software is updated
     * @property {boolean} allSymbolsCached - True if all symbols are cached (so we know to re-cache all during symbol version change)
     * @property {object} symbols - Object containing all so far cached symbols
     * @property {boolean} allDataTypesCached - True if all data types are cached (so we know to re-cache all during symbol version change)
     * @property {object} dataTypes - Object containing all so far cached data types
     * @property {object} routerState - Local AMS router state (RUN, STOP etc) - Updated only if router sends notification (debug/run change etc)
     */

    /**
     * Metadata related to the target system that is used during connection
     * @readonly
     * @type {Metadata}
     */
    this.metaData = {
      deviceInfo: null,
      systemManagerState: null,
      plcRuntimeState: null,
      uploadInfo: null,
      symbolVersion: null,
      allSymbolsCached: false,
      symbols: {},
      allDataTypesCached: false,
      dataTypes: {},
      routerState: {}
    }


    /**
     * 
     * @typedef Connection
     * @property {boolean} connected - True if target is connected 
     * @property {boolean} isLocal - True if target is local runtime / loopback connection
     * @property {string} localAmsNetId - Local system AmsNetId
     * @property {number} localAdsPort - Local system ADS port
     * @property {string} targetAmsNetId - Target system AmsNetId
     * @property {number} targetAdsPort - Target system ADS port
     * 
     */

    /**
     * Connection info
     * @readonly
     * @type {Connection}
     */
    this.connection = {
      connected: false,
      isLocal: false,
      localAmsNetId: null,
      localAdsPort: null,
      targetAmsNetId: this.settings.targetAmsNetId,
      targetAdsPort: this.settings.targetAdsPort
    }
  }








  /**
   * Sets debugging using debug package on/off. 
   * Another way for environment variable DEBUG:
   *  - 0 = no debugging
   *  - 1 = Extended exception stack trace
   *  - 2 = basic debugging (same as $env:DEBUG='ads-client')
   *  - 3 = detailed debugging (same as $env:DEBUG='ads-client,ads-client:details')
   *  - 4 = full debugging (same as $env:DEBUG='ads-client,ads-client:details,ads-client:raw-data')
   * 
   * @param {number} level 0 = none, 1 = extended stack traces, 2 = basic, 3 = detailed, 4 = detailed + raw data
   */
  setDebugging(level) {
    debug(`setDebugging(): Debug level set to ${level}`)

    debug.enabled = false
    debugD.enabled = false
    debugIO.enabled = false
    this._internals.debugLevel = level

    if (level === 0) {
      //See ClientException
    }
    else if (level === 2) {
      debug.enabled = true

    } else if (level === 3) {
      debug.enabled = true
      debugD.enabled = true

    } else if (level === 4) {
      debug.enabled = true
      debugD.enabled = true
      debugIO.enabled = true
    }
  }







  /**
   * Connects to the target system using pre-defined Client::settings (at constructor or manually given)
   * 
   * @returns {Promise} Returns a promise (async function)
   * - If resolved, client is connected successfully and connection info is returned (object)
   * - If rejected, something went wrong and error info is returned (object)
   */
  connect() {
    return _connect.call(this)
  }










  /**
   * Unsubscribes all notifications, unregisters ADS port from router (if it was registered) 
   * and disconnects target system and ADS router 
   * 
   * @param {boolean} [forceDisconnect] - If true, the connection is dropped immediately (default = false)  
   * 
   * @returns {Promise} Returns a promise (async function)
   * - If resolved, disconnect was successful 
   * - If rejected, connection is still closed but something went wrong during disconnecting and error info is returned
   */
  disconnect(forceDisconnect = false) {
    return _disconnect.call(this, forceDisconnect)
  }








  /**
   * Disconnects and reconnects again. Subscribes again to all active subscriptions.
   * To prevent subscribing, call unsubscribeAll() before reconnecting.
   * 
   * @param {boolean} [forceDisconnect] - If true, the connection is dropped immediately (default = false)  
   * 
   * @returns {Promise} Returns a promise (async function)
   * - If resolved, reconnecting was successful
   * - If rejected, reconnecting failed and error info is returned
   */
  reconnect(forceDisconnect = false) {
    debug(`reconnect(): Reconnecting...`)
    return _reconnect.call(this, forceDisconnect)
  }










  /**
     * Reads target device information and also saves it to the Client::metaData.deviceInfo object
     * 
     * @returns {Promise<object>} Returns a promise (async function)
     * - If resolved, device information is returned (object)
     * - If rejected, reading failed and error info is returned (object)
     */
  readDeviceInfo() {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readDeviceInfo()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`readDeviceInfo(): Reading device info`)

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadDeviceInfo, Buffer.alloc(0), ADS.ADS_RESERVED_PORTS.SystemService)
        .then((res) => {
          debug(`readDeviceInfo(): Device info read successfully`)
          this.metaData.deviceInfo = res.ads.data
          resolve(this.metaData.deviceInfo)
        })
        .catch((res) => {
          debug(`readDeviceInfo(): Device info read failed`)

          reject(new ClientException(this, 'readDeviceInfo()', 'Reading device info failed', res))
        })
    })
  }



  /**
    * Reads target device system status (run/config/etc)
    * Uses ADS port 10000, which is system manager
    * 
    * @returns {Promise<object>} Returns a promise (async function)
    * - If resolved, system status is returned (object)
    * - If rejected, reading failed and error info is returned (object)
    */
  readSystemManagerState() {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readSystemManagerState()', `Client is not connected. Use connect() to connect to the target first.`))

      debugD(`readSystemManagerState(): Reading device system manager state`)

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadState, Buffer.alloc(0), ADS.ADS_RESERVED_PORTS.SystemService)
        .then((res) => {
          debugD(`readSystemManagerState(): Device system state read successfully`)
          this.metaData.systemManagerState = res.ads.data

          resolve(this.metaData.systemManagerState)
        })
        .catch((res) => {
          debug(`readSystemManagerState(): Device system manager state read failed`)
          reject(new ClientException(this, 'readSystemManagerState()', 'Device system manager state read failed', res))
        })
    })
  }






  /**
    * Reads target device status and also saves it to the metaData.plcRuntimeStatus
    * 
    * @returns {Promise<object>} Returns a promise (async function)
    * - If resolved, device status is returned (object)
    * - If rejected, reading failed and error info is returned (object)
    */
  readPlcRuntimeState(adsPort = this.settings.targetAdsPort) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readPlcRuntimeState()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`readPlcRuntimeState(): Reading PLC runtime (port ${adsPort}) state`)

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadState, Buffer.alloc(0), adsPort)
        .then((res) => {
          debug(`readPlcRuntimeState(): Device state read successfully for port ${adsPort}`)

          //Save to metaData but only if target ADS port
          if (adsPort === this.settings.targetAdsPort) {
            this.metaData.plcRuntimeState = res.ads.data
          }

          resolve(res.ads.data)
        })
        .catch((res) => {
          debug(`readPlcRuntimeState(): Reading PLC runtime (port ${adsPort}) state failed`)
          reject(new ClientException(this, 'readPlcRuntimeState()', `Reading PLC runtime (port ${adsPort}) state failed`, res))
        })
    })
  }





  /**
    * Reads target device PLC software symbol version and also saves it to the Client::metaData.symbolVersion
    * 
    * @returns {Promise<number>} Returns a promise (async function)
    * - If resolved, symbol version number is returned (number)
    * - If rejected, reading failed and error info is returned (object)
    */
  readSymbolVersion() {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readSymbolVersion()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`readSymbolVersion(): Reading symbol version`)

      this.readRaw(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolVersion, 0, 1)
        .then((res) => {
          debug(`readSymbolVersion(): Symbol version read successfully`)

          this.metaData.symbolVersion = res.readUInt8(0)
          resolve(this.metaData.symbolVersion)
        })
        .catch((res) => {
          debug(`readSymbolVersion(): Symbol version read failed`)
          reject(new ClientException(this, 'readSymbolVersion()', `Reading PLC symbol version failed`, res))
        })
    })
  }








  /**
    * Reads target device PLC upload info and also saves it to the Client::metaData.uploadInfo
    * 
    * @returns {Promise<object>} Returns a promise (async function)
    * - If resolved, upload info is returned (object)
    * - If rejected, reading failed and error info is returned (object)
    */
  readUploadInfo() {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readUploadInfo()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`readUploadInfo(): Reading upload info`)

      //Allocating bytes for request
      const data = Buffer.alloc(12)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolUploadInfo2, pos)
      pos += 4

      //4..7 IndexOffset
      data.writeUInt32LE(0, pos)
      pos += 4

      //8..11 Write data length
      data.writeUInt32LE(24, pos)
      pos += 4


      _sendAdsCommand.call(this, ADS.ADS_COMMAND.Read, data)
        .then((res) => {
          //Now the result has data parsed
          let result = {}, pos = 0

          let data = res.ads.data

          //0..3 Symbol count
          result.symbolCount = data.readUInt32LE(pos)
          pos += 4

          //4..7 Symbol length
          result.symbolLength = data.readUInt32LE(pos)
          pos += 4

          //8..11 Data type count
          result.dataTypeCount = data.readUInt32LE(pos)
          pos += 4

          //12..15 Data type length
          result.dataTypeLength = data.readUInt32LE(pos)
          pos += 4

          //16..19 Extra count
          result.extraCount = data.readUInt32LE(pos)
          pos += 4

          //20..23 Extra length
          result.extraLength = data.readUInt32LE(pos)
          pos += 4

          this.metaData.uploadInfo = result

          debug(`readUploadInfo(): Upload info read`)

          resolve(result)
        })
        .catch((res) => {
          reject(new ClientException(this, 'readUploadInfo()', `Reading PLC upload info failed`, res))
        })
    })
  }






  /**
    * Reads all symbols from target device and caches them to the metaData.symbols
    * 
    * **WARNING** Returned object is usually very large
    * 
    * @returns {Promise<object>} Returns a promise (async function)
    * - If resolved, symbols are returned (object)
    * - If rejected, reading failed and error info is returned (object)
    */
  readAndCacheSymbols() {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readAndCacheSymbols()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`readAndCacheSymbols(): Starting to download symbols`)

      //First, download most recent upload info (=symbol count & byte length)
      try {
        debug(`readAndCacheSymbols(): Updating upload info`)

        await this.readUploadInfo()
      } catch (err) {
        if (this.metaData.uploadInfo === null) {
          debug(`readAndCacheSymbols(): Updating upload info failed, no old data available`)
          return reject(new ClientException(this, 'readAndCacheSymbols()', `Downloading symbols to cache failed (updating upload info failed)`, err))
        } else {
          debug(`readAndCacheSymbols(): Updating upload info failed, using old data`)
        }
      }

      //Allocating bytes for request
      const data = Buffer.alloc(12)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolUpload, pos)
      pos += 4

      //4..7 IndexOffset
      data.writeUInt32LE(0, pos)
      pos += 4

      //8..11 Write data length
      data.writeUInt32LE(this.metaData.uploadInfo.symbolLength, pos)
      pos += 4


      _sendAdsCommand.call(this, ADS.ADS_COMMAND.Read, data)
        .then((res) => {
          //Now the result has data parsed
          let data = res.ads.data
          let symbols = {}, symbolCount = 0

          //The data contains all symbol, each has an unknown size
          while (data.byteLength > 0) {
            let pos = 0

            //0..3 Symbol information bytelength
            const len = data.readUInt32LE(pos)
            pos += 4

            //Let the parser method handle the rest
            let symbol = _parseSymbolInfo.call(this, data.slice(pos, pos + len))
            symbols[symbol.name.trim().toLowerCase()] = symbol

            data = data.slice(len)
            symbolCount++
          }

          this.metaData.symbols = symbols
          this.metaData.allSymbolsCached = true

          debug(`readAndCacheSymbols(): All symbols cached (symbol count: ${symbolCount})`)

          resolve(symbols)
        })
        .catch((res) => {
          return reject(new ClientException(this, 'readAndCacheSymbols()', `Downloading symbols to cache failed`, res))
        })
    })
  }











  /**
   * Reads and caches all data types from target PLC runtime
   * 
    * **WARNING** Returned object is usually very large
    * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, data types are cached and returned (object)
   * - If rejected, reading failed and error info is returned (object)
   */
  readAndCacheDataTypes() {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readAndCacheDataTypes()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`readAndCacheDataTypes(): Reading all data types`)

      //First, download most recent upload info (=symbol count & byte length)
      try {
        debugD(`readAndCacheDataTypes(): Updating upload info`)

        await this.readUploadInfo()
      } catch (err) {
        if (this.metaData.uploadInfo === null) {
          debug(`readAndCacheDataTypes(): Updating upload info failed, no old data available`)
          return reject(new ClientException(this, 'readAndCacheDataTypes()', `Downloading data types to cache failed (updating upload info failed)`, err))
        } else {
          debug(`readAndCacheDataTypes(): Updating upload info failed, using old data`)
        }
      }

      //Allocating bytes for request
      const data = Buffer.alloc(12)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolDataTypeUpload, pos)
      pos += 4

      //4..7 IndexOffset
      data.writeUInt32LE(0, pos)
      pos += 4

      //8..11 Write data length
      data.writeUInt32LE(this.metaData.uploadInfo.dataTypeLength, pos)
      pos += 4

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.Read, data)
        .then(async (res) => {
          //Now the result has data parsed
          let data = res.ads.data
          let dataTypes = {}, dataTypeCount = 0

          //The data contains all datatypes, each has its own unique size
          while (data.byteLength > 0) {
            let pos = 0

            //0..3 Datatype information bytelength
            const len = data.readUInt32LE(pos)
            pos += 4

            //Let the parser method handle the rest
            let dataType = await _parseDataType.call(this, data.slice(pos, pos + len))
            dataTypes[dataType.name.trim().toLowerCase()] = dataType

            data = data.slice(len)
            dataTypeCount++
          }

          debug(`readAndCacheDataTypes(): All data types read, parsed and cached (data type count: ${dataTypeCount})`)

          this.metaData.dataTypes = dataTypes
          this.metaData.allDataTypesCached = true

          resolve(dataTypes)
        })

        .catch((res) => {
          reject(new ClientException(this, 'readAndCacheDataTypes()', `Downloading data types to cache failed`, res))
        })
    })
  }










  /**
   * Returns full datatype as object.
   * 
   * First searchs the local cache. If not found, reads it from PLC and caches it
   * 
   * @param {string} dataTypeName - Data type name in the PLC - Example: 'ST_SomeStruct', 'REAL',.. 
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, full data type info is returned (object)
   * - If rejected, reading or parsing failed and error info is returned (object)
   */
  getDataType(dataTypeName) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'getDataType()', `Client is not connected. Use connect() to connect to the target first.`))

      //Wrapper for _getDataTypeRecursive
      _getDataTypeRecursive.call(this, dataTypeName.trim())
        .then(res => resolve(res))
        .catch(err => reject(new ClientException(this, 'getDataType()', `Finding data type ${dataTypeName} failed`, err)))
    })
  }








  /**
   * Returns symbol information for given symbol.
   * 
   * First searchs the local cache. If not found, reads it from PLC and caches it
   * 
   * @param {string} variableName - Variable name in the PLC (full path) - Example: 'MAIN.SomeStruct.SomeValue' 
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, symbol info is returned (object)
   * - If rejected, reading failed and error info is returned (object)
   */
  getSymbolInfo(variableName) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'getSymbolInfo()', `Client is not connected. Use connect() to connect to the target first.`))

      const varName = variableName.trim().toLowerCase()

      debug(`getSymbolInfo(): Symbol info requested for ${variableName}`)

      //First, check already downloaded symbols
      if (this.metaData.symbols[varName]) {
        debugD(`getSymbolInfo(): Symbol info found from cache for ${variableName}`)

        return resolve(this.metaData.symbols[varName])
      } else {
        //Read from PLC and cache it
        _readSymbolInfo.call(this, variableName.trim())
          .then((symbol) => {
            this.metaData.symbols[varName] = symbol

            debugD(`getSymbolInfo(): Symbol info read and cached from PLC for ${variableName}`)
            return resolve(symbol)
          })
          .catch(err => reject(new ClientException(this, 'getSymbolInfo()', `Reading symbol info for ${variableName} failed`, err)))
      }
    })
  }

















  /**
   * Reads given PLC symbol value and parses it as Javascript object
   * 
   * @param {string} variableName Variable name in the PLC (full path) - Example: 'MAIN.SomeStruct.SomeValue' 
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, symbol value and data type are returned {value, type} (object)
   * - If rejected, reading or parsing failed and error info is returned (object)
   */
  readSymbol(variableName) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readSymbol()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`readSymbol(): Reading symbol ${variableName}`)

      variableName = variableName.trim()

      //1. Get symbol from cache or from PLC
      let symbol = {}
      try {
        debugD(`readSymbol(): Reading symbol info for ${variableName}`)

        symbol = await this.getSymbolInfo(variableName)
      } catch (err) {
        return reject(new ClientException(this, 'readSymbol()', `Reading symbol ${variableName} failed: Reading symbol info failed`, err))
      }

      //2. Read the value
      let value = null
      try {
        debugD(`readSymbol(): Reading symbol value for ${variableName}`)

        value = await this.readRaw(symbol.indexGroup, symbol.indexOffset, symbol.size)
      } catch (err) {
        return reject(new ClientException(this, 'readSymbol()', `Reading symbol ${variableName} failed: Reading value failed`, err))
      }

      //3. Create the data type
      let dataType = {}
      try {
        debugD(`readSymbol(): Reading symbol data type for ${variableName}`)

        dataType = await _getDataTypeRecursive.call(this, symbol.type, true, symbol.size)
      } catch (err) {
        return reject(new ClientException(this, 'readSymbol()', `Reading symbol ${variableName} failed: Reading data type failed`, err))
      }

      //4. Parse the data to javascript object
      let data = {}
      try {
        debugD(`readSymbol(): Parsing symbol data for ${variableName}`)

        data = _parsePlcDataToObject.call(this, value, dataType)
      } catch (err) {
        return reject(new ClientException(this, 'readSymbol()', `Reading symbol ${variableName} failed: Parsing data type to Javascript failed`, err))
      }

      debug(`readSymbol(): Reading symbol for ${variableName} done`)

      resolve({
        value: data,
        type: dataType,
        symbol: symbol
      })
    })
  }











  /**
   * Writes given PLC symbol value
   * 
   * @param {string} variableName Variable name in the PLC - Example: 'MAIN.SomeStruct'
   * @param {object} value - Value to write
   * @param {boolean} autoFill - If true and variable type is STRUCT, given value can be just a part of it and the rest is kept the same. Otherwise they must match 1:1
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, write is successful and value and data type are returned {value, type} (object)
   * - If rejected, writing or parsing given data failed and error info is returned (object)
   */
  writeSymbol(variableName, value, autoFill = false) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'writeSymbol()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`writeSymbol(): Writing symbol ${variableName}`)

      variableName = variableName.trim()

      //1. Get symbol from cache or from PLC
      let symbol = {}
      try {
        debugD(`writeSymbol(): Reading symbol info for ${variableName}`)

        symbol = await this.getSymbolInfo(variableName)
      } catch (err) {
        return reject(new ClientException(this, 'writeSymbol()', `Writing symbol ${variableName} failed: Reading symbol info failed`, err))
      }

      //2. Create full data type recursively
      let dataType = {}
      try {
        debugD(`writeSymbol(): Reading symbol data type for ${variableName}`)

        dataType = await this.getDataType(symbol.type)
      } catch (err) {
        return reject(new ClientException(this, 'writeSymbol()', `Writing symbol ${variableName} failed: Reading data type failed`, err))
      }

      //3. Create data buffer packet (parse the value to a byte Buffer)
      let dataBuffer = null
      try {
        debugD(`writeSymbol(): Parsing data buffer from Javascript object`)

        dataBuffer = _parseJsObjectToBuffer.call(this, value, dataType)

      } catch (err) {
        //Parsing the Javascript object failed. If error is TypeError with specific message, it means that the object is missing a required field (not 1:1 match to PLC data type)
        if (err instanceof TypeError && err.isNotCompleteObject != null) {
          if (!autoFill) {
            debug(`writeSymbol(): Given Javascript object does not match the PLC variable - autoFill not given so quiting`)

            return reject(new ClientException(this, 'writeSymbol()', `Writing symbol ${variableName} failed: ${err.message} - Set writeSymbol() 3rd parameter (autoFill) to true to allow uncomplete objects`))
          }
          debug(`writeSymbol(): Given Javascript object does not match the PLC variable - autoFill given so continuing`)

          try {
            debugD(`writeSymbol(): Reading latest data for symbol (autoFill parameter given)`)
            let activeValue = await this.readSymbol(variableName)

            //Deep merge objects - Object.assign() won't work for objects that contain objects
            value = _deepMergeObjects(false, activeValue.value, value)

            debugD(`writeSymbol(): Parsing data buffer from Javascript object (after autoFill)`)
            dataBuffer = _parseJsObjectToBuffer.call(this, value, dataType)

          } catch (err) {
            //Still failing
            return reject(new ClientException(this, 'writeSymbol()', `Writing symbol ${variableName} failed: Parsing the Javascript object to PLC failed`, err))
          }

        } else {
          //Error is something else than TypeError -> quit
          return reject(err)
        }
      }

      //4. Write the data to the PLC
      try {
        debugD(`writeSymbol(): Witing symbol value for ${variableName}`)
        await this.writeRaw(symbol.indexGroup, symbol.indexOffset, dataBuffer)

      } catch (err) {
        return reject(new ClientException(this, 'writeSymbol()', `Writing symbol ${variableName} failed: Writing the data failed`, err))
      }

      debug(`writeSymbol(): Writing symbol ${variableName} done`)

      resolve({
        value: value,
        type: dataType,
        symbol: symbol
      })
    })
  }










  /**
   * @typedef subscriptionCallbackData
   * @property {object} value - Parsed data (if available) or Buffer
   * @property {Date} timeStamp - Date object that contains the PLC timestamp of the data
   * @property {object} type - Data type object (if available)
   */

  /**
   * @callback subscriptionCallback
   * @param {subscriptionCallbackData} data - Object containing {value, timeStamp, type] where value is the latest data from PLC
   * @param {object} sub - Subscription object - Information about subscription and for example, unsubscribe() method
   */

  /**
   * Subscribes to variable value change notifications
   * 
   * @param {string} variableName Variable name in the PLC (full path) - Example: 'MAIN.SomeStruct.SomeValue' 
   * @param {subscriptionCallback} callback - Callback function that is called when notification is received
   * @param {number} cycleTime - How often the PLC checks for value changes (milliseconds) - Default 10 ms
   * @param {boolean} onChange - If true (default), PLC sends the notification only when value has changed. If false, the value is sent every cycleTime milliseconds
   * @param {number} initialDelay - How long the PLC waits for sending the value - default 0 ms (immediately)
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, subscribing is successful and notification data is returned (object)
   * - If rejected, subscribing failed and error info is returned (object)
   */
  subscribe(variableName, callback, cycleTime = 10, onChange = true, initialDelay = 0) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'subscribe()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`subscribe(): Subscribing to ${variableName}`)

      _subscribe.call(
        this,
        variableName.trim(),
        callback,
        {
          transmissionMode: (onChange === true ? ADS.ADS_TRANS_MODE.OnChange : ADS.ADS_TRANS_MODE.Cyclic),
          cycleTime: cycleTime,
          maximumDelay: initialDelay
        }
      )
        .then(res => resolve(res))
        .catch(err => reject(new ClientException(this, 'subscribe()', `Subscribing to ${variableName} failed`, err)))
    })
  }








  /**
   * Subscribes to variable value change notifications by index group and index offset
   * 
   * @param {number} indexGroup - Variable index group in the PLC
   * @param {number} indexOffset - Variable index offset in the PLC
   * @param {number} size - Variable size in the PLC (enter 0xFFFFFFFF if not known)
   * @param {subscriptionCallback} callback - Callback function that is called when notification is received
   * @param {number} cycleTime - How often the PLC checks for value changes (milliseconds) - Default 10 ms
   * @param {boolean} onChange - If true (default), PLC sends the notification only when value has changed. If false, the value is sent every cycleTime milliseconds
   * @param {number} initialDelay - How long the PLC waits for sending the value - default 0 ms (immediately)
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, subscribing is successful and notification data is returned (object)
   * - If rejected, subscribing failed and error info is returned (object)
   */
  subscribeRaw(indexGroup, indexOffset, size, callback, cycleTime = 10, onChange = true, initialDelay = 0) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'subscribeRaw()', `Client is not connected. Use connect() to connect to the target first.`))

      const target = {
        indexGroup,
        indexOffset,
        size
      }

      debug(`subscribeRaw(): Subscribing to %o`, target)

      _subscribe.call(
        this,
        target,
        callback,
        {
          transmissionMode: (onChange === true ? ADS.ADS_TRANS_MODE.OnChange : ADS.ADS_TRANS_MODE.Cyclic),
          cycleTime: cycleTime,
          maximumDelay: initialDelay
        }
      )
        .then(res => resolve(res))
        .catch(err => reject(new ClientException(this, 'subscribeRaw()', `Subscribing to ${JSON.stringify(target)} failed`, err)))
    })
  }









  /**
   * Unsubscribes from the PLC variable value change notifications
   * 
   * @param {number} notificationHandle - Notification handle for the notification
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, subscribing is successful and notification data is returned (object)
   * - If rejected, subscribing failed and error info is returned (object)
   */
  unsubscribe(notificationHandle) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'unsubscribe()', `Client is not connected. Use connect() to connect to the target first.`))

      const sub = this._internals.activeSubscriptions[notificationHandle]

      if (!sub) {
        debug(`unsubscribe(): Unsubscribing failed - Unknown notification handle ${notificationHandle}`)
        return reject(new ClientException(this, 'unsubscribe()', `Unsubscribing from notification handle "${notificationHandle}" failed: Unknown handle`))
      }

      debug(`unsubscribe(): Unsubscribing from %o (notification handle: ${notificationHandle})`, sub.target)

      //Allocating bytes for request
      const data = Buffer.alloc(4)
      let pos = 0

      //0..3 Notification handle
      data.writeUInt32LE(notificationHandle, pos)

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.DeleteNotification, data)
        .then((res) => {
          //Unsubscribing was successful, remove from active subs
          delete this._internals.activeSubscriptions[notificationHandle]

          debug(`unsubscribe(): Unsubscribed from notification with handle ${notificationHandle}`)
          resolve()
        })
        .catch((res) => {
          debug(`unsubscribe(): Unsubscribing from notification ${notificationHandle} failed`)
          if (this._internals.activeSubscriptions[notificationHandle] && this._internals.activeSubscriptions[notificationHandle].target !== undefined) {
            reject(new ClientException(this, 'unsubscribe()', `Unsubscribing from notification ${JSON.stringify(this._internals.activeSubscriptions[notificationHandle].target)} failed`, res))
          } else {
            reject(new ClientException(this, 'unsubscribe()', `Unsubscribing from notification with handle ${notificationHandle} failed`, res))
          }
        })
    })
  }









  /**
   * Unsubscribes from all active user-added subscriptions
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, subscribing from all was successful and number of unsubscribed notifications is returned (object)
   * - If rejected, subscribing failed for some of the notifications, number of successful, number of failed and error info are returned (object)
   */
  unsubscribeAll() {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'unsubscribeAll()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`unsubscribeAll(): Unsubscribing from all notifications`)

      let firstError = null, unSubCount = 0

      for (let sub in this._internals.activeSubscriptions) {
        try {
          //This method won't unsubscribe internal notification handles
          if (this._internals.activeSubscriptions[sub].internal === true) continue

          await this._internals.activeSubscriptions[sub].unsubscribe()
          unSubCount++

        } catch (err) {
          if (this._internals.activeSubscriptions[sub] && this._internals.activeSubscriptions[sub].target !== undefined) {
            debug(`unsubscribeAll(): Unsubscribing from notification ${JSON.stringify(this._internals.activeSubscriptions[sub].target)} failed`)
          } else {
            debug(`unsubscribeAll(): Unsubscribing from notification with handle ${sub} failed`)
          }

          firstError = new ClientException(this, 'unsubscribeAll()', err)
        }
      }

      if (firstError != null) {
        debug(`unsubscribeAll(): Unsubscribed from ${unSubCount} notifications but unsubscribing from some notifications failed`)

        return reject(firstError)
      }

      //If we are here, everything went fine
      debug(`unsubscribeAll(): Unsubscribed from ${unSubCount} notifications`)
      resolve({
        unSubCount
      })
    })
  }





  /**
   * Reads (raw byte) data from PLC by given previously created variable handle
   * 
   * @param {object|number} handle Variable handle or object including {handle, size} to read from 
   * @param {number} [size] - Variable size in the PLC (bytes). **Keep as default if not known** - Default 0xFFFFFFFF
   * 
   * @returns {Promise<Buffer>} Returns a promise (async function)
   * - If resolved, reading was successful and data is returned (Buffer)
   * - If rejected, reading failed and error info is returned (object)
   */
  readRawByHandle(handle, size = 0xFFFFFFFF) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readRawByHandle()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`readRawByHandle(): Reading data using handle "${handle}" ${(size !== 0xFFFFFFFF ? `and size of ${size} bytes` : ``)}`)

      if (handle == null) {
        return reject(new ClientException(this, 'readRawByHandle()', `Required parameter handle is not assigned`))
      } else if (typeof handle === 'object' && handle.handle) {
        handle = handle.handle
        if (handle.size) {
          size = handle.size
        }
      }

      this.readRaw(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolValueByHandle, handle, size)
        .then(res => resolve(res))
        .catch(err => reject(new ClientException(this, 'readRawByHandle()', `Reading data using handle "${handle}"`, err)))
    })
  }



  /**
   * Reads (raw byte) data from PLC by given variable name (uses *READ_SYMVAL_BYNAME* ADS command inside)
   * 
   * @param {string} variableName Variable name in the PLC (full path) - Example: 'MAIN.SomeStruct.SomeValue' 
   * 
   * @returns {Promise<Buffer>} Returns a promise (async function)
   * - If resolved, reading was successful and data is returned (Buffer)
   * - If rejected, reading failed and error info is returned (object)
   */
  readRawByName(variableName) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readRawByName()', `Client is not connected. Use connect() to connect to the target first.`))

      if (variableName == null) {
        return reject(new ClientException(this, 'readRawByName()', `Required parameter variableName is not assigned`))
      }

      variableName = variableName.trim()

      debug(`readRawByName(): Reading data from ${variableName} using ADS command READ_SYMVAL_BYNAME)}`)

      //Allocating bytes for request
      const data = Buffer.alloc(16 + variableName.length + 1) //Note: String end delimeter
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolValueByName, pos)
      pos += 4

      //4..7 IndexOffset
      data.writeUInt32LE(0, pos)
      pos += 4

      //8..11 Read data length
      data.writeUInt32LE(0xFFFFFFFF, pos) //0xFFFFFFFF = maximum size as we don't know it, seems to work well
      pos += 4

      //12..15 Write data length
      data.writeUInt32LE(variableName.length + 1, pos) //Note: String end delimeter
      pos += 4

      //16..n Data
      iconv.encode(variableName, 'cp1252').copy(data, pos)
      pos += variableName.length

      //String end mark
      data.writeUInt8(0, pos)
      pos += 1

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadWrite, data)
        .then((res) => {
          debug(`readRawByName(): Data read - ${res.ads.data.byteLength} bytes received for ${variableName}`)

          resolve(res.ads.data)
        })
        .catch((res) => {
          debug(`readRawByName(): Reading data from ${variableName} failed %o`, res)
          reject(new ClientException(this, 'readRawByName()', `Reading ${variableName} failed`, res))
        })
    })
  }






  /**
   * Reads (raw byte) data from PLC by given symbol info. Note: Returns Buffer
   * 
   * @param {object} symbol - Object (PLC symbol - read for example with getSymbolInfo() method) that contains at least {indexGroup, indexOffset, size}
   * 
   * @returns {Promise<Buffer>} Returns a promise (async function)
   * - If resolved, reading was successful and data is returned (Buffer)
   * - If rejected, reading failed and error info is returned (object)
   */
  readRawBySymbol(symbol) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readRawBySymbol()', `Client is not connected. Use connect() to connect to the target first.`))

      if (symbol == null) {
        return reject(new ClientException(this, 'readRawBySymbol()', `Required parameter symbol is not assigned`))
      }

      this.readRaw(symbol.indexGroup, symbol.indexOffset, symbol.size)
        .then(res => resolve(res))
        .catch(err => reject(new ClientException(this, 'readRawBySymbol()', err)))
    })
  }







  /**
   * Reads (raw byte) data from PLC by given index group, index offset and size
   * 
   * All required parameters can be read for example with getSymbolInfo() method, **see also readRawBySymbol()**
   * 
   * @param {number} indexGroup - Variable index group in the PLC
   * @param {number} indexOffset - Variable index offset in the PLC
   * @param {number} size - Variable size in the PLC (bytes)
   * @param {number} [targetAdsPort] - Target ADS port (optional) - default is this.settings.targetAdsPort
   * 
   * @returns {Promise<Buffer>} Returns a promise (async function)
   * - If resolved, reading was successful and data is returned (Buffer)
   * - If rejected, reading failed and error info is returned (object)
   * 
  
   */
  readRaw(indexGroup, indexOffset, size, targetAdsPort = null) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readRaw()', `Client is not connected. Use connect() to connect to the target first.`))

      if (indexGroup == null || indexOffset == null || size == null) {
        return reject(new ClientException(this, 'readRaw()', `Some of parameters (indexGroup, indexOffset, size) are not assigned`))
      }

      debug(`readRaw(): Reading data from ${JSON.stringify({ indexGroup, indexOffset, size })}`)

      //Allocating bytes for request
      const data = Buffer.alloc(12)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(indexGroup, pos)
      pos += 4

      //4..7 IndexOffset
      data.writeUInt32LE(indexOffset, pos)
      pos += 4

      //8..11 Read data length
      data.writeUInt32LE(size, pos)
      pos += 4

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.Read, data, targetAdsPort)
        .then((res) => {
          debug(`readRaw(): Data read - ${res.ads.data.byteLength} bytes from ${JSON.stringify({ indexGroup, indexOffset })}`)

          resolve(res.ads.data)
        })
        .catch((res) => {
          debug(`readRaw(): Reading data from ${JSON.stringify({ indexGroup, indexOffset, size })} failed: %o`, res)
          reject(new ClientException(this, 'readRaw()', `Reading data failed`, res))
        })
    })
  }





  /**
   * @typedef IndexGroupAndOffset
   * 
   * @property {number} indexGroup - Index group in the PLC
   * @property {number} indexOffset - Index offset in the PLC
   * 
   */

  /**
   * @typedef MultiResult
   * 
   * @property {boolean} error - If true, command failed
   * @property {number} errorCode - If error, ADS error code
   * @property {string} errorStr - If error, ADS error as string
   * 
   */


  /**
   * @typedef ReadRawMultiParam
   * 
   * @property {number} indexGroup - Variable index group in the PLC
   * @property {number} indexOffset - Variable index offset in the PLC
   * @property {number} size - Variable size in the PLC (bytes)
   * 
   */

  /**
   * @typedef ReadRawMultiResult
   * 
   * @property {boolean} success - True if read was successful
   * @property {MultiErrorInfo} errorInfo - Error information (if any)
   * @property {IndexGroupAndOffset} target - Original target info
   * @property {Buffer} data - Read data as byte Buffer
   * 
   */



  /**
   * Reads multiple (raw byte) data from PLC by given index group, index offset and size
   * 
   * All required parameters can be read for example with getSymbolInfo() method, **see also readRawBySymbol()**
   * 
   * @param {ReadRawMultiParam[]} targetArray - Targets to read from
   * @param {number} [targetAdsPort] - Target ADS port (optional) - default is this.settings.targetAdsPort
   * 
   * @returns {Promise<Array.<ReadRawMultiResult>>} Returns a promise (async function)
   * - If resolved, reading was successful and data is returned (object)
   * - If rejected, reading failed and error info is returned (object)
   */
  readRawMulti(targetArray, targetAdsPort = null) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readRawMulti()', `Client is not connected. Use connect() to connect to the target first.`))

      if (Array.isArray(targetArray) !== true) {
        return reject(new ClientException(this, 'readRawMulti()', `Given targetArray parameter is not an array`))
      }

      for (let i = 0; i < targetArray.length; i++) {
        if (targetArray[i].indexGroup == null || targetArray[i].indexOffset == null || targetArray[i].size == null) {
          return reject(new ClientException(this, 'readRawMulti()', `Given targetArray index ${i} is missing some of the required parameters (indexGroup, indexOffset, size)`))
        }
      }

      const totalSize = targetArray.reduce((total, target) => total + target.size, 0)

      debug(`readRawMulti(): Reading ${targetArray.length} values (total length ${totalSize} bytes)`)

      //Allocating bytes for request
      const data = Buffer.alloc(16 + targetArray.length * 12)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SumCommandRead, pos)
      pos += 4

      //4..7 IndexOffset - Number of read requests
      data.writeUInt32LE(targetArray.length, pos)
      pos += 4

      //8..11 Read data length
      data.writeUInt32LE(totalSize + 4 * targetArray.length, pos)
      pos += 4

      //12..15 Write data length
      data.writeUInt32LE(targetArray.length * 12, pos)
      pos += 4

      //16..n All read targets
      targetArray.forEach(target => {
        //0..3 IndexGroup
        data.writeUInt32LE(target.indexGroup, pos)
        pos += 4

        //4..7 IndexOffset
        data.writeUInt32LE(target.indexOffset, pos)
        pos += 4

        //8..11 Data size
        data.writeUInt32LE(target.size, pos)
        pos += 4
      })

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadWrite, data, targetAdsPort)
        .then(res => {
          debug(`readRawMulti(): Data read - ${res.ads.data.byteLength} bytes received`)

          let pos = 0, results = []

          //First we have ADS error codes for each target
          targetArray.forEach(target => {

            const errorCode = res.ads.data.readUInt32LE(pos)
            pos += 4

            const result = {
              success: errorCode === 0,
              errorInfo: {
                error: errorCode > 0,
                errorCode: errorCode,
                errorStr: ADS.ADS_ERROR[errorCode]
              },
              target: {
                indexGroup: target.indexGroup,
                indexOffset: target.indexOffset
              },
              data: null
            }

            if (target.name != null)
              result.target.name = target.name

            results.push(result)
          })

          //And now the data for each target
          for (let i = 0; i < targetArray.length; i++) {
            results[i].data = res.ads.data.slice(pos, pos + targetArray[i].size)
            pos += targetArray[i].size
          }

          resolve(results)
        })


        .catch(res => {
          debug(`readRawMulti(): Reading ${targetArray.length} values (total length ${totalSize} bytes) failed: %o`, res)
          reject(new ClientException(this, 'readRawMulti()', `Reading data failed`, res))
        })
    })
  }






  /**
   * Writes given byte Buffer to the target and reads result. Uses ADS command ReadWrite.
   * 
   * @param {number} indexGroup - Index group in the PLC
   * @param {number} indexOffset - Index offset in the PLC
   * @param {number} readLength - Read data length in the PLC (bytes)
   * @param {Buffer} dataBuffer - Data to write
   * @param {number} [targetAdsPort] - Target ADS port (optional) - default is this.settings.targetAdsPort
   *  
   * @returns {Promise<Buffer>} Returns a promise (async function)
   * - If resolved, writing and reading was successful and data is returned (Buffer)
   * - If rejected, command failed and error info is returned (object)
   * 
   */
  readWriteRaw(indexGroup, indexOffset, readLength, dataBuffer, targetAdsPort = null) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'readWriteRaw()', `Client is not connected. Use connect() to connect to the target first.`))

      if (indexGroup == null || indexOffset == null || readLength == null || dataBuffer == null) {
        return reject(new ClientException(this, 'writeRawByHandle()', `Some of parameters (indexGroup, indexOffset, readLength, dataBuffer) are not assigned`))
      }
      else if (!(dataBuffer instanceof Buffer)) {
        return reject(new ClientException(this, 'writeRawByHandle()', `Required parameter dataBuffer is not a Buffer type`))
      }
      debug(`readWriteRaw(): Writing ${dataBuffer.byteLength} bytes and reading ${readLength} bytes data to ${JSON.stringify({ indexGroup, indexOffset })}`)

      //Allocating bytes for request
      const data = Buffer.alloc(16 + dataBuffer.byteLength)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(indexGroup, pos)
      pos += 4

      //4..7 IndexOffset
      data.writeUInt32LE(indexOffset, pos)
      pos += 4

      //8..11 Read data length
      data.writeUInt32LE(readLength, pos)
      pos += 4

      //12..15 Write data length
      data.writeUInt32LE(dataBuffer.byteLength, pos)
      pos += 4

      //16..n Write data
      dataBuffer.copy(data, pos)


      _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadWrite, data, targetAdsPort)
        .then(res => {
          debug(`readWriteRaw(): Data written and ${res.ads.data.byteLength} bytes response received`)

          resolve(res.ads.data)
        })
        .catch((res) => {
          debug(`readWriteRaw(): Command failed: %o`, res)

          reject(new ClientException(this, 'readWriteRaw()', `Command failed`, res))
        })
    })
  }




  /**
   * Writes (raw byte) data to PLC by previously created variable handle
   * 
   * @param {number} handle Variable handle to write to (created previously using createVariableHandle())
   * @param {Buffer} dataBuffer - Buffer object that contains the data (and byteLength is acceptable)
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, writing was successful
   * - If rejected, writing failed and error info is returned (object)
   */
  writeRawByHandle(handle, dataBuffer) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'writeRawByHandle()', `Client is not connected. Use connect() to connect to the target first.`))

      if (handle == null || dataBuffer == null) {
        return reject(new ClientException(this, 'writeRawByHandle()', `Some of required parameters (handle, dataBuffer) are not assigned`))

      } else if (!(dataBuffer instanceof Buffer)) {
        return reject(new ClientException(this, 'writeRawByHandle()', `Required parameter dataBuffer is not a Buffer type`))

      } else if (typeof handle === 'object' && handle.handle) {
        handle = handle.handle
      }

      debug(`writeRawByHandle(): Writing ${dataBuffer.byteLength} bytes of data using handle "${handle}"`)

      return this.writeRaw(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolValueByHandle, handle, dataBuffer)
        .then(res => resolve(res))
        .catch(err => reject(new ClientException(this, 'writeRawByHandle()', err)))
    })
  }


  /**
   * Writes (raw byte) data to PLC by given symbol info
   * 
   * @param {object} symbol - Object (PLC symbol - read for example with getSymbolInfo() method) that contains at least {indexGroup, indexOffset}
   * @param {Buffer} dataBuffer - Buffer object that contains the data (and byteLength is acceptable)
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, writing was successful
   * - If rejected, writing failed and error info is returned (object)
   */
  writeRawBySymbol(symbol, dataBuffer) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'writeRawBySymbol()', `Client is not connected. Use connect() to connect to the target first.`))

      if (symbol == null || dataBuffer == null) {
        return reject(new ClientException(this, 'writeRawBySymbol()', `Some of required parameters (symbol, dataBuffer) are not assigned`))

      } else if (!(dataBuffer instanceof Buffer)) {
        return reject(new ClientException(this, 'writeRawBySymbol()', `Required parameter dataBuffer is not a Buffer type`))
      }

      this.writeRaw(symbol.indexGroup, symbol.indexOffset, dataBuffer)
        .then(res => resolve(res))
        .catch(err => reject(new ClientException(this, 'writeRawBySymbol()', err)))
    })
  }







  /**
   * Writes (raw byte) data to PLC with given index group and index offset
   * 
   * @param {number} indexGroup - Variable index group in the PLC
   * @param {number} indexOffset - Variable index offset in the PLC
   * @param {Buffer} dataBuffer - Buffer object that contains the data (and byteLength is acceptable)
   * @param {number} [targetAdsPort] - Target ADS port (optional) - default is this.settings.targetAdsPort
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, writing was successful
   * - If rejected, writing failed and error info is returned (object)
   */
  writeRaw(indexGroup, indexOffset, dataBuffer, targetAdsPort = null) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'writeRaw()', `Client is not connected. Use connect() to connect to the target first.`))

      if (indexGroup == null || indexOffset == null || dataBuffer == null) {
        return reject(new ClientException(this, 'writeRaw()', `Some of parameters (indexGroup, indexOffset, dataBuffer) are not assigned`))

      } else if (!(dataBuffer instanceof Buffer)) {
        return reject(new ClientException(this, 'writeRaw()', `Required parameter dataBuffer is not a Buffer type`))
      }

      debug(`writeRaw(): Writing ${dataBuffer.byteLength} bytes of data to ${JSON.stringify({ indexGroup, indexOffset })}`)

      //Allocating bytes for request
      const data = Buffer.alloc(12 + dataBuffer.byteLength)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(indexGroup, pos)
      pos += 4

      //4..7 IndexOffset
      data.writeUInt32LE(indexOffset, pos)
      pos += 4

      //8..11 Write data length
      data.writeUInt32LE(dataBuffer.byteLength, pos)
      pos += 4

      //12..n Data
      dataBuffer.copy(data, pos)


      _sendAdsCommand.call(this, ADS.ADS_COMMAND.Write, data, targetAdsPort)
        .then((res) => {
          debug(`writeRaw(): Data written: ${dataBuffer.byteLength} bytes of data to ${JSON.stringify({ indexGroup, indexOffset })}`)

          resolve()
        })
        .catch((res) => {
          debug(`writeRaw(): Writing ${dataBuffer.byteLength} bytes of data to ${JSON.stringify({ indexGroup, indexOffset })} failed %o`, res)
          reject(new ClientException(this, 'writeRaw()', `Writing data failed`, res))
        })
    })
  }





  /**
   * @typedef WriteRawMultiParam
   * 
   * @property {number} indexGroup - Variable index group in the PLC
   * @property {number} indexOffset - Variable index offset in the PLC
   * @property {Buffer} data - Buffer object that contains the data (and byteLength is acceptable)
   * 
   */
  /**
   * @typedef WriteRawMultiResult
   * 
   * @property {boolean} success - True if write was successful
   * @property {MultiErrorInfo} errorInfo - Error information (if any)
   * @property {IndexGroupAndOffset} target - Original target info
   * 
   */



  /**
   * Writes multiple (raw byte) data to PLC by given index group and index offset
   * 
   * All required parameters can be read for example with getSymbolInfo() method, **see also readRawBySymbol()**
   * 
   * @param {WriteRawMultiParam[]} targetArray - Targets to write to
   * @param {number} [targetAdsPort] - Target ADS port (optional) - default is this.settings.targetAdsPort
   * 
   * @returns {Promise<Array.<WriteRawMultiResult>>} Returns a promise (async function)
   * - If resolved, writing was successful and data is returned (Buffer)
   * - If rejected, reading failed and error info is returned (object)
   */
  writeRawMulti(targetArray, targetAdsPort = null) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'writeRawMulti()', `Client is not connected. Use connect() to connect to the target first.`))

      if (Array.isArray(targetArray) !== true) {
        return reject(new ClientException(this, 'writeRawMulti()', `Given targetArray parameter is not an array`))
      }

      for (let i = 0; i < targetArray.length; i++) {
        if (targetArray[i].indexGroup == null || targetArray[i].indexOffset == null || targetArray[i].data == null) {
          return reject(new ClientException(this, 'writeRawMulti()', `Given targetArray index ${i} is missing some of the required parameters (indexGroup, indexOffset, data)`))
        }
      }

      const totalSize = targetArray.reduce((total, target) => total + target.data.byteLength, 0)

      debug(`writeRawMulti(): Writing ${targetArray.length} values (total length ${totalSize} bytes)`)

      //Allocating bytes for request
      const data = Buffer.alloc(16 + targetArray.length * 12 + totalSize)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SumCommandWrite, pos)
      pos += 4

      //4..7 IndexOffset - Number of write requests
      data.writeUInt32LE(targetArray.length, pos)
      pos += 4

      //8..11 Read data length
      data.writeUInt32LE(4 * targetArray.length, pos)
      pos += 4

      //12..15 Write data length
      data.writeUInt32LE(targetArray.length * 12 + totalSize, pos)
      pos += 4

      //16..n All write targets
      targetArray.forEach(target => {
        //0..3 IndexGroup
        data.writeUInt32LE(target.indexGroup, pos)
        pos += 4

        //4..7 IndexOffset
        data.writeUInt32LE(target.indexOffset, pos)
        pos += 4

        //8..11 Data size
        data.writeUInt32LE(target.data.byteLength, pos)
        pos += 4
      })

      //Then the actual data for each one
      targetArray.forEach(target => {
        target.data.copy(data, pos)
        pos += target.data.byteLength
      })

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadWrite, data, targetAdsPort)
        .then(res => {
          debug(`writeRawMulti(): Data written - ${res.ads.data.byteLength} bytes received`)

          let pos = 0, results = []

          //Result has ADS error codes for each target
          targetArray.forEach(target => {

            const errorCode = res.ads.data.readUInt32LE(pos)
            pos += 4

            const result = {
              success: errorCode === 0,
              errorInfo: {
                error: errorCode > 0,
                errorCode: errorCode,
                errorStr: ADS.ADS_ERROR[errorCode]
              },
              target: {
                indexGroup: target.indexGroup,
                indexOffset: target.indexOffset
              }
            }

            results.push(result)
          })

          resolve(results)
        })


        .catch(res => {
          debug(`writeRawMulti(): Writing ${targetArray.length} values (total length ${totalSize} bytes) failed: %o`, res)
          reject(new ClientException(this, 'writeRawMulti()', `Writing data failed`, res))
        })
    })
  }








  /**
   * @typedef CreateVariableHandleResult
   * 
   * @property {number} handle - Received variable handle
   * @property {number} size - Received target variable size
   * @property {string} type - Received target variable type
   * 
   */

  /**
   * Creates an ADS variable handle to given PLC variable. 
   * 
   * Using the handle, read and write operations are possible to that variable with readRawByHandle() and writeRawByHandle()
   * 
   * @param {string} variableName Variable name in the PLC (full path) - Example: 'MAIN.SomeStruct.SomeValue' 
   * 
   * @returns {Promise<CreateVariableHandleResult>} Returns a promise (async function)
   * - If resolved, creating handle was successful and handle, size and data type are returned (object)
   * - If rejected, creating failed and error info is returned (object)
   */
  createVariableHandle(variableName) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'createVariableHandle()', `Client is not connected. Use connect() to connect to the target first.`))

      if (variableName == null) {
        return reject(new ClientException(this, 'createVariableHandle()', `Parameter variableName is not assigned`))
      }

      variableName = variableName.trim()

      debug(`createVariableHandle(): Creating variable handle to ${variableName}`)

      //Allocating bytes for request
      const data = Buffer.alloc(16 + variableName.length + 1) //Note: String end delimeter
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolHandleByName, pos)
      pos += 4

      //4..7 IndexOffset
      data.writeUInt32LE(0, pos)
      pos += 4

      //8..11 Read data length
      //data.writeUInt32LE(ADS.ADS_INDEX_OFFSET_LENGTH, pos)
      data.writeUInt32LE(0xFFFFFFFF, pos)
      pos += 4

      //12..15 Write data length
      data.writeUInt32LE(variableName.length + 1, pos) //Note: String end delimeter
      pos += 4

      //16..n Data
      iconv.encode(variableName, 'cp1252').copy(data, pos)
      pos += variableName.length

      //String end mark
      data.writeUInt8(0, pos)
      pos += 1

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadWrite, data)
        .then((res) => {
          let result = {}, pos = 0, data = res.ads.data

          //0..3 Variable handle
          result.handle = data.readUInt32LE(pos)
          pos += 4

          //4..7 Size
          result.size = data.readUInt32LE(pos)
          pos += 4

          //8..11 "type decoration"
          //result.decoration = data.readUInt32LE(pos)
          pos += 4

          //8..9 Data type name length
          let dataTypeNameLength = data.readUInt16LE(pos)
          pos += 2

          //10..n Data type name
          result.type = _trimPlcString(iconv.decode(data.slice(pos, pos + dataTypeNameLength + 1), 'cp1252'))

          debug(`createVariableHandle(): Handle created and size+type obtained for ${variableName}: %o`, result)

          resolve(result)
        })
        .catch((res) => {
          debug(`createVariableHandle(): Creating handle to ${variableName} failed: %o`, res)
          reject(new ClientException(this, 'createVariableHandle()', `Creating handle to ${variableName} failed`, res))
        })
    })
  }




  /**
   * @typedef CreateVariableHandleMultiResult
   * 
   * @property {boolean} success - True if creating handle was successful
   * @property {MultiErrorInfo} errorInfo - Error information (if any)
   * @property {string} target - Target variable name
   * @property {number} handle - Returned variable handle (if successful)
   * 
   */


  /**
   * Creates ADS variable handles to given PLC variables. 
   * 
   * Using the handle, read and write operations are possible to that variable with readRawByHandle() and writeRawByHandle()
   * 
   * **NOTE:** Returns only handle, not data type and size like createVariableHandle()
   * 
   * @param {Array<string>} targetArray Variable names as array in the PLC (full path) - Example: ['MAIN.value1', 'MAIN.value2'] 
   * 
   * @returns {Promise<Array.<CreateVariableHandleMultiResult>>} Returns a promise (async function)
   * - If resolved, command was successful and results for each handle request are returned(object)
   * - If rejected, command failed and error info is returned (object)
   */
  createVariableHandleMulti(targetArray) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'createVariableHandleMulti()', `Client is not connected. Use connect() to connect to the target first.`))

      if (Array.isArray(targetArray) !== true) {
        return reject(new ClientException(this, 'createVariableHandleMulti()', `Given targetArray parameter is not an array`))
      }

      const totalVariableNamesLength = targetArray.reduce((total, target) => total + target.length + 1, 0)

      debug(`createVariableHandleMulti(): Creating ${targetArray.length} variable handles`)

      //Allocating bytes for request
      const data = Buffer.alloc(16 + targetArray.length * 16 + totalVariableNamesLength)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SumCommandReadWrite, pos)
      pos += 4

      //4..7 IndexOffset - Number of read requests
      data.writeUInt32LE(targetArray.length, pos)
      pos += 4

      //8..11 Read data length
      data.writeUInt32LE(12 * targetArray.length, pos)
      pos += 4

      //12..15 Write data length
      data.writeUInt32LE(targetArray.length * 16 + totalVariableNamesLength, pos)
      pos += 4

      //16..n All read targets
      targetArray.forEach(target => {
        //0..3 IndexGroup
        data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolHandleByName, pos)
        pos += 4

        //4..7 IndexOffset
        data.writeUInt32LE(0, pos)
        pos += 4

        //8..11 Read data length
        data.writeUInt32LE(ADS.ADS_INDEX_OFFSET_LENGTH, pos)
        pos += 4

        //12..15 Write data length
        data.writeUInt32LE(target.length + 1, pos) //Note: String end delimeter
        pos += 4
      })

      //All write data
      targetArray.forEach(target => {
        //16..n Data
        iconv.encode(target, 'cp1252').copy(data, pos)
        pos += target.length + 1 //Note: string end delimeter
      })

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadWrite, data)
        .then(res => {
          debug(`createVariableHandleMulti(): Command done - ${res.ads.data.byteLength} bytes received`)

          let pos = 0, results = []

          //First we have ADS error codes and data length for each target
          targetArray.forEach(target => {

            //Error code
            const errorCode = res.ads.data.readUInt32LE(pos)
            pos += 4

            //Data length
            const size = res.ads.data.readUInt32LE(pos)
            pos += 4

            const result = {
              success: errorCode === 0,
              errorInfo: {
                error: errorCode > 0,
                errorCode: errorCode,
                errorStr: ADS.ADS_ERROR[errorCode]
              },
              target: target,
              handle: null,
              size: size
            }

            results.push(result)
          })

          //And now the handle for each target
          for (let i = 0; i < targetArray.length; i++) {
            if (results[i].size === 4) {
              results[i].handle = res.ads.data.readUInt32LE(pos)
              pos += 4
            } else {
              //It's not a handle, do nothing (error code should be available)
              pos += results[i].size
            }

            //We can delete the return data size, not relevant to the end user
            delete results[i].size
          }

          resolve(results)
        })
        .catch(res => {
          debug(`createVariableHandleMulti(): Creating ${targetArray.length} variable handles failed: %o`, res)
          reject(new ClientException(this, 'createVariableHandleMulti()', `Creating variable handles failed`, res))
        })
    })
  }






  /**
   * Deletes the given previously created ADS variable handle
   * 
   * @param {number} handle Variable handle to delete (created previously using createVariableHandle())
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, deleting handle was successful
   * - If rejected, deleting failed and error info is returned (object)
   */
  deleteVariableHandle(handle) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'deleteVariableHandle()', `Client is not connected. Use connect() to connect to the target first.`))

      if (handle == null) {
        return reject(new ClientException(this, 'deleteVariableHandle()', `Parameter handle is not assigned`))

      } else if (typeof handle === 'object' && handle.handle) {
        handle = handle.handle

      } else if (typeof handle !== 'number') {
        return reject(new ClientException(this, 'deleteVariableHandle()', `Parameter handle is not correct type`))
      }

      debug(`deleteVariableHandle(): Deleting variable handle ${handle}`)

      //Allocating bytes for request
      const data = Buffer.alloc(16)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolReleaseHandle, pos)
      pos += 4

      //4..7 IndexOffset
      data.writeUInt32LE(0, pos)
      pos += 4

      //8..11 Write data length
      data.writeUInt32LE(ADS.ADS_INDEX_OFFSET_LENGTH, pos)
      pos += 4

      //12..15 The handle to delete
      data.writeUInt32LE(handle, pos)
      pos += 4


      _sendAdsCommand.call(this, ADS.ADS_COMMAND.Write, data)
        .then((res) => {
          debug(`deleteVariableHandle(): Variable handle ${handle} deleted`)

          resolve()
        })
        .catch((err) => {
          debug(`deleteVariableHandle(): Deleting handle "${handle}" failed:  %o`, err)
          reject(new ClientException(this, 'createVariableHandle()', `Deleting variable handle failed`, err))
        })
    })
  }







  /**
   * @typedef DeleteVariableHandleMultiResult
   * 
   * @property {boolean} success - True if deleting handle was successful
   * @property {MultiErrorInfo} errorInfo - Error information (if any)
   * @property {number} handle - The variable handle that was tried to be deleted
   * 
   */

  /**
   * Deletes the given previously created ADS variable handles
   * 
   * @param {Array<string>} handleArray Variable handles to be deleted as array (can also be array of objects that contain 'handle' key)
   * 
   * @returns {Promise<Array.<DeleteVariableHandleMultiResult>>} Returns a promise (async function)
   * - If resolved, command was successful and results for each request are returned(object)
   * - If rejected, command failed and error info is returned (object)
   */
  deleteVariableHandleMulti(handleArray) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'deleteVariableHandleMulti()', `Client is not connected. Use connect() to connect to the target first.`))

      if (Array.isArray(handleArray) !== true) {
        return reject(new ClientException(this, 'deleteVariableHandleMulti()', `Given handleArray parameter is not an array`))
      }

      debug(`deleteVariableHandleMulti(): Deleting ${handleArray.length} variable handles`)

      //Allocating bytes for request
      const data = Buffer.alloc(16 + handleArray.length * 16)
      let pos = 0

      //0..3 IndexGroup
      data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SumCommandWrite, pos)
      pos += 4

      //4..7 IndexOffset - Number of requests
      data.writeUInt32LE(handleArray.length, pos)
      pos += 4

      //8..11 Read data length
      data.writeUInt32LE(4 * handleArray.length, pos)
      pos += 4

      //12..15 Write data length
      data.writeUInt32LE(16 * handleArray.length, pos)
      pos += 4

      //16..n Commands for each handle
      handleArray.forEach(handle => {
        //0..3 IndexGroup
        data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolReleaseHandle, pos)
        pos += 4

        //4..7 IndexOffset
        data.writeUInt32LE(0, pos)
        pos += 4

        //8..11 Write data length
        data.writeUInt32LE(ADS.ADS_INDEX_OFFSET_LENGTH, pos)
        pos += 4
      })

      //Each handle
      handleArray.forEach(handle => {
        if (typeof handle === 'object' && handle.handle) {
          data.writeUInt32LE(handle.handle, pos)
        } else {
          data.writeUInt32LE(handle, pos)
        }
        pos += 4
      })

      _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadWrite, data)
        .then(res => {
          debug(`deleteVariableHandleMulti(): Command done - ${res.ads.data.byteLength} bytes received`)

          let pos = 0, results = []

          //Result has ADS error codes for each target
          handleArray.forEach(handle => {

            //Error code
            const errorCode = res.ads.data.readUInt32LE(pos)
            pos += 4

            const result = {
              success: errorCode === 0,
              errorInfo: {
                error: errorCode > 0,
                errorCode: errorCode,
                errorStr: ADS.ADS_ERROR[errorCode]
              },
              handle: ((typeof handle === 'object' && handle.handle) ? handle.handle : handle)
            }

            results.push(result)
          })

          resolve(results)
        })
        .catch(res => {
          debug(`deleteVariableHandleMulti(): Deleting ${handleArray.length} variable handles failed: %o`, res)
          reject(new ClientException(this, 'deleteVariableHandleMulti()', `Deleting variable handles failed`, res))
        })
    })
  }







  /**
   * Converts given raw data (byte Buffer) to Javascript object by given dataTypeName
   * 
   * 
   * @param {Buffer} rawData - A Buffer containing valid data for given data type
   * @param {string} dataTypeName - Data type name in the PLC - Example: 'ST_SomeStruct', 'REAL',.. 
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, Javascript object / variable is returned
   * - If rejected, parsing failed and error info is returned (object)
   */
  convertFromRaw(rawData, dataTypeName) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'convertFromRaw()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`convertFromRaw(): Converting ${rawData.byteLength} bytes of data to ${dataTypeName}`)

      //1. Get data type
      let dataType = {}
      try {
        debugD(`convertFromRaw(): Reading data type info for ${dataTypeName}`)

        dataType = await this.getDataType(dataTypeName)
      } catch (err) {
        return reject(new ClientException(this, 'convertFromRaw()', `Reading data type info for "${dataTypeName} failed" - Is the data type correct?`, err))
      }

      //Make sure lengths are the same
      if (dataType.arrayData.length === 0 && dataType.size != rawData.byteLength) {
        //Not an array (plain data type)
        return reject(new ClientException(this, 'convertFromRaw()', `Given data buffer and data type sizes mismatch: Buffer is ${rawData.byteLength} bytes and data type is ${dataType.size} bytes`))

      } else if (dataType.arrayData.length > 0) {
        //Data type is array, calculate array size and compare to given Buffer
        const totalLength = dataType.arrayData.reduce((total, dimension) => total + dimension.length * dataType.size, 0)

        if (totalLength != rawData.byteLength) {
          return reject(new ClientException(this, 'convertFromRaw()', `Given data buffer and data type sizes mismatch: Buffer is ${rawData.byteLength} bytes and data type is ${totalLength} bytes`))
        }
      }

      //2. Parse the data to javascript object
      let data = {}
      try {
        debugD(`convertFromRaw(): Parsing ${rawData.byteLength} bytes of data`)

        data = _parsePlcDataToObject.call(this, rawData, dataType)

        resolve(data)
      } catch (err) {
        return reject(new ClientException(this, 'convertFromRaw()', `Converting given data to Javascript type failed`, err))
      }
    })
  }


  /**
   * Converts given Javascript object/variable to raw Buffer data by given dataTypeName
   * 
   * 
   * @param {object} value - Javacript object or variable that represents dataTypeName
   * @param {string} dataTypeName - Data type name in the PLC - Example: 'ST_SomeStruct', 'REAL',.. 
   * @param {boolean} autoFill - If true and variable type is STRUCT, given value can be just a part of it and the rest is **SET TO ZEROS**. 
   * Otherwise they must match 1:1. Note: can also be 'internal' in internal use (no additional error message data)
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, Javascript object / variable is returned
   * - If rejected, parsing failed and error info is returned (object)
   */
  convertToRaw(value, dataTypeName, autoFill = false) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'convertToRaw()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`convertToRaw(): Converting given object to ${dataTypeName}`)

      //1. Get data type
      let dataType = {}
      try {
        debugD(`convertToRaw(): Reading data type info for ${dataTypeName}`)

        dataType = await this.getDataType(dataTypeName)
      } catch (err) {
        return reject(new ClientException(this, 'convertToRaw()', `Reading data type info for "${dataTypeName} failed" - Is the data type correct?`, err))
      }

      //2. Create data buffer packet (parse the value to a byte Buffer)
      let dataBuffer = null
      try {
        debugD(`convertToRaw(): Parsing data buffer from Javascript object`)

        dataBuffer = _parseJsObjectToBuffer.call(this, value, dataType)

        resolve(dataBuffer)
      } catch (err) {
        //Parsing the Javascript object failed. If error is TypeError with specific message, it means that the object is missing a required field (not 1:1 match to PLC data type)
        if (err instanceof TypeError && err.isNotCompleteObject != null) {
          if (autoFill !== true) {
            debug(`convertToRaw(): Given Javascript object does not match the PLC variable - autoFill not given so quiting`)

            if (autoFill === 'internal') {
              return reject(new ClientException(this, 'convertToRaw()', err.message))
            } else {
              return reject(new ClientException(this, 'convertToRaw()', `Converting Javascript object to byte Buffer failed: ${err.message} - Set 3rd parameter (autoFill) to true to allow uncomplete objects`))
            }
          }
          debug(`convertToRaw(): Given Javascript object does not match the PLC variable - autoFill given so continuing`)

          try {
            debugD(`convertToRaw(): Creating empty object (autoFill parameter given)`)
            let emptyObject = await this.getEmptyPlcType(dataTypeName)

            //Deep merge objects - Object.assign() won't work for objects that contain objects
            value = _deepMergeObjects(false, emptyObject, value)

            debugD(`convertToRaw(): Parsing data buffer from Javascript object (after autoFill)`)
            dataBuffer = _parseJsObjectToBuffer.call(this, value, dataType)

            resolve(dataBuffer)

          } catch (err) {
            //Still failing
            return reject(new ClientException(this, 'convertToRaw()', `Converting Javascript object to byte Buffer failed: Parsing the Javascript object to PLC failed`, err))
          }

        } else {
          //Error is something else than TypeError -> quit
          return reject(err)
        }
      }
    })
  }


  /**
   * Returns empty Javascript object that represents given dataTypeName
   * 
   * 
   * @param {string} dataTypeName - Data type name in the PLC - Example: 'ST_SomeStruct', 'REAL',.. 
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, Javascript object / variable is returned
   * - If rejected, parsing failed and error info is returned (object)
   */
  getEmptyPlcType(dataTypeName) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'getEmptyPlcType()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`getEmptyPlcType(): Converting given object to ${dataTypeName}`)

      //1. Get data type
      let dataType = {}
      try {
        debugD(`getEmptyPlcType(): Reading data type info for ${dataTypeName}`)

        dataType = await this.getDataType(dataTypeName)
      } catch (err) {
        return reject(new ClientException(this, 'getEmptyPlcType()', `Reading data type info for "${dataTypeName} failed" - Is the data type correct?`, err))
      }

      const rawData = Buffer.alloc(dataType.size)

      //2. Parse the data to javascript object
      let data = {}
      try {
        debugD(`convertFromRaw(): Parsing ${rawData.byteLength} bytes of data`)

        data = _parsePlcDataToObject.call(this, rawData, dataType)

        resolve(data)
      } catch (err) {
        return reject(new ClientException(this, 'convertFromRaw()', `Converting given data to Javascript type failed`, err))
      }

    })
  }





  /**
   * Sends a WriteControl ADS command to the given ADS port. 
   * WriteControl can be used to start/stop PLC, set TwinCAT system to run/config and so on
   * 
   * **NOTE: Commands will fail if already in requested state**
   * 
   * @param {number} adsPort - Target ADS port (TC3 runtime 1: 851, system manager: 10000 and so on)
   * @param {number} adsState - ADS state to be set (see ADS.ADS_STATE for different values)
   * @param {number} [deviceState] - device state to be set (usually 0) - Default: 0
   * @param {Buffer} [data] - Additional data to be send (Buffer object - usually empty) - Default: none
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, command was successful
   * - If rejected, command failed error info is returned (object)
   */
  writeControl(adsPort, adsState, deviceState = 0, data = Buffer.alloc(0)) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'writeControl()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`writeControl(): Sending writeControl command to port ${adsPort} - adsState: ${adsState}, deviceState: ${deviceState} and ${data.byteLength} bytes data`)

      //Allocating bytes for request
      const dataBuffer = Buffer.alloc(8 + data.byteLength)
      let pos = 0

      //0..1 ADS state
      dataBuffer.writeUInt16LE(adsState, pos)
      pos += 2

      //2..3 Device state
      dataBuffer.writeUInt16LE(deviceState, pos)
      pos += 2

      //4..7 Data length
      dataBuffer.writeUInt32LE(data.byteLength, pos)
      pos += 4

      //7..n Data
      data.copy(dataBuffer, pos)

      //Sending the command to given port
      _sendAdsCommand.call(this, ADS.ADS_COMMAND.WriteControl, dataBuffer, adsPort)
        .then(res => {
          debug(`writeControl(): Sending command to port ${adsPort} was successful`)

          resolve()
        })
        .catch(err => {
          debug(`writeControl(): Sending writeControl command failed (port: ${adsPort}, adsState: ${adsState}, deviceState: ${deviceState}, ${data.byteLength} bytes data): %o`, err)
          reject(new ClientException(this, 'writeControl()', `Failed to send command. Is the target system already in given state?`, err))
        })

    })
  }







  /**
   * Starts the PLC runtime from settings.targetAdsPort or given ads port
   * 
   * **WARNING:** Make sure the system is ready to start
   * 
   * @param {number} adsPort - Target ADS port, for example 851 for TwinCAT 3 PLC runtime 1 (default: this.settings.targetAdsPort)
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, command was successful
   * - If rejected, command failed error info is returned (object)
   */
  startPlc(adsPort = this.settings.targetAdsPort) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'startPlc()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`startPlc(): Starting PLC at ADS port ${adsPort}`)

      try {
        //Read deviceState (we don't want to change it, as we have no idea what it does)
        const state = await this.readPlcRuntimeState(adsPort)

        await this.writeControl(adsPort, ADS.ADS_STATE.Run, state.deviceState)

        debugD(`startPlc(): PLC started at ADS port ${adsPort}`)
        resolve()
      } catch (err) {
        debug(`startPlc(): Starting PLC at ADS port ${adsPort} failed: %o`, err)
        reject(err)
      }
    })
  }







  /**
   * Stops the PLC runtime from settings.targetAdsPort or given ads port
   * 
   * **WARNING:** Make sure the system is ready to stop
   * 
   * @param {number} adsPort - Target ADS port, for example 851 for TwinCAT 3 PLC runtime 1 (default: this.settings.targetAdsPort)
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, command was successful
   * - If rejected, command failed error info is returned (object)
   */
  stopPlc(adsPort = this.settings.targetAdsPort) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'stopPlc()', `Client is not connected. Use connect() to connect to the target first.`))

      debug(`stopPlc(): Stopping PLC at ADS port ${adsPort}`)

      try {
        //Read deviceState (we don't want to change it, as we have no idea what it does)
        const state = await this.readPlcRuntimeState(adsPort)

        await this.writeControl(adsPort, ADS.ADS_STATE.Stop, state.deviceState)

        debugD(`stopPlc(): PLC stopped at ADS port ${adsPort}`)
        resolve()
      } catch (err) {
        debug(`stopPlc(): Stopping PLC at ADS port ${adsPort} failed: %o`, err)
        reject(err)
      }
    })
  }








  /**
   * Restarts the PLC runtime from settings.targetAdsPort or given ads port
   * 
   * **WARNING:** Make sure the system is ready to stop and start
   * 
   * @param {number} adsPort - Target ADS port, for example 851 for TwinCAT 3 PLC runtime 1 (default: this.settings.targetAdsPort)
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, command was successful
   * - If rejected, command failed error info is returned (object)
   */
  restartPlc(adsPort = this.settings.targetAdsPort) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'restartPlc()', `Client is not connected. Use connect() to connect to the target first.`))

      try {
        debug(`restartPlc(): Restarting PLC at ADS port ${adsPort}`)

        //Read deviceState (we don't want to change it, as we have no idea what it does)
        const state = await this.readPlcRuntimeState(adsPort)

        await this.writeControl(adsPort, ADS.ADS_STATE.Reset, state.deviceState)

        await this.startPlc(adsPort)

        debugD(`restartPlc(): PLC restarted at ADS port ${adsPort}`)
        resolve()
      } catch (err) {
        debug(`restartPlc(): Restarting PLC at ADS port ${adsPort} failed: %o`, err)
        reject(err)
      }
    })
  }






  /**
   * Sets the TwinCAT system (system manager) to start/run mode (green TwinCAT icon)
   * 
   * **WARNING:** Make sure the system is ready to stop and start
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, command was successful
   * - If rejected, command failed error info is returned (object)
   */
  setSystemManagerToRun() {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'setSystemManagerToRun()', `Client is not connected. Use connect() to connect to the target first.`))

      try {
        debug(`setSystemManagerToRun(): Starting TwinCAT system to run mode`)
        //Read deviceState (we don't want to change it, as we have no idea what it does)
        const state = await this.readSystemManagerState()

        await this.writeControl(ADS.ADS_RESERVED_PORTS.SystemService, ADS.ADS_STATE.Reset, state.deviceState)

        debugD(`setSystemManagerToRun(): TwinCAT system started to run mode`)
        resolve()
      } catch (err) {
        debug(`setSystemManagerToRun(): Starting TwinCAT system to run mode failed: %o`, err)
        reject(err)
      }
    })
  }





  /**
   * Sets the TwinCAT system (system manager) to config mode (blue TwinCAT icon)
   * 
   * **WARNING:** Make sure the system is ready to stop
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, command was successful
   * - If rejected, command failed error info is returned (object)
   */
  setSystemManagerToConfig() {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'setSystemManagerToConfig()', `Client is not connected. Use connect() to connect to the target first.`))

      try {
        debug(`setSystemManagerToConfig(): Setting TwinCAT system to config mode`)

        //Read deviceState (we don't want to change it, as we have no idea what it does)
        const state = await this.readSystemManagerState()

        await this.writeControl(ADS.ADS_RESERVED_PORTS.SystemService, ADS.ADS_STATE.Reconfig, state.deviceState)

        debugD(`setSystemManagerToConfig(): TwinCAT system set to config mode`)
        resolve()
      } catch (err) {
        debug(`setSystemManagerToConfig(): Setting TwinCAT system to config mode failed: %o`, err)
        reject(err)
      }
    })
  }






  /**
   * Restarts TwinCAT system (system manager) for software update etc. 
   * Internally same as setSystemManagerToRun()
   * 
   * **WARNING:** Make sure the system is ready to stop and start
   * 
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, command was successful
   * - If rejected, command failed error info is returned (object)
   */
  restartSystemManager() {

    if (!this.connection.connected)
      return reject(new ClientException(this, 'restartSystemManager()', `Client is not connected. Use connect() to connect to the target first.`))

    debug(`restartSystemManager(): Restarting TwinCAT system`)
    return this.setSystemManagerToRun()
  }





  /**
   * @typedef RpcMethodResult
   * 
   * @property {object} returnValue - The return value of the method
   * @property {object} outputs - Object containing all VAR_OUTPUT variables of the method
   * 
   */


  /**
   * Invokes (calls) a function block method from PLC with given parameters. 
   * Returns the function result as Javascript object.
   * **NOTE:** The method must have {attribute 'TcRpcEnable'} above its METHOD definiton
   * 
   * @param {string} variableName Function block path in the PLC (full path) - Example: 'MAIN.FunctionBlock' 
   * @param {string} methodName Method name to invoke
   * @param {object} parameters Method parameters as object - Example: {param1: value, param2: value}
   * 
   * @returns {Promise<RpcMethodResult>} Returns a promise (async function)
   * - If resolved, invoking was successful and method return data (return value and outputs) is returned
   * - If rejected, command failed error info is returned (object)
   */
  invokeRpcMethod(variableName, methodName, parameters = {}) {
    return new Promise(async (resolve, reject) => {

      if (!this.connection.connected)
        return reject(new ClientException(this, 'invokeRpcMethod()', `Client is not connected. Use connect() to connect to the target first.`))

      try {
        debug(`invokeRpcMethod(): Invoking RPC method ${variableName}.${methodName}`)

        //Get symbol from cache or from PLC
        let symbol = {}
        try {
          debugD(`invokeRpcMethod(): Reading symbol info for ${variableName}`)

          symbol = await this.getSymbolInfo(variableName)
        } catch (err) {
          return reject(new ClientException(this, 'invokeRpcMethod()', `Invoking method ${methodName} failed. Reading symbol info for ${variableName} failed`, err))
        }


        //Create the data type
        let dataType = {}
        try {
          debugD(`invokeRpcMethod(): Reading symbol data type for ${variableName}`)

          dataType = await this.getDataType(symbol.type)
        } catch (err) {
          return reject(new ClientException(this, 'invokeRpcMethod()', `Invoking method ${methodName} failed. Reading data type failed`, err))
        }

        //Get the required method if exist
        const method = dataType.rpcMethods.find(m => m.name.toLowerCase() === methodName.toLowerCase().trim())
        if (method == null) {
          return reject(new ClientException(this, 'invokeRpcMethod()', `Given symbol ${variableName} has no RPC method "${methodName}". Make sure you have added pragma {attribute 'TcRpcEnable'} above method definition.`))
        }


        //Gather output parametera (if any), loop input parameters and create data buffer
        //Todo: Use bitmask instead of === as it COULD be possible to have multiple values
        const outputParameters = method.parameters.filter(p => p.flags === ADS.RCP_METHOD_PARAM_FLAGS.Out)
        let inputParamBuffer = Buffer.alloc(0)

        for (let param of method.parameters.filter(p => p.flags === ADS.RCP_METHOD_PARAM_FLAGS.In)) {
          let foundParam = null

          //First, try if we get the parameter easy way
          if (parameters[param.name] !== undefined) {
            foundParam = param.name

          } else {
            //Not found, try case-insensitive way
            try {
              foundParam = Object.keys(parameters).find(objKey => objKey.toLowerCase().trim() === param.name.toLowerCase().trim())

            } catch (err) {
              //value is null or not object or something else
              foundParam = null
            }
          }

          if (foundParam == null) {
            return reject(new ClientException(this, 'invokeRpcMethod()', `Given parameters are missing at least parameter "${param.name}" (${param.type})`))
          }

          //Parse parameter to byte buffer
          try {
            debugD(`invokeRpcMethod(): Parsing parameter ${foundParam} to raw data`)

            //Note 'internal': So we get better error message for this use
            const dataBuffer = await this.convertToRaw(parameters[foundParam], param.type, 'internal')
            inputParamBuffer = Buffer.concat([inputParamBuffer, dataBuffer])

          } catch (err) {
            return reject(new ClientException(this, 'invokeRpcMethod()', `Parsing RPC method parameter "${foundParam}" failed: ${err.message}`, err))
          }
        }


        //Create handle to the method
        let handle = 0
        try {
          debugD(`invokeRpcMethod(): Creating variable handle to RPC method ${variableName}#${methodName}`)

          handle = await this.createVariableHandle(`${variableName}#${methodName}`)

        } catch (err) {
          return reject(new ClientException(this, 'invokeRpcMethod()', `Creating variable handle to RPC method failed`, err))
        }


        //Allocating bytes for request
        const data = Buffer.alloc(16 + inputParamBuffer.byteLength)
        let pos = 0

        //0..3 IndexGroup
        data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolValueByHandle, pos)
        pos += 4

        //4..7 IndexOffset
        data.writeUInt32LE(handle.handle, pos)
        pos += 4

        //8..11 Read data length (return value size + total var_output size)
        data.writeUInt32LE(method.returnSize + outputParameters.reduce((total, param) => total + param.size, 0), pos)
        pos += 4

        //12..15 Write data length
        data.writeUInt32LE(inputParamBuffer.byteLength, pos)
        pos += 4

        //16..n Data
        inputParamBuffer.copy(data, pos)

        _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadWrite, data)
          .then(async res => {
            debugD(`invokeRpcMethod(): Invoking RPC method was successful`)

            //Delete variable handle
            try {
              debugD(`invokeRpcMethod(): Deleting variable handle %o`, handle)
              await this.deleteVariableHandle(handle)

            } catch (err) {
              return reject(new ClientException(this, 'invokeRpcMethod()', `Deleting variable handle failed`, err))
            }

            //Parse return data
            try {
              debugD(`invokeRpcMethod(): Converting return data (${res.ads.data.byteLength} bytes) to Javascript objects`)

              let pos = 0

              const returnData = {
                returnValue: null,
                outputs: {}
              }

              //Method return value
              if (method.returnSize > 0) {
                returnData.returnValue = await this.convertFromRaw(res.ads.data.slice(pos, pos + method.returnSize), method.returnType)
                pos += method.returnSize
              }

              //VAR_OUTPUT parameters
              for (let param of outputParameters) {
                returnData.outputs[param.name] = await this.convertFromRaw(res.ads.data.slice(pos, pos + param.size), param.type)
                pos += param.size
              }

              debug(`invokeRpcMethod(): Invoking RPC method ${variableName}.${methodName} was successful and ${res.ads.data.byteLength} bytes data returned`)

              return resolve(returnData)
            } catch (err) {
              return reject(new ClientException(this, 'invokeRpcMethod()', `Converting method return or output data to Javascript object failed`, err))
            }

          })
          .catch((res) => {
            debug(`invokeRpcMethod(): Invoking RPC method ${variableName}.${methodName} failed: %o`, res)
            return reject(new ClientException(this, 'invokeRpcMethod()', `Invoking RPC method ${variableName}.${methodName} failed`, res))
          })

      } catch (err) {
        debug(`invokeRpcMethod(): Invoking RPC method ${variableName}.${methodName} failed`, err)
        reject(err)
      }
    })
  }







  /**
   * Sends an ADS command including provided data
   * As default the command is sent to target provided in Client settings. 
   * However, the target can also be given
   *
   * @param {number} adsCommand ADS command to send (See ADS.ADS_COMMAND)
   * @param {Buffer} adsData Data to send as byte Buffer
   * @param {number} [targetAdsPort] Target ADS port (default: same as in Client settings, this.settings.targetAdsPort)
   * @param {string} [targetAmsNetId] Target AmsNetID (default: same as in Client settings, this.settings.targetAmsNetId)
   *
   * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, command was successful and result is returned
   * - If rejected, command failed error info is returned (object)
   */
  sendAdsCommand(adsCommand, adsData, targetAdsPort = null, targetAmsNetId = null) {
    return _sendAdsCommand.call(this, adsCommand, adsData, targetAdsPort, targetAmsNetId)
  }




  /**
   * **Helper:** Converts byte array (Buffer) to AmsNetId string
   * 
   * @param {Buffer|array} byteArray Buffer/array that contains AmsNetId bytes
   * @returns {string} AmsNetId as string
   */
  byteArrayToAmsNetIdStr(byteArray) {
    return _byteArrayToAmsNetIdStr(byteArray)
  }

  /**
   * **Helper:** Converts AmsNetId string to byte array
   * 
   * @param {string} byteArray String that represents an AmsNetId
   * @returns {array} AmsNetId as array
   */
  amsNetIdStrToByteArray(str) {
    return _amsNetIdStrToByteArray(str)
  }

}








/**
 * Own exception class used for Client errors
 * 
 * Derived from Error but added innerException and ADS error information
 * 
 * @class
 */
class ClientException extends Error {


  /**
   * @typedef AdsErrorInfo
   * @property {string} adsErrorType Type of the error (AMS error, ADS error)
   * 
   * - AMS error: Something went wrong with the routing, the command was unknown etc.
   * - ADS error: The command failed
   * @property {number} adsErrorCode ADS error code
   * @property {string} adsErrorStr The description/message of ADS error code [https://infosys.beckhoff.com/english.php?content=../content/1033/tf6610_tc3_s5s7communication/36028797393240971.html&id=](https://infosys.beckhoff.com/english.php?content=../content/1033/tf6610_tc3_s5s7communication/36028797393240971.html&id=)
   * 
   */

  /**
   * @constructor
   * @param {Client} client AdsClient instance
   * @param {string} sender The method name that threw the error
   * @param {string|Error|ClientException} messageOrError Error message or another Error/ClientException instance
   * @param {...*} errData Inner exceptions, AMS/ADS responses or any other metadata
   */
  constructor(client, sender, messageOrError, ...errData) {

    //The 2nd parameter can be either message or another Error or ClientException
    if (messageOrError instanceof ClientException) {
      super(messageOrError.message)
      //Add to errData, so will be handled later
      errData.push(messageOrError)

    } else if (messageOrError instanceof Error) {
      super(messageOrError.message)
      //Add to errData, so will be handled later
      errData.push(messageOrError)

    } else {
      super(messageOrError)
    }

    //Stack trace
    if (typeof Error.captureStackTrace === 'function') {
      Error.captureStackTrace(this, this.constructor)
    } else {
      this.stack = (new Error(messageOrError)).stack
    }

    /** 
     * Error class name
     * @type {string} 
     */
    this.name = this.constructor.name
    /** 
     * The method that threw the error 
     * @type {string}
     */
    this.sender = sender
    /** 
     * If true, this error is an AMS/ADS error 
     * @type {boolean} 
     */
    this.adsError = false
    /** 
     * If adsError = true, this contains error information
     * @type {AdsErrorInfo} 
     */
    this.adsErrorInfo = null
    /** 
     * All other metadata that is passed to the error class 
     * @type {any} 
     */
    this.metaData = null
    /** 
     * Senders and error messages of inner exceptions 
     * @type {string[]} 
     */
    this.errorTrace = []
    /** 
     * Function to retrieve the inner exception of this error 
     * @type {function} 
     */
    this.getInnerException = null


    //Loop through given additional data
    errData.forEach(data => {

      if (data instanceof ClientException && this.getInnerException == null) {
        //Another ClientException error
        this.getInnerException = () => data

        //Add it to our own tracing array
        this.errorTrace.push(`${this.getInnerException().sender}: ${this.getInnerException().message}`)

        //Add also all traces from the inner exception
        this.getInnerException().errorTrace.forEach(s => this.errorTrace.push(s))

        //Modifying the stack trace so it contains all previous ones too
        //Source: Matt @ https://stackoverflow.com/a/42755876/8140625

        if (client._internals && client._internals.debugLevel > 0) {
          let message_lines = (this.message.match(/\n/g) || []).length + 1
          this.stack = this.stack.split('\n').slice(0, message_lines + 1).join('\n') + '\n' +
            this.getInnerException().stack
        }

      } else if (data instanceof Error && this.getInnerException == null) {

        //Error -> Add it's message to our message
        this.message += ` (${data.message})`
        this.getInnerException = () => data

        //Modifying the stack trace so it contains all previous ones too
        //Source: Matt @ https://stackoverflow.com/a/42755876/8140625
        if (client._internals && client._internals.debugLevel > 0) {
          let message_lines = (this.message.match(/\n/g) || []).length + 1
          this.stack = this.stack.split('\n').slice(0, message_lines + 1).join('\n') + '\n' +
            this.getInnerException().stack
        }

      } else if (data.ams && data.ams.error) {
        //AMS reponse with error code
        this.adsError = true
        this.adsErrorInfo = {
          adsErrorType: 'AMS error',
          adsErrorCode: data.ams.errorCode,
          adsErrorStr: data.ams.errorStr
        }

      } else if (data.ads && data.ads.error) {
        //ADS response with error code
        this.adsError = true
        this.adsErrorInfo = {
          adsErrorType: 'ADS error',
          adsErrorCode: data.ads.errorCode,
          adsErrorStr: data.ads.errorStr
        }

      } else if (this.metaData == null) {
        //If something else is provided, save it
        this.metaData = data
      }
    })

    //If this particular exception has no ADS error, check if the inner exception has
    //It should always be passed upwards to the end-user
    if (!this.adsError && this.getInnerException != null && this.getInnerException().adsError && this.getInnerException().adsError === true) {
      this.adsError = true
      this.adsErrorInfo = this.getInnerException().adsErrorInfo
    }
  }
}











/**
 * Libray internal methods are documented inside a virtual namespace *_LibraryInternals*.
 * 
 * These methods **are not meant for end-user** and they are not available through module exports.
 * 
 * @namespace _LibraryInternals
 */






/**
 * Connects to the target system using pre-defined Client::settings (at constructor or manually given)
 *
   * @param {boolean} [isReconnecting] - If true, the _connect() call is made during reconnection (-> affects disconnect() calls if connecting fails)
 * 
 * @returns {Promise<object>} Returns a promise (async function)
   * - If resolved, client is connected successfully and connection info is returned (object)
   * - If rejected, something went wrong and error info is returned (object)
 * 
 * @memberof _LibraryInternals
 */
function _connect(isReconnecting = false) {
  return new Promise(async (resolve, reject) => {

    if (this._internals.socket !== null) {
      debug(`_connect(): Socket already assigned`)
      return reject(new ClientException(this, '_connect()', 'Connection is already opened. Close the connection first using disconnect()'))
    }

    debug(`_connect(): Starting to connect ${this.settings.routerAddress}:${this.settings.routerTcpPort}`)

    //Creating a socket and setting it up
    let socket = new net.Socket()
    socket.setNoDelay(true) //Sends data without delay



    //----- Connecting error events -----

    //Listening error event during connection
    socket.once('error', err => {
      debug('_connect(): Socket connect failed: %O', err)

      //Remove all events from socket
      socket.removeAllListeners()
      socket = null

      //Reset connection flag
      this.connection.connected = false

      reject(new ClientException(this, '_connect()', `Connection to ${this.settings.routerAddress}:${this.settings.routerTcpPort} failed (socket error ${err.errno})`, err))
    })



    //Listening close event during connection
    socket.once('close', hadError => {
      debug(`_connect(): Socket closed by remote, connection failed`)

      //Remove all events from socket
      socket.removeAllListeners()
      socket = null

      //Reset connection flag
      this.connection.connected = false

      reject(new ClientException(this, '_connect()', `Connection to ${this.settings.routerAddress}:${this.settings.routerTcpPort} failed - socket closed by remote (hadError = ${hadError})`))
    })


    //Listening end event during connection
    socket.once('end', () => {
      debug(`_connect(): Socket connection ended by remote, connection failed.`)

      //Remove all events from socket
      socket.removeAllListeners()
      socket = null

      //Reset connection flag
      this.connection.connected = false

      if (this.settings.localAdsPort != null)
        reject(new ClientException(this, '_connect()', `Connection to ${this.settings.routerAddress}:${this.settings.routerTcpPort} failed - socket ended by remote (is the given local ADS port ${this.settings.localAdsPort} already in use?)`))
      else
        reject(new ClientException(this, '_connect()', `Connection to ${this.settings.routerAddress}:${this.settings.routerTcpPort} failed - socket ended by remote`))
    })

    //Listening timeout event during connection
    socket.once('timeout', () => {
      debug(`_connect(): Socket timeout`)

      //No more timeout needed
      socket.setTimeout(0);
      socket.destroy()

      //Remove all events from socket
      socket.removeAllListeners()
      socket = null

      //Reset connection flag
      this.connection.connected = false

      reject(new ClientException(this, '_connect()', `Connection to ${this.settings.routerAddress}:${this.settings.routerTcpPort} failed (timeout) - No response from router in ${this.settings.timeoutDelay} ms`))
    })

    //----- Connecting error events end -----


    //Listening for connect event
    socket.once('connect', async () => {
      debug(`_connect(): Socket connection established to ${this.settings.routerAddress}:${this.settings.routerTcpPort}`)

      //No more timeout needed
      socket.setTimeout(0);

      this._internals.socket = socket

      //Try to register an ADS port
      try {
        let res = await _registerAdsPort.call(this)

        this.connection.connected = true
        this.connection.localAmsNetId = res.amsTcp.data.localAmsNetId
        this.connection.localAdsPort = res.amsTcp.data.localAdsPort
        this.connection.isLocal = this.settings.targetAmsNetId === '127.0.0.1.1.1' || this.settings.targetAmsNetId === this.connection.localAmsNetId

        debug(`_connect(): ADS port registered from router. We are ${this.connection.localAmsNetId}:${this.connection.localAdsPort}`)
      } catch (err) {

        if (socket) {
          socket.destroy()
          //Remove all events from socket
          socket.removeAllListeners()
        }
        this.connection.connected = false

        return reject(new ClientException(this, '_connect()', `Registering ADS port from router failed`, err))
      }

      //Remove the socket events that were used only during _connect()
      socket.removeAllListeners('error')
      socket.removeAllListeners('close')
      socket.removeAllListeners('end')

      //When socket errors from now on, we will close the connection
      this._internals.socketErrorHandler = _onSocketError.bind(this)
      socket.on('error', this._internals.socketErrorHandler)

      if (this.settings.bareClient !== true) {
        try {
          //Try to read system manager state - If it's OK, connection is successful to the target
          await this.readSystemManagerState()
          _systemManagerStatePoller.call(this)

        } catch (err) {
          try {
            await _disconnect.call(this, false, isReconnecting)
          } catch (err) {
            debug(`_connect(): Reading target system manager failed -> Connection closed`)
          }
          this.connection.connected = false

          return reject(new ClientException(this, '_connect()', `Connection failed: ${err.message}`, err))
        }


        try {
          await _reInitializeInternals.call(this)

        } catch (err) {
          if (this.settings.allowHalfOpen !== true) {
            try {
              await _disconnect.call(this, false, true)
            } catch (err) {
              debug(`_connect(): Connecting to target PLC runtime failed -> Connection closed`)
            }
            this.connection.connected = false

            return reject(new ClientException(this, '_connect()', `Target and system manager found but couldn't connect to the PLC runtime (see settings allowHalfOpen and bareClient): ${err.message}`, err))
          }

          //Todo: Redesign this
          if (this.metaData.systemManagerState.adsState !== ADS.ADS_STATE.Run)
            _console.call(this, `WARNING: Target is connected but not in RUN mode (mode: ${this.metaData.systemManagerState.adsStateStr}) - connecting to runtime (ADS port ${this.settings.targetAdsPort}) failed`)
          else
            _console.call(this, `WARNING: Target is connected but connecting to runtime (ADS port ${this.settings.targetAdsPort}) failed - Check the port number and that the target system state (${this.metaData.systemManagerState.adsStateStr}) is valid.`)
        }
      }

      //Listening connection lost events
      this._internals.socketConnectionLostHandler = _onConnectionLost.bind(this, true)
      socket.on('close', this._internals.socketConnectionLostHandler)

      //We are connected to the target
      this.emit('connect', this.connection)

      resolve(this.connection)
    })

    //Listening data event
    socket.on('data', data => {
      _socketReceive.call(this, data)
    })

    //Timeout only during connecting, other timeouts are handled elsewhere
    socket.setTimeout(this.settings.timeoutDelay);

    //Finally, connect
    try {
      socket.connect({
        port: this.settings.routerTcpPort,
        host: this.settings.routerAddress,
        localPort: (this.settings.localTcpPort ? this.settings.localTcpPort : null),
        localAddress: (this.settings.localAddress ? this.settings.localAddress : null),
      })

    } catch (err) {
      this.connection.connected = false

      reject(new ClientException(this, '_connect()', `Opening socket connection to ${this.settings.routerAddress}:${this.settings.routerTcpPort} failed`, err))
    }
  })
}


/**
 * Unsubscribes all notifications, unregisters ADS port from router (if it was registered)
 * and disconnects target system and ADS router
 *
 * @param {boolean} [forceDisconnect] - If true, the connection is dropped immediately (default = false)
 * @param {boolean} [isReconnecting] - If true, _disconnect() call is made during reconnecting
 *
 * @returns {Promise<object>} Returns a promise (async function)
 * - If resolved, disconnect was successful 
 * - If rejected, connection is still closed but something went wrong during disconnecting and error info is returned
 *
 * @memberof _LibraryInternals
 */
function _disconnect(forceDisconnect = false, isReconnecting = false) {
  return new Promise(async (resolve, reject) => {

    debug(`_disconnect(): Starting to close connection (force: ${forceDisconnect})`)

    try {
      if (this._internals.socketConnectionLostHandler) {
        this._internals.socket.off('close', this._internals.socketConnectionLostHandler)
      }

    } catch (err) {
      //We probably have no socket anymore. Just quit.
      forceDisconnect = true
    }

    //Clear reconnection timer only when not reconnecting
    if (!isReconnecting) {
      _clearTimer(this._internals.reconnectionTimer)
    }

    //Clear other timers
    _clearTimer(this._internals.systemManagerStatePoller)
    clearTimeout(this._internals.portRegisterTimeoutTimer)

    //If forced, then just destroy the socket
    if (forceDisconnect) {

      if (this._internals.socket) {
        this._internals.socket.removeAllListeners()
        this._internals.socket.destroy()
      }
      this.connection.connected = false
      this.connection.localAdsPort = null
      this._internals.socket = null

      this.emit('disconnect')

      return resolve()
    }


    let error = null

    try {
      await this.unsubscribeAll()
    } catch (err) {
      error = new ClientException(this, 'disconnect()', err)
    }

    try {
      await _unsubscribeAllInternals.call(this)
    } catch (err) {
      error = new ClientException(this, 'disconnect()', err)
    }

    try {
      await _unregisterAdsPort.call(this)

      //Done
      this.connection.connected = false
      this.connection.localAdsPort = null

      //Socket should be null but check it anyways
      if (this._internals.socket != null) {
        this._internals.socket.removeAllListeners()
        this._internals.socket.destroy() //Just incase
        this._internals.socket = null
      }

      debug(`_disconnect(): Connection closed successfully`)

    } catch (err) {
      //Force socket close
      if (this._internals.socket) {
        this._internals.socket.removeAllListeners()
        this._internals.socket.destroy()
      }

      this.connection.connected = false
      this.connection.localAdsPort = null
      this._internals.socket = null

      error = new ClientException(this, 'disconnect()', err)

      debug(`_disconnect(): Connection closing failed, connection forced to close`)
    }

    if (error !== null) {
      error.message = `Disconnected but something failed: ${error.message}`
      this.emit('disconnect')

      return reject(error)
    }

    this.emit('disconnect')
    resolve()
  })
}



/**
 * Disconnects and reconnects again. Subscribes again to all active subscriptions.
 * To prevent subscribing, call unsubscribeAll() before reconnecting.
 *
 * @param {boolean} [forceDisconnect] - If true, the connection is dropped immediately (default = false)
 * @param {boolean} [isReconnecting] - If true, _reconnect() call is made during reconnecting
 *
 * @returns {Promise<object>} Returns a promise (async function)
 * - If resolved, reconnecting was successful
 * - If rejected, reconnecting failed and error info is returned
 *
 * @memberof _LibraryInternals
 */
function _reconnect(forceDisconnect = false, isReconnecting = false) {
  return new Promise(async (resolve, reject) => {

    //Clear all cached symbols and data types (might be incorrect)
    this.metaData.symbols = {}
    this.metaData.dataTypes = {}

    //Save active subscriptions to memory and delete olds
    if (this._internals.oldSubscriptions == null) {
      this._internals.oldSubscriptions = {}
      Object.assign(this._internals.oldSubscriptions, this._internals.activeSubscriptions)

      debug(`_reconnect(): Total of ${Object.keys(this._internals.activeSubscriptions).length} subcriptions saved for reinitializing`)
    }

    this._internals.activeSubscriptions = {}

    if (this._internals.socket != null) {
      try {
        debug(`_reconnect(): Trying to disconnect`)

        await _disconnect.call(this, forceDisconnect, isReconnecting)

      } catch (err) {
        //debug(`_reconnect(): Disconnecting failed: %o`, err)
      }
    }

    debug(`_reconnect(): Trying to connect`)

    return _connect.call(this, true)
      .then(res => {
        debug(`_reconnect(): Connected!`)

        //Reconnected, try to subscribe again 
        _reInitializeSubscriptions.call(this, this._internals.oldSubscriptions)
          .then(() => {
            _console.call(this, `PLC runtime reconnected successfully and all subscriptions were restored!`)

            debug(`_reconnect(): Connection and subscriptions reinitialized. Connection is back.`)
          })
          .catch(err => {
            _console.call(this, `PLC runtime reconnected successfully but not all subscriptions were restored. Error info: ${err}`)

            debug(`_reconnect(): Connection and some subscriptions reinitialized. Connection is back.`)
          })

        this.emit('reconnect')

        resolve(res)
      })
      .catch(err => {
        debug(`_reconnect(): Connecting failed`)
        reject(err)
      })
  })
}


/**
 * Registers a new ADS port from used AMS router
 * 
 * Principe is from .NET library TwinCAT.Ads.dll 
 * 
 * @returns {Promise<object>} Returns a promise (async function)
 * - If resolved, registering a port was successful and local AmsNetId and ADS port are returned (object)
 * - If rejected, registering failed and error info is returned (object)
 * 
 * @memberof _LibraryInternals
 */
function _registerAdsPort() {
  return new Promise((resolve, reject) => {
    debugD(`_registerAdsPort(): Registering an ADS port from ADS router ${this.settings.routerAddress}:${this.settings.routerTcpPort}`)

    //If a manual AmsNetId and ADS port values are used, we should resolve immediately
    //This is used for example if connecting to a remote PLC from non-ads device
    if (this.settings.localAmsNetId && this.settings.localAdsPort) {
      debug(`_registerAdsPort(): Local AmsNetId and ADS port manually given so using ${this.settings.localAmsNetId}:${this.settings.localAdsPort}`)

      return resolve({
        amsTcp: {
          data: {
            localAmsNetId: this.settings.localAmsNetId,
            localAdsPort: this.settings.localAdsPort
          }
        }
      })
    }

    const packet = Buffer.alloc(8)
    let pos = 0

    //0..1 Ams command (header flag)
    packet.writeUInt16LE(ADS.AMS_HEADER_FLAG.AMS_TCP_PORT_CONNECT)
    pos += 2

    //2..5 Data length
    packet.writeUInt32LE(2, pos)
    pos += 4

    //6..7 Data: Requested ads port (0 = let the server decide)
    packet.writeUInt16LE((this.settings.localAdsPort ? this.settings.localAdsPort : 0), pos)

    //Timeout (if no answer from router)
    this._internals.portRegisterTimeoutTimer = setTimeout(() => {
      //Callback is no longer needed, delete it
      this._internals.amsTcpCallback = null

      //Create a custom "ads error" so that the info is passed onwards
      const adsError = {
        ads: {
          error: true,
          errorCode: -1,
          errorStr: `Timeout - no response in ${this.settings.timeoutDelay} ms`
        }
      }
      debug(`_registerAdsPort(): Failed to register ADS port - Timeout - no response in ${this.settings.timeoutDelay} ms`)

      return reject(new ClientException(this, '_registerAdsPort()', `Timeout - no response in ${this.settings.timeoutDelay} ms`, adsError))
    }, this.settings.timeoutDelay)


    const errorHandler = () => {
      clearTimeout(this._internals.portRegisterTimeoutTimer)
      debugD(`_registerAdsPort(): Socket connection errored.`)
      reject(new ClientException(this, '_registerAdsPort()', `Socket connection error`))
    }

    this._internals.socket.once('error', errorHandler)

    this._internals.amsTcpCallback = (res) => {
      clearTimeout(this._internals.portRegisterTimeoutTimer)
      this._internals.socket.off('error', errorHandler)
      this._internals.amsTcpCallback = null

      debugD(`_registerAdsPort(): ADS port registered, assigned AMS address is ${res.amsTcp.data.localAmsNetId}:${res.amsTcp.data.localAdsPort}`)

      return resolve(res)
    }

    try {
      _socketWrite.call(this, packet)

    } catch (err) {
      clearTimeout(this._internals.portRegisterTimeoutTimer)
      this._internals.socket.off('error', errorHandler)
      return reject(new ClientException(this, '_registerAdsPort()', `Error - Writing to socket failed`, err))
    }
  })
}






/**
 * Unregisters previously registered ADS port from AMS router
 * 
 * Principe is from .NET library TwinCAT.Ads.dll 
 * 
 * @returns {Promise<object>} Returns a promise (async function)
 * - In all cases this is resolved, ADS port is unregistered
 * 
 * @memberof _LibraryInternals
 */
function _unregisterAdsPort() {
  return new Promise(async (resolve, reject) => {
    debugD(`_unregisterAdsPort(): Unregister ads port ${this.connection.localAdsPort} from ${this.settings.routerAddress}:${this.settings.routerTcpPort}`)

    //If we have manually given AmsNetId and ADS port, we only need to close the connection manually
    if (this.settings.localAmsNetId && this.settings.localAdsPort) {
      debug(`_unregisterAdsPort(): Local AmsNetId and ADS port manually given so no need to unregister`)


      if (this._internals.socket) {

        this._internals.socket.once('error', () => {
          debugD(`_unregisterAdsPort(): Socket connection errored. Connection closed.`)
          this._internals.socket.destroy()
          resolve()
        })

        //Timeout if socket.end fails
        let timeoutTimer = setTimeout(() => {
          if (this._internals.socket) {
            this._internals.socket.destroy()
          }
          resolve()
        }, this.settings.timeoutDelay)

        //First ending socket nicely, after that destroy it
        this._internals.socket.end(() => {
          clearTimeout(timeoutTimer)
          debugD(`_unregisterAdsPort(): Socket closed`)

          if (this._internals.socket) {
            this._internals.socket.destroy()
            debugD(`_unregisterAdsPort(): Socket destroyed`)
          }

          resolve()
        })
      } else {
        resolve()
      }


    } else {
      //Unregister ADS port from router

      if (this._internals.socket == null) {
        return resolve()
      }

      const buffer = Buffer.alloc(8)
      let pos = 0

      //0..1 AMS command (header flag)
      buffer.writeUInt16LE(ADS.AMS_HEADER_FLAG.AMS_TCP_PORT_CLOSE)
      pos += 2

      //2..5 Data length
      buffer.writeUInt32LE(2, pos)
      pos += 4

      //6..9 Data: port to unregister
      buffer.writeUInt16LE(this.connection.localAdsPort, pos)


      this._internals.portRegisterTimeoutTimer = setTimeout(() => {
        debug(`_registerAdsPort(): Failed to unregister ADS port - Timeout - no response in ${this.settings.timeoutDelay} ms`)

        if (this._internals.socket)
          this._internals.socket.destroy()

        resolve()
      }, this.settings.timeoutDelay)



      this._internals.socket.once('error', () => {
        clearTimeout(this._internals.portRegisterTimeoutTimer)
        debugD(`_unregisterAdsPort(): Socket connection errored. Connection closed.`)
        this._internals.socket.destroy()
        resolve()
      })

      this._internals.socket.once('timeout', (error) => {
        clearTimeout(this._internals.portRegisterTimeoutTimer)
        debugD(`_unregisterAdsPort(): Timeout happened during port unregister. Closing connection anyways.`)
        this._internals.socket.end(() => {
          debugD(`_unregisterAdsPort(): Socket closed after timeout`)
          this._internals.socket.destroy()
          debugD(`_unregisterAdsPort(): Socket destroyed after timeout`)
        })
      })

      //When socket emits close event, the ads port is unregistered and connection closed
      this._internals.socket.once('close', hadError => {
        clearTimeout(this._internals.portRegisterTimeoutTimer)
        debugD(`_unregisterAdsPort(): Ads port unregistered and socket connection closed.`)
        resolve()
      })

      //Sometimes close event is not received, so resolve already here
      this._internals.socket.once('end', () => {
        clearTimeout(this._internals.portRegisterTimeoutTimer)
        debugD(`_unregisterAdsPort(): Socket connection ended. Connection closed.`)
        this._internals.socket.destroy()
        resolve()
      })


      try {
        _socketWrite.call(this, buffer)

      } catch (err) {

        try {
          debugD(`_unregisterAdsPort(): Failed to send unregister command. Closing connection anyways.`)

          this._internals.socket.destroy()
        } catch (err) {
          debugD(`_unregisterAdsPort(): Failed to destroy socket.`)

        } finally {
          clearTimeout(this._internals.portRegisterTimeoutTimer)
          resolve()
        }
      }
    }
  })
}




/**
 * Event listener for socket errors. Just calling the _onConnectionLost handler.
 * 
 * @memberof _LibraryInternals
 */
async function _onSocketError(err) {
  _console.call(this, `WARNING: Socket connection had an error, closing connection: ${JSON.stringify(err)}`)

  _onConnectionLost.call(this, true)
}





/**
 * Called when connection to the remote is lost
 * 
 * @param {boolean} socketFailure - If true, connection was lost due socket/tcp problem -> Just destroy the socket
 * 
 * @memberof _LibraryInternals
 */
async function _onConnectionLost(socketFailure = false) {
  //Clear timers
  _clearTimer(this._internals.systemManagerStatePoller)

  debug(`_onConnectionLost(): Connection was lost. Socket failure: ${socketFailure}`)

  this.connection.connected = false
  this.emit('connectionLost')

  if (this.settings.autoReconnect !== true) {
    _console.call(this, 'WARNING: Connection was lost and setting autoReconnect=false. Quiting.')
    try {
      await this.disconnect(true)
    } catch (err) {
      debugD(`_onConnectionLost(): Error during disconnecting. Quiting.`)
    }

    return
  }

  if (this._internals.socket && this._internals.socketConnectionLostHandler)
    this._internals.socket.off('close', this._internals.socketConnectionLostHandler)

  _console.call(this, 'WARNING: Connection was lost. Trying to reconnect...')

  const tryToReconnect = async (firstTime, timerId) => {

    //If the timer has changed, quit here
    if (this._internals.reconnectionTimer.id !== timerId) {
      return
    }

    //Try to reconnect
    _reconnect.call(this, socketFailure, true)
      .then(res => {

        //Success -> remove timer
        _clearTimer(this._internals.reconnectionTimer)

      })
      .catch(err => {
        //Reconnecting failed
        if (firstTime)
          _console.call(this, `WARNING: Reconnecting failed. Keeping trying in the background every ${this.settings.reconnectInterval} ms...`)

        //If this is still a valid timer, start over again
        if (this._internals.reconnectionTimer.id === timerId) {
          //Creating a new timer with the same id
          this._internals.reconnectionTimer.timer = setTimeout(
            () => tryToReconnect(false, timerId),
            this.settings.reconnectInterval
          )
        } else {
          debugD(`_onConnectionLost(): Timer is no more valid, quiting here`)
        }
      })
  }

  //Clearing old timer if there is one + increasing timer id
  _clearTimer(this._internals.reconnectionTimer)

  //Starting poller timer
  this._internals.reconnectionTimer.timer = setTimeout(
    () => tryToReconnect(true, this._internals.reconnectionTimer.id),
    this.settings.reconnectInterval
  )
}





/**
 * Clears given object timer if it's available
 * and increases the id
 * 
 * @param {object} timerObject Timer object {id, timer}
 * 
 * @memberof _LibraryInternals
 */
function _clearTimer(timerObject) {
  //Clearing timer
  clearTimeout(timerObject.timer)
  timerObject.timer = null

  //Increasing timer id
  timerObject.id = timerObject.id < Number.MAX_SAFE_INTEGER ? timerObject.id + 1 : 0;
}









/**
 * Initializes internal subscriptions and caches symbols/datatypes if required
 *  
 * @throws {Error} If failed, error is thrown
 * 
 * @memberof _LibraryInternals
 */
async function _reInitializeInternals() {

  //Read device status and subscribe to its changes
  await this.readPlcRuntimeState()
  await _subcribeToPlcRuntimeStateChanges.call(this)

  //Read device info
  await this.readDeviceInfo()
  await this.readUploadInfo()

  //Read symbol version and subscribe to its changes
  if (!this.settings.disableSymbolVersionMonitoring) await this.readSymbolVersion()
  if (!this.settings.disableSymbolVersionMonitoring) await _subscribeToSymbolVersionChanges.call(this)

  //Cache data 
  if (this.settings.readAndCacheSymbols || this.metaData.allSymbolsCached) await this.readAndCacheSymbols()
  if (this.settings.readAndCacheDataTypes || this.metaData.allDataTypesCached) await this.readAndCacheDataTypes()

}









/**
 * Reinitializes user-made subscriptions (not internal ones)
 *  
 * @throws {Error} If failed, array of errors is thrown
 * 
 * @memberof _LibraryInternals
 */
async function _reInitializeSubscriptions(previousSubscriptions) {
  debug(`_reInitializeSubscriptions(): Reinitializing subscriptions`)

  const errors = []

  for (let sub in previousSubscriptions) {
    if (previousSubscriptions[sub].internal === true) continue

    const oldSub = previousSubscriptions[sub]
    debugD(`_reInitializeSubscriptions(): Reinitializing subscription: ${oldSub.target}`)

    //Subscribe again
    try {
      const newSub = await _subscribe.call(this, oldSub.target, oldSub.callback, oldSub.settings)

      debugD(`_reInitializeSubscriptions(): Reinitializing successful: ${oldSub.target} - Old handle was ${oldSub.notificationHandle} - Handle is now ${newSub.notificationHandle}`)
      //Success. Change old object values before deleting as there might still be some references
      //NOTE: Sometimes the handle will be the same, so do not delete the new one
      if (oldSub.notificationHandle !== newSub.notificationHandle) {
        Object.assign(previousSubscriptions[sub], newSub)
      }

    } catch (err) {
      debug(`_reInitializeSubscriptions(): Reinitializing subscription failed: ${oldSub.target}`)
      errors.push(err)
    }
  }

  //We should clear the temporary data now
  this._internals.oldSubscriptions = null

  if (errors.length > 0) {
    debug(`_reInitializeSubscriptions(): Some subscriptions were not reinitialized`)
    throw errors
  }

  debug(`_reInitializeSubscriptions(): All subscriptions successfully reinitialized`)
}





/**
 * Writes given data buffer to the socket
 * 
 * Just a simple wrapper for socket.write()
 * 
 * @param data Buffer to write
 * 
 * @memberof _LibraryInternals
 */
function _socketWrite(data) {
  if (debugIO.enabled) {
    debugIO(`IO out ------> ${data.byteLength} bytes : ${data.toString('hex')}`)
  } else {
    debugD(`IO out ------> ${data.byteLength} bytes`)
  }

  this._internals.socket.write(data)
}









/**
 * Event listener for socket.on('data')
 * 
 * Adds received data to the receive buffer
 * 
 * @memberof _LibraryInternals
 */
function _socketReceive(data) {
  if (debugIO.enabled) {
    debugIO(`IO in  <------ ${data.byteLength} bytes: ${data.toString('hex')}`)
  } else {
    debugD(`IO in  <------ ${data.byteLength} bytes`)
  }

  //Add received data to buffer
  this._internals.receiveDataBuffer = Buffer.concat([this._internals.receiveDataBuffer, data])

  //Check data for valid messages
  _checkReceivedData.call(this)
}











/**
 * Subscribes to symbol version changes
 * 
 * Symbol version is changed for example during PLC program update
 * 
 * @memberof _LibraryInternals
 */
function _subscribeToSymbolVersionChanges() {
  debugD(`_subscribeToSymbolVersionChanges(): Subscribing to PLC symbol version changes`)

  return _subscribe.call(
    this,
    {
      indexGroup: ADS.ADS_RESERVED_INDEX_GROUPS.SymbolVersion,
      indexOffset: 0,
      size: 1
    },
    _onSymbolVersionChanged.bind(this), //Note: binding this
    {
      transmissionMode: ADS.ADS_TRANS_MODE.OnChange,
      maximumDelay: 0, //immediately
      cycleTime: 0, //immediately
      internal: true //Library internal
    }
  )
}










/**
 * Called when PLC symbol version is changed (_subscribeToSymbolVersionChanges())
 * 
 * @param data Buffer that contains the new symbol version
 * 
 * @memberof _LibraryInternals
 */
async function _onSymbolVersionChanged(data) {
  const version = data.value.readUInt8(0)

  if (version !== this.metaData.symbolVersion) {
    debug(`_onSymbolVersionChanged(): Symbol version changed from ${this.metaData.symbolVersion} to ${version}`)
    this.metaData.symbolVersion = version
    this.emit('symbolVersionChange', version)

    //Clear all cached symbols and data types (might be incorrect)
    this.metaData.symbols = {}
    this.metaData.dataTypes = {}

    try {
      await this.readUploadInfo()
    } catch (err) {
      debug(`_onSymbolVersionChanged(): readUploadInfo failed: %O`, err)
      this.metaData.uploadInfo = null //So we will try again later (we know that it's not up to date)
    }

    //If all symbols are cached, try to re-download all, if it fails then it just fails
    if (this.metaData.allSymbolsCached) {
      try {
        await this.readAndCacheSymbols()
      } catch (err) {
        debug(`_onSymbolVersionChanged(): readAndCacheSymbols failed: %O`, err)
      }
    }

    if (this.metaData.allDataTypesCached) {
      try {
        await this.readAndCacheDataTypes()
      } catch (err) {
        debug(`_onSymbolVersionChanged(): readAndCacheDataTypes failed: %O`, err)
      }
    }

    //Reinitialize all subscriptions
    this._internals.oldSubscriptions = {}
    Object.assign(this._internals.oldSubscriptions, this._internals.activeSubscriptions)
    this._internals.activeSubscriptions = {}

    try {
      await _reInitializeSubscriptions.call(this, this._internals.oldSubscriptions)

      debug(`_onSymbolVersionChanged(): All subscriptions reinitialized successfully`)
    } catch (err) {
      _console.call(this, `WARNING: PLC symbol version changed and not all subscriptions were reinitialized. Error info: ${JSON.stringify(err)}`)
      debug(`_onSymbolVersionChanged(): Some subscriptions weren't reinitialized`)
    }

    try {
      if (!this.settings.disableSymbolVersionMonitoring) await _subscribeToSymbolVersionChanges.call(this)
    } catch (err) {
      debug(`_onSymbolVersionChanged(): _subscribeToSymbolVersionChanges failed: %O`, err)
    }

    debug(`_onSymbolVersionChanged(): Finished`)
  }
  else {
    debug(`_onSymbolVersionChanged(): Symbol version received (not changed). Version is ${version}`)
  }
}






/**
 * Starts a poller that reads system manager state to see 
 * if connection is ok and if the manager state has changed
 * 
 * @memberof _LibraryInternals
 */
function _systemManagerStatePoller() {

  const poller = async (timerId) => {
    let startAgain = true
    let oldState = this.metaData.systemManagerState

    //If the timer has changed, quit here
    if (this._internals.systemManagerStatePoller.id !== timerId) {
      return
    }

    try {
      await this.readSystemManagerState()

      //Read success
      this._internals.firstStateReadFaultTime = null

      if (oldState.adsState !== this.metaData.systemManagerState.adsState) {
        debug(`_systemManagerStatePoller(): System manager state has changed to ${this.metaData.systemManagerState.adsStateStr}`)

        this.emit('systemManagerStateChange', this.metaData.systemManagerState.adsState)

        if (this.metaData.systemManagerState.adsState !== ADS.ADS_STATE.Run) {
          //Now state is config/something else -> connection is lost
          debug(`_systemManagerStatePoller(): System manager state is not run -> connection lost`)

          startAgain = false
          _onConnectionLost.call(this)
        }
      }

    } catch (err) {
      debug(`_systemManagerStatePoller(): Reading system manager state failed: %o`, err)

      if (this._internals.firstStateReadFaultTime == null)
        this._internals.firstStateReadFaultTime = new Date()

      let failedFor = (new Date()).getTime() - this._internals.firstStateReadFaultTime.getTime()

      if (failedFor > this.settings.connectionDownDelay) {
        debug(`_systemManagerStatePoller(): No system manager state read for longer than ${this.settings.connectionDownDelay} ms. Connection lost.`)

        startAgain = false
        _onConnectionLost.call(this)
      }
    }

    //Try again if required AND this is still the valid timer
    if (startAgain && this._internals.systemManagerStatePoller.id === timerId) {
      //Creating a new timer with the same id
      this._internals.systemManagerStatePoller.timer = setTimeout(
        () => poller(timerId),
        this.settings.checkStateInterval
      )
    }
  }


  //Clearing old timer if there is one + increasing timer id
  _clearTimer(this._internals.systemManagerStatePoller)

  //Starting poller timer
  this._internals.systemManagerStatePoller.timer = setTimeout(
    () => poller(this._internals.systemManagerStatePoller.id),
    this.settings.checkStateInterval
  )

}












/**
 * Subscribes to PLC runtime state changes
 * 
 * @memberof _LibraryInternals
 */
function _subcribeToPlcRuntimeStateChanges() {
  debugD(`_subcribeToPlcRuntimeStateChanges(): Subscribing to PLC runtime state changes`)

  return _subscribe.call(
    this,
    {
      indexGroup: ADS.ADS_RESERVED_INDEX_GROUPS.DeviceData,
      indexOffset: 0,
      size: 4
    },
    _onPlcRuntimeStateChanged.bind(this), //Note: bind()
    {
      transmissionMode: ADS.ADS_TRANS_MODE.OnChange,
      maximumDelay: 0, //immediately
      cycleTime: 0, //immediately
      internal: true, //Library internal
    }
  )
}














/**
 * Called when local AMS router status has changed (Router notification received)
 * For example router state changes when local TwinCAT changes from Config to Run state and vice-versa
 * 
 * @param data Buffer that contains the new router state
 * 
 * @memberof _LibraryInternals
 */
async function _onRouterStateChanged(data) {
  const state = data.amsTcp.data.routerState

  debug(`_onRouterStateChanged(): Local AMS router state has changed${(this.metaData.routerState.stateStr ? ` from ${this.metaData.routerState.stateStr}` : '')} to ${ADS.AMS_ROUTER_STATE.toString(state)} (${state})`)

  this.metaData.routerState = {
    state: state,
    stateStr: ADS.AMS_ROUTER_STATE.toString(state)
  }

  this.emit('routerStateChange', this.metaData.routerState)

  //If we have a local connection, connection needs to be reinitialized
  if (this.connection.isLocal === true) {
    //We should stop polling system manager, it will be reinitialized later
    _clearTimer(this._internals.systemManagerStatePoller)

    debug(`_onRouterStateChanged(): Local loopback connection active, monitoring router state`)

    if (this.metaData.routerState.state === ADS.AMS_ROUTER_STATE.START) {
      _console.call(this, `WARNING: Local AMS router state has changed to ${ADS.AMS_ROUTER_STATE.toString(state)}. Reconnecting...`)
      _onConnectionLost.call(this)

    } else {
      //Nothing to do, just wait until router has started again..
      _console.call(this, `WARNING: Local AMS router state has changed to ${ADS.AMS_ROUTER_STATE.toString(state)}. Connection and active subscriptions might have been lost.`)
    }
  }
}




/**
 * Called when device status has changed (_subcribeToPlcRuntimeStateChanges)
 * 
 * @memberof _LibraryInternals
 */
async function _onPlcRuntimeStateChanged(data, sub) {

  const state = {}
  let pos = 0

  //0..1 ADS state
  state.adsState = data.value.readUInt16LE(pos)
  state.adsStateStr = ADS.ADS_STATE.toString(state.adsState)
  pos += 2

  //2..3 Device state
  state.deviceState = data.value.readUInt16LE(pos)

  let changed = false

  if (this.metaData.plcRuntimeState.adsState !== state.adsState) {
    debug(`_onPlcRuntimeStateChanged(): PLC runtime state changed from ${this.metaData.plcRuntimeState.adsStateStr} to ${state.adsStateStr}`)
    changed = true
  }
  if (this.metaData.plcRuntimeState.deviceState !== state.deviceState) {
    debug(`_onPlcRuntimeStateChanged(): PLC runtime state (deviceState) changed from ${this.metaData.plcRuntimeState.deviceState} to ${state.deviceState}`)
    changed = true
  }

  this.metaData.plcRuntimeState = state

  if (changed)
    this.emit('plcRuntimeStateChange', state)

}




/**
 * @typedef subscriptionSettings
 * @memberof _LibraryInternals
 * @property {number} transmissionMode - How the PLC checks for value changes (Cyclic or on-change - ADS.ADS_TRANS_MODE)
 * @property {number} maximumDelay - When subscribing, a notification is sent after this time even if no changes (milliseconds)
 * @property {number} cycleTime - How often the PLC checks for value changes (milliseconds)
 * @property {number} [targetAdsPort] - Target ADS port, optional. 
 * @property {boolean} [internal] - If true, the subscription is library internal (e.g. symbol version change notification)
 */
/**
 * Subscribes to variable value change notifications
 * 
 * @param {(string|object)} target Variable name in the PLC (full path, example: 'MAIN.SomeStruct.SomeValue') OR symbol object containing {indexGroup, indexOffset, size}
 * @param {subscriptionCallback} callback - Callback function that is called when notification is received
 * @param {subscriptionSettings} settings - Settings object for subscription
 * 
 * @returns {Promise<object>} Returns a promise (async function)
 * - If resolved, subscribing is successful and notification data is returned (object)
 * - If rejected, subscribing failed and error info is returned (object)
 * 
 * @memberof _LibraryInternals
 */
function _subscribe(target, callback, settings) {
  return new Promise(async (resolve, reject) => {
    debugD(`_subscribe(): Subscribing to %o with settings %o`, target, settings)

    let symbolInfo = {}

    //If target is object, it should contain indexGroup, indexOffset and size
    if (typeof target === 'object') {
      if (target.indexGroup == null || target.indexOffset == null) {
        return reject(new ClientException(this, '_subscribe()', `Target is an object but some of the required values (indexGroup, indexOffset) are not assigned`))

      } else if (target.size == null) {
        target.size = 0xFFFFFFFF
      }
      symbolInfo = target

      //Otherwise we should download symbol info by target, which should be variable name
    } else if (typeof target === 'string') {
      try {
        symbolInfo = await this.getSymbolInfo(target)

        //Read symbol datatype to cache -> It's cached when we start getting notifications
        //Otherwise with large structs and fast cycle times there might be some problems
        await this.getDataType(symbolInfo.type)

      } catch (err) {
        return reject(new ClientException(this, '_subscribe()', err))
      }
    } else {
      return reject(new ClientException(this, '_subscribe()', `Given target parameter is unknown type`))
    }


    //Allocating bytes for request
    const data = Buffer.alloc(40)
    let pos = 0

    //0..3 IndexGroup
    data.writeUInt32LE(symbolInfo.indexGroup, pos)
    pos += 4

    //4..7 IndexOffset
    data.writeUInt32LE(symbolInfo.indexOffset, pos)
    pos += 4

    //8..11 Data length
    data.writeUInt32LE(symbolInfo.size, pos)
    pos += 4

    //12..15 Transmission mode
    data.writeUInt32LE(settings.transmissionMode, pos)
    pos += 4

    //16..19 Maximum delay (ms) - When subscribing, a notification is sent after this time even if no changes 
    data.writeUInt32LE(settings.maximumDelay * 10000, pos)
    pos += 4

    //20..23 Cycle time (ms) - How often the PLC checks for value changes (minimum value: Task 0 cycle time)
    data.writeUInt32LE(settings.cycleTime * 10000, pos)
    pos += 4

    //24..40 reserved

    _sendAdsCommand.call(this, ADS.ADS_COMMAND.AddNotification, data, (settings.targetAdsPort ? settings.targetAdsPort : null))
      .then(async (res) => {

        debugD(`_subscribe(): Subscribed to %o`, target)

        //Passing the client to the newSub object so it can access itself by "this"
        let client = this

        //Creating new subscription object
        let newSub = {
          target: target,
          settings: settings,
          callback: callback,
          symbolInfo: symbolInfo,
          notificationHandle: res.ads.data.notificationHandle,
          lastValue: null,
          internal: (settings.internal && settings.internal === true ? true : false),
          unsubscribe: async function () {
            await client.unsubscribe(this.notificationHandle)
          },
          dataParser: async function (value) {
            try {
              if (this.symbolInfo.type) {
                const dataType = await client.getDataType(this.symbolInfo.type)
                const data = _parsePlcDataToObject.call(client, value, dataType)

                this.lastValue = data

                return {
                  value: data,
                  timeStamp: null, //Added later
                  type: dataType,
                  symbol: this.symbolInfo
                }
              }

              //If we don't know the data type
              this.lastValue = value

              return {
                value: value,
                timeStamp: null, //Added later
                type: null,
                symbol: null
              }
            } catch (err) {
              throw err
            }
          }
        }

        this._internals.activeSubscriptions[newSub.notificationHandle] = newSub

        resolve(newSub)
      })
      .catch((res) => {
        debug(`_subscribe(): Subscribing to %o failed: %o`, target, res)
        reject(new ClientException(this, '_subscribe()', `Subscribing to ${JSON.stringify(target)} failed`, res))
      })
  })
}









/**
 * Unsubscribes all internal (library) subscriptions
 * 
 * @returns {Promise<object>} Returns a promise (async function)
 * - If resolved, subscribing from all was successful and number of unsubscribed notifications is returned (object)
 * - If rejected, subscribing failed for some of the notifications, number of successful, number of failed and error info are returned (object)
 * 
 * @memberof _LibraryInternals
 */
function _unsubscribeAllInternals() {
  return new Promise(async (resolve, reject) => {
    debug(`_unsubscribeAllInternals(): Unsubscribing from all notifications`)

    let firstError = null, unSubCount = 0

    for (let sub in this._internals.activeSubscriptions) {
      try {
        //This method will only unsubscribe from internal notification handles
        if (this._internals.activeSubscriptions[sub].internal !== true) continue

        await this._internals.activeSubscriptions[sub].unsubscribe()
        unSubCount++

      } catch (err) {
        debug(`_unsubscribeAllInternals(): Unsubscribing from notification ${JSON.stringify(this._internals.activeSubscriptions[sub].target)} failed`)
        firstError = new ClientException(this, '_unsubscribeAllInternals()', err)
      }
    }

    if (firstError != null) {
      debug(`_unsubscribeAllInternals(): Unsubscribed from ${unSubCount} notifications but unsubscribing from some notifications failed failed`)
      return reject(firstError)
    }

    //If we are here, everything went fine
    debug(`_unsubscribeAllInternals(): Unsubscribed from ${unSubCount} notifications`)
    resolve({
      unSubCount
    })
  })
}








/**
 * Parses data type information from given (byte) Buffer. Recursively parses all sub types also
 * 
 * @param {Buffer} data - Buffer object that contains the data
 * 
 * @returns {object} Returns the data type information as object
 * 
 * @memberof _LibraryInternals
 */
async function _parseDataType(data) {
  let pos = 0, dataType = {}

  //4..7 Version
  dataType.version = data.readUInt32LE(pos)
  pos += 4

  //8..11 Hash value of datatype to compare datatypes
  dataType.hashValue = data.readUInt32LE(pos)
  pos += 4

  //12..15 hashValue of base type / Code Offset to setter Method (typeHashValue or offsSetCode)
  dataType.typeHashValue = data.readUInt32LE(pos)
  pos += 4

  //16..19 Size
  dataType.size = data.readUInt32LE(pos)
  pos += 4

  //20..23 Offset
  dataType.offset = data.readUInt32LE(pos)
  pos += 4

  //24..27 ADS data type (AdsDataType)
  dataType.adsDataType = data.readUInt32LE(pos)
  dataType.adsDataTypeStr = ADS.ADS_DATA_TYPES.toString(dataType.adsDataType)
  pos += 4

  //28..31 Flags (AdsDataTypeFlags)
  dataType.flags = data.readUInt32LE(pos)
  dataType.flagsStr = ADS.ADS_DATA_TYPE_FLAGS.toStringArray(dataType.flags)
  pos += 4

  //32..33 Name length
  dataType.nameLength = data.readUInt16LE(pos)
  pos += 2

  //34..35 Type length
  dataType.typeLength = data.readUInt16LE(pos)
  pos += 2

  //36..37 Comment length
  dataType.commentLength = data.readUInt16LE(pos)
  pos += 2

  //38..39 Array dimension
  dataType.arrayDimension = data.readUInt16LE(pos)
  pos += 2

  //40..41 Subitem count
  dataType.subItemCount = data.readUInt16LE(pos)
  pos += 2

  //42.... Name
  dataType.name = _trimPlcString(iconv.decode(data.slice(pos, pos + dataType.nameLength + 1), 'cp1252'))
  pos += dataType.nameLength + 1

  //...... Type
  dataType.type = _trimPlcString(iconv.decode(data.slice(pos, pos + dataType.typeLength + 1), 'cp1252'))
  pos += dataType.typeLength + 1

  //...... Comment
  dataType.comment = _trimPlcString(iconv.decode(data.slice(pos, pos + dataType.commentLength + 1), 'cp1252'))
  pos += dataType.commentLength + 1


  //Array data
  dataType.arrayData = []
  for (let i = 0; i < dataType.arrayDimension; i++) {
    const array = {}

    array.startIndex = data.readInt32LE(pos)
    pos += 4

    array.length = data.readUInt32LE(pos)
    pos += 4

    dataType.arrayData.push(array)
  }


  //Subitems data
  dataType.subItems = []
  for (let i = 0; i < dataType.subItemCount; i++) {
    //Get subitem length
    let len = data.readUInt32LE(pos)
    pos += 4

    //Parse the subitem recursively
    dataType.subItems.push(await _parseDataType.call(this, data.slice(pos, pos + len)))

    pos += (len - 4)
  }

  //If flags contain TypeGuid
  if (dataType.flagsStr.includes('TypeGuid')) {
    dataType.typeGuid = data.slice(pos, pos + 16).toString('hex')
    pos += 16
  }

  //If flags contain CopyMask
  if (dataType.flagsStr.includes('CopyMask')) {
    //Let's skip this for now
    pos += dataType.size
  }

  dataType.rpcMethods = []

  //If flags contain MethodInfos (TwinCAT.Ads.dll: AdsMethodEntry)
  if (dataType.flagsStr.includes('MethodInfos')) {
    dataType.methodCount = data.readUInt16LE(pos)
    pos += 2

    //RPC methods
    for (let i = 0; i < dataType.methodCount; i++) {
      const method = {}

      //Get method length
      let len = data.readUInt32LE(pos)
      pos += 4

      //4..7 Version
      method.version = data.readUInt32LE(pos)
      pos += 4

      //8..11 Virtual table index
      method.vTableIndex = data.readUInt32LE(pos)
      pos += 4

      //12..15 Return size
      method.returnSize = data.readUInt32LE(pos)
      pos += 4

      //16..19 Return align size
      method.returnAlignSize = data.readUInt32LE(pos)
      pos += 4

      //20..23 Reserved
      method.reserved = data.readUInt32LE(pos)
      pos += 4

      //24..27 Return type GUID
      method.returnTypeGuid = data.slice(pos, pos + 16).toString('hex')
      pos += 16

      //28..31 Return data type
      method.retunAdsDataType = data.readUInt32LE(pos)
      method.retunAdsDataTypeStr = ADS.ADS_DATA_TYPES.toString(method.retunAdsDataType)
      pos += 4

      //27..30 Flags (AdsDataTypeFlags)
      method.flags = data.readUInt16LE(pos)
      method.flagsStr = ADS.ADS_DATA_TYPE_FLAGS.toStringArray(method.flags)
      pos += 4

      //31..32 Name length
      method.nameLength = data.readUInt16LE(pos)
      pos += 2

      //33..34 Return type length
      method.returnTypeLength = data.readUInt16LE(pos)
      pos += 2

      //35..36 Comment length
      method.commentLength = data.readUInt16LE(pos)
      pos += 2

      //37..38 Parameter count
      method.parameterCount = data.readUInt16LE(pos)
      pos += 2

      //39.... Name
      method.name = _trimPlcString(iconv.decode(data.slice(pos, pos + method.nameLength + 1), 'cp1252'))
      pos += method.nameLength + 1

      //...... Return type
      method.returnType = _trimPlcString(iconv.decode(data.slice(pos, pos + method.returnTypeLength + 1), 'cp1252'))
      pos += method.returnTypeLength + 1

      //...... Comment
      method.comment = _trimPlcString(iconv.decode(data.slice(pos, pos + method.commentLength + 1), 'cp1252'))
      pos += method.commentLength + 1

      method.parameters = []

      for (let p = 0; p < method.parameterCount; p++) {
        const param = {}

        let paramStartPos = pos

        //Get parameter length
        let paramLen = data.readUInt32LE(pos)
        pos += 4

        //4..7 Size
        param.size = data.readUInt32LE(pos)
        pos += 4

        //8..11 Align size
        param.alignSize = data.readUInt32LE(pos)
        pos += 4

        //12..15 Data type
        param.adsDataType = data.readUInt32LE(pos)
        param.adsDataTypeStr = ADS.ADS_DATA_TYPES.toString(param.adsDataType)
        pos += 4

        //16..19 Flags (RCP_METHOD_PARAM_FLAGS)
        param.flags = data.readUInt16LE(pos)
        param.flagsStr = ADS.RCP_METHOD_PARAM_FLAGS.toStringArray(param.flags)
        pos += 4

        //20..23 Reserved
        param.reserved = data.readUInt32LE(pos)
        pos += 4

        //24..27 Type GUID
        param.typeGuid = data.slice(pos, pos + 16).toString('hex')
        pos += 16

        //28..31 LengthIsPara
        param.lengthIsPara = data.readUInt16LE(pos)
        pos += 2

        //32..33 Name length
        param.nameLength = data.readUInt16LE(pos)
        pos += 2

        //34..35 Type length
        param.typeLength = data.readUInt16LE(pos)
        pos += 2

        //36..37 Comment length
        param.commentLength = data.readUInt16LE(pos)
        pos += 2

        //38.... Name
        param.name = _trimPlcString(iconv.decode(data.slice(pos, pos + param.nameLength + 1), 'cp1252'))
        pos += param.nameLength + 1

        //...... Type
        param.type = _trimPlcString(iconv.decode(data.slice(pos, pos + param.typeLength + 1), 'cp1252'))
        pos += param.typeLength + 1

        //...... Comment
        param.comment = _trimPlcString(iconv.decode(data.slice(pos, pos + param.commentLength + 1), 'cp1252'))
        pos += param.commentLength + 1

        if (pos - paramStartPos > paramLen) {
          //There is some additional data
          param.reserved2 = data.slice(pos)
        }
        method.parameters.push(param)
      }

      dataType.rpcMethods.push(method)
    }
  }

  //If flags contain Attributes (TwinCAT.Ads.dll: AdsAttributeEntry)
  //Attribute is for example, a pack-mode attribute above struct
  dataType.attributes = []
  if (dataType.flagsStr.includes('Attributes')) {
    dataType.attributeCount = data.readUInt16LE(pos)
    pos += 2

    //Attributes
    for (let i = 0; i < dataType.attributeCount; i++) {
      let attr = {}

      //Name length
      let nameLen = data.readUInt8(pos)
      pos += 1

      //Value length
      let valueLen = data.readUInt8(pos)
      pos += 1

      //Name
      attr.name = _trimPlcString(iconv.decode(data.slice(pos, pos + nameLen + 1), 'cp1252'))
      pos += (nameLen + 1)

      //Value
      attr.value = _trimPlcString(iconv.decode(data.slice(pos, pos + valueLen + 1), 'cp1252'))
      pos += (valueLen + 1)

      dataType.attributes.push(attr)
    }
  }


  //If flags contain EnumInfos (TwinCAT.Ads.dll: AdsEnumInfoEntry)
  //EnumInfo contains the enumeration values as string
  if (dataType.flagsStr.includes('EnumInfos')) {
    dataType.enumInfoCount = data.readUInt16LE(pos)
    pos += 2
    //EnumInfos
    dataType.enumInfo = []
    for (let i = 0; i < dataType.enumInfoCount; i++) {
      let enumInfo = {}

      //Name length
      let nameLen = data.readUInt8(pos)
      pos += 1

      //Name
      enumInfo.name = _trimPlcString(iconv.decode(data.slice(pos, pos + nameLen + 1), 'cp1252'))
      pos += (nameLen + 1)

      //Value
      enumInfo.value = data.slice(pos, pos + dataType.size)
      pos += dataType.size

      dataType.enumInfo.push(enumInfo)
    }
  }

  //Reserved, if any
  dataType.reserved = data.slice(pos)

  return dataType
}










/**
 * Parses symbol information from given (byte) Buffer
 * 
 * @param {Buffer} data - Buffer object that contains the data **Note:** The data should not contain the first 4 bytes of symbol entry data length, it should be already parsed
 * 
 * @returns {object} Returns the symbol information as object
 * 
 * @memberof _LibraryInternals
 */
function _parseSymbolInfo(data) {
  let pos = 0, symbol = {}

  //0..3 Index group
  symbol.indexGroup = data.readUInt32LE(pos)
  pos += 4

  //4..7 Index offset
  symbol.indexOffset = data.readUInt32LE(pos)
  pos += 4

  //12..15 Symbol size
  symbol.size = data.readUInt32LE(pos)
  pos += 4

  //16..19 Symbol datatype
  symbol.adsDataType = data.readUInt32LE(pos)
  symbol.adsDataTypeStr = ADS.ADS_DATA_TYPES.toString(symbol.adsDataType)
  pos += 4

  //20..21 Flags
  symbol.flags = data.readUInt16LE(pos)
  symbol.flagsStr = ADS.ADS_SYMBOL_FLAGS.toStringArray(symbol.flags)
  pos += 2

  //22..23 Array dimension
  symbol.arrayDimension = data.readUInt16LE(pos)
  pos += 2

  //24..25 Symbol name length
  symbol.nameLength = data.readUInt16LE(pos)
  pos += 2

  //26..27 Symbol type length
  symbol.typeLength = data.readUInt16LE(pos)
  pos += 2

  //28..29 Symbol comment length
  symbol.commentLength = data.readUInt16LE(pos)
  pos += 2

  //30.... Symbol name & system name
  symbol.name = _trimPlcString(iconv.decode(data.slice(pos, pos + symbol.nameLength + 1), 'cp1252'))
  pos += symbol.nameLength + 1

  //...... Symbol type
  symbol.type = _trimPlcString(iconv.decode(data.slice(pos, pos + symbol.typeLength + 1), 'cp1252'))
  pos += symbol.typeLength + 1

  //...... Symbol comment
  symbol.comment = _trimPlcString(iconv.decode(data.slice(pos, pos + symbol.commentLength + 1), 'cp1252'))
  pos += symbol.commentLength + 1

  //Array data
  symbol.arrayData = []
  for (let i = 0; i < symbol.arrayDimension; i++) {
    const array = {}

    array.startIndex = data.readInt32LE(pos)
    pos += 4

    array.length = data.readUInt32LE(pos)
    pos += 4

    symbol.arrayData.push(array)
  }

  //If flags contain TypeGuid
  if (symbol.flagsStr.includes('TypeGuid')) {
    symbol.typeGuid = data.slice(pos, pos + 16).toString('hex')
    pos += 16
  }

  //If flags contain Attributes (TwinCAT.Ads.dll: AdsAttributeEntry)
  //Attribute is for example, a pack-mode attribute above struct
  symbol.attributes = []
  if (symbol.flagsStr.includes('Attributes')) {
    symbol.attributeCount = data.readUInt16LE(pos)
    pos += 2

    //Attributes
    for (let i = 0; i < symbol.attributeCount; i++) {
      let attr = {}

      //Name length
      let nameLen = data.readUInt8(pos)
      pos += 1

      //Value length
      let valueLen = data.readUInt8(pos)
      pos += 1

      //Name
      attr.name = _trimPlcString(iconv.decode(data.slice(pos, pos + nameLen + 1), 'cp1252'))
      pos += (nameLen + 1)

      //Value
      attr.value = _trimPlcString(iconv.decode(data.slice(pos, pos + valueLen + 1), 'cp1252'))
      pos += (valueLen + 1)

      symbol.attributes.push(attr)
    }
  }

  //If flags contain ExtendedFlags
  if (symbol.flagsStr.includes('ExtendedFlags')) {
    //Add later if required (32 bit integer)
    symbol.ExtendedFlags = data.readUInt32LE(pos)
    pos += 4
  }

  //Reserved, if any
  symbol.reserved = data.slice(pos)

  return symbol
}








/**
 * Reads symbol information from PLC for given variable
 * 
 * @param {string} variableName Variable name in the PLC (full path) - Example: 'MAIN.SomeStruct.SomeValue' 
 * 
 * @returns {Promise<object>} Returns a promise (async function)
 * - If resolved, reading was successful and parsed symbol information is returned (object)
 * - If rejected, reading failed and error info is returned (object)
 * 
 * @memberof _LibraryInternals
 */
function _readSymbolInfo(variableName) {
  return new Promise(async (resolve, reject) => {
    debugD(`_readSymbolInfo(): Reading symbol info for ${variableName}`)

    //Allocating bytes for request
    const data = Buffer.alloc(16 + variableName.length + 1) //Note: String end delimeter
    let pos = 0

    //0..3 IndexGroup
    data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.SymbolInfoByNameEx, pos)
    pos += 4

    //4..7 IndexOffset
    data.writeUInt32LE(0, pos)
    pos += 4

    //8..11 Read data length
    data.writeUInt32LE(0xFFFFFFFF, pos) //Seems to work OK (we don't know the size)
    pos += 4

    //12..15 Write data length
    data.writeUInt32LE(variableName.length + 1, pos) //Note: String end delimeter
    pos += 4

    //16..n Data
    iconv.encode(variableName, 'cp1252').copy(data, pos)
    pos += variableName.length

    _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadWrite, data)
      .then((res) => {
        //Success - NOTE: We need to skip the data length (4 bytes) as the _parseSymbolInfo requires so
        debugD(`_readSymbolInfo(): Reading symbol info for ${variableName} successful`)

        resolve(_parseSymbolInfo.call(this, res.ads.data.slice(4)))
      })
      .catch((res) => {
        debug(`_readSymbolInfo(): Reading symbol info for ${variableName} failed: %o`, res)
        return reject(new ClientException(this, '_readSymbolInfo()', `Reading symbol information for ${variableName} failed`, res))
      })
  })
}








/**
 * Recursively parses given object to (byte) Buffer for PLC using data type information
 * 
 * @param {object} value Object containing the data
 * @param {object} dataType Data type information object, that corresponds to the given value and the PLC type
 * @param {string} [objectPathStr] **DO NOT ASSIGN MANUALLY** Object path that is being parsed (eg. 'SomeStruct.InnerStruct.SomeValue') for debugging.
 * @param {boolean} [isArraySubItem] **DO NOT ASSIGN MANUALLY** True if the object that is being parsed is an array subitem 
 * 
 * @returns {Buffer} Returns a Buffer that contains the data ready for PLC
 * 
 * @memberof _LibraryInternals
 */
function _parseJsObjectToBuffer(value, dataType, objectPathStr = '', isArraySubItem = false) {
  let buffer = Buffer.alloc(0)

  //Struct or array subitem - Go through each subitem 
  if ((dataType.arrayData.length === 0 || isArraySubItem) && dataType.subItems.length > 0) {
    buffer = Buffer.alloc(dataType.size)

    for (const subItem of dataType.subItems) {
      //Try the find the subitem from javascript object
      let key = null

      //First, try the easy way (5-20x times faster)
      if (value[subItem.name] !== undefined) {
        key = subItem.name
      } else {
        //Not found, try case-insensitive way
        try {
          key = Object.keys(value).find(objKey => objKey.toLowerCase() === subItem.name.toLowerCase())
        } catch (err) {
          //value is null or not object or something else
          key = null
        }
      }

      //If the subitem isn't found from given object, throw error
      if (key == null) {
        const err = new TypeError(`Given Javascript object is missing key/value for at least "${objectPathStr}.${subItem.name}" (${subItem.type})`)
        err.isNotCompleteObject = true

        throw err
      }

      //Recursively parse subitem(s)
      const bufferedData = _parseJsObjectToBuffer.call(this, value[key], subItem, `${objectPathStr}.${subItem.name}`)

      //Add the subitem data to the buffer
      bufferedData.copy(buffer, subItem.offset)
    }

    //Array - Go through each array subitem
  } else if (dataType.arrayData.length > 0 && !isArraySubItem) {

    //Recursive parsing of array dimensions
    const parseArray = (value, arrayDimension, arrayPathStr = '') => {

      for (let child = 0; child < dataType.arrayData[arrayDimension].length; child++) {
        if (dataType.arrayData[arrayDimension + 1]) {
          //More dimensions available -> go deeper
          parseArray(value[child], arrayDimension + 1, `${arrayPathStr}[${child}]`)

        } else {
          //This is the final dimension
          if (value[child] === undefined) {
            throw new Error(`Given Javascript object is missing array index for ${objectPathStr}${arrayPathStr}[${child}]`)
          }

          const bufferedData = _parseJsObjectToBuffer.call(this, value[child], dataType, `${objectPathStr}${arrayPathStr}[${child}]`, true)
          buffer = Buffer.concat([buffer, bufferedData]) //TODO: optimize, concat is not a good way
        }
      }
    }
    parseArray(value, 0)



    //Enumeration
  } else if (dataType.enumInfo) {
    buffer = Buffer.alloc(dataType.size)

    let enumValue = null

    if (value.value != undefined) {
      enumValue = value.value

    } else if (value.name !== undefined || typeof value === 'string') {
      enumValue = dataType.enumInfo.find(info => info.name.trim().toLowerCase() === (value.name ? value.name : value).trim().toLowerCase())

      if (enumValue) {
        enumValue = enumValue.value
      } else {
        throw `Given Javascript enumeration value ${objectPathStr}.${dataType.name} = ${(value.name ? value.name : value)} is not allowed`
      }

    } else if (typeof value === 'number') {
      enumValue = value
    }
    else {
      throw `Given Javascript object value ${objectPathStr}.${dataType.name} is not allowed`
    }

    //Parse the base type from javascript variable to buffer
    _parseJsVariableToPlc.call(this, enumValue, dataType, buffer)


    //Base datatype that is not empty
  } else if (dataType.size > 0) {
    buffer = Buffer.alloc(dataType.size)

    //Parse the base type from javascript variable to buffer
    _parseJsVariableToPlc.call(this, value, dataType, buffer)
  }
  return buffer
}









/**
 * Parses given javascript variable to (byte) Buffer for PLC
 * 
 * @param {object} value Variable containing the data
 * @param {object} dataType Data type information object, that corresponds to the given value and the PLC type
 * @param {Buffer} dataBuffer Target Buffer where that data is saved (used as reference so is the output also)
 * 
 * @memberof _LibraryInternals
 */
function _parseJsVariableToPlc(value, dataType, dataBuffer) {
  if (dataType.size === 0) {
    //Do nothing to the buffer (keep empty)
    return
  }

  //Some datatypes are easier to parse using ads data type
  switch (dataType.adsDataType) {
    case ADS.ADS_DATA_TYPES['ADST_STRING']:
      iconv.encode(value, 'cp1252').copy(dataBuffer)
      break

    case ADS.ADS_DATA_TYPES['ADST_WSTRING']:
      iconv.encode(value, 'ucs2').copy(dataBuffer)
      break

    //All others ads data types:
    default:
      ADS.BASE_DATA_TYPES.toBuffer(this.settings, dataType.type, value, dataBuffer)
  }
}













/**
 * Recursively parses given (byte) Buffer to javascript object using data type information
 * 
 * @param {Buffer} dataBuffer Buffer that contains the PLC data
 * @param {object} dataType Data type information object, that corresponds to the given value and the PLC type
 * @param {boolean} [isArraySubItem] **DO NOT ASSIGN MANUALLY** True if the buffer that is being parsed is an array subitem 
 * 
 * @returns {object} Parsed data as Javascript object
 * 
 * @memberof _LibraryInternals
 */
function _parsePlcDataToObject(dataBuffer, dataType, isArraySubItem = false) {
  let output = null

  //Struct or array subitem - Go through each subitem
  if ((dataType.arrayData.length === 0 || isArraySubItem) && dataType.subItems.length > 0) {
    output = {}

    //First skip the offset 
    dataBuffer = dataBuffer.slice(dataType.offset)

    for (const subItem of dataType.subItems) {
      output[subItem.name] = _parsePlcDataToObject.call(this, dataBuffer, subItem)
    }


    //Array - Go through each array subitem
  } else if (dataType.arrayData.length > 0 && !isArraySubItem) {
    output = []

    //Recursive parsing of array dimensions
    const parseArray = (arrayDimension) => {
      let result = []

      for (let child = 0; child < dataType.arrayData[arrayDimension].length; child++) {
        if (dataType.arrayData[arrayDimension + 1]) {
          //More dimensions available -> go deeper
          result.push(parseArray(arrayDimension + 1))

        } else {
          //This is the final dimension -> we have actual data
          result.push(_parsePlcDataToObject.call(this, dataBuffer, dataType, true))
          dataBuffer = dataBuffer.slice(dataType.size)
        }
      }
      return result
    }

    output = parseArray(0)


    //Enumeration (only if we want to convert enumerations to object)
  } else if (dataType.enumInfo && this.settings.objectifyEnumerations && this.settings.objectifyEnumerations === true) {
    output = _parsePlcVariableToJs.call(this, dataBuffer.slice(dataType.offset, dataType.offset + dataType.size), dataType)

    let enumVal = dataType.enumInfo.find(entry => entry.value === output)
    if (enumVal) {
      output = enumVal
    } else {
      //Enumeration value is not valid, so set name to null
      output = {
        name: null,
        value: output
      }
    }


    //Basic datatype
  } else {
    output = _parsePlcVariableToJs.call(this, dataBuffer.slice(dataType.offset, dataType.offset + dataType.size), dataType)
  }

  return output
}










/**
 * Parses javascript variable from given (byte) Buffer using data type information
 * 
 * @param {Buffer} dataBuffer Target Buffer where that data is saved (used as reference so is the output also)
 * @param {object} dataType Data type information object, that corresponds to the given value and the PLC type
 * 
 * @returns {object} Parsed variable
 * 
 * @memberof _LibraryInternals
 */
function _parsePlcVariableToJs(dataBuffer, dataType) {
  if (dataType.size === 0) {
    return {}
  }

  //Some datatypes are easier to parse using ads data type
  switch (dataType.adsDataType) {
    case ADS.ADS_DATA_TYPES['ADST_STRING']:
      return _trimPlcString(iconv.decode(dataBuffer, 'cp1252'))

    case ADS.ADS_DATA_TYPES['ADST_WSTRING']:
      return _trimPlcString(iconv.decode(dataBuffer, 'ucs2'))

    //All others ads data types:
    default:
      return ADS.BASE_DATA_TYPES.fromBuffer(this.settings, dataType.type, dataBuffer)
  }
}









/**
 * Reads data type information from PLC for given data type
 * 
 * @param {string} dataTypeName - Data type name in the PLC - Example: 'INT', 'E_SomeEnum', 'ST_SomeStruct' etc. 
 * 
 * @returns {Promise<object>} Returns a promise (async function)
 * - If resolved, reading was successful and parsed data type information is returned (object)
 * - If rejected, reading failed and error info is returned (object)
 * 
 * @memberof _LibraryInternals
 */
function _readDataTypeInfo(dataTypeName) {
  return new Promise(async (resolve, reject) => {
    debugD(`_readDataTypeInfo(): Reading data type info for ${dataTypeName}`)

    //Allocating bytes for request
    const data = Buffer.alloc(16 + dataTypeName.length + 1)
    let pos = 0

    //0..3 IndexGroup
    data.writeUInt32LE(ADS.ADS_RESERVED_INDEX_GROUPS.DataDataTypeInfoByNameEx, pos)
    pos += 4

    //4..7 IndexOffset
    data.writeUInt32LE(0, pos)
    pos += 4

    //8..11 Read data length
    data.writeUInt32LE(0xFFFFFFFF, pos) //Seems to work OK (we don't know the size)
    pos += 4

    //12..15 Write data length
    data.writeUInt32LE(dataTypeName.length + 1, pos) //Note: String end delimeter
    pos += 4

    //16..n Data
    iconv.encode(dataTypeName, 'cp1252').copy(data, pos)
    pos += dataTypeName.length

    _sendAdsCommand.call(this, ADS.ADS_COMMAND.ReadWrite, data)
      .then(async (res) => {
        //Success - NOTE: We need to skip the data length (4 bytes) as the _parseDataType requires so
        debugD(`_readDataTypeInfo(): Reading data type info info for ${dataTypeName} successful`)
        resolve(await _parseDataType.call(this, res.ads.data.slice(4)))

      })
      .catch((res) => {
        debug(`_readDataTypeInfo(): Reading data type info for ${dataTypeName} failed: %o`, res)
        reject(new ClientException(this, '_readDataTypeInfo()', `Reading data type info for ${dataTypeName} failed`, res))
      })
  })
}







/**
  * Parses full data type information recursively and returns it as object
  * 
  * @param {string} dataTypeName - Data type name in the PLC - Example: 'INT', 'E_SomeEnum', 'ST_SomeStruct' etc. 
  * @param {boolean} [firstLevel] **DO NOT ASSIGN MANUALLY** True if this is the first recursion / top level of the data type
  * @param {number} [size] - The size of the data type. This is used if we are connected to older runtime that doesn't provide base data type info -> we need to know the size at least in some cases
  * 
  * @returns {Promise<object>} Returns a promise (async function)
  * - If resolved, data type is returned (object)
  * - If rejected, reading failed and error info is returned (object)
* 
* @memberof _LibraryInternals
  */
function _getDataTypeRecursive(dataTypeName, firstLevel = true, size = null) {
  return new Promise(async (resolve, reject) => {
    let dataType = {}

    try {
      dataType = await _getDataTypeInfo.call(this, dataTypeName)

    } catch (err) {
      //Empty dummy data type
      dataType = {
        version: 1,
        hashValue: 0,
        typeHashValue: 0,
        size: 0,
        offset: 0,
        adsDataType: 0,
        adsDataTypeStr: '',
        flags: [ADS.ADS_DATA_TYPE_FLAGS.DataType],
        flagsStr: ADS.ADS_DATA_TYPE_FLAGS.toStringArray([ADS.ADS_DATA_TYPE_FLAGS.DataType]),
        nameLength: '',
        typeLength: 0,
        commentLength: 0,
        arrayDimension: 0,
        subItemCount: 0,
        name: '',
        type: '',
        comment: '',
        arrayData: [],
        subItems: [],
        attributes: [],
        rpcMethods: []
      }

      //Not found. Try if it's base type (to support TwinCAT 2 and 3.1.4020 and lower)
      if (ADS.BASE_DATA_TYPES.isPseudoType(dataTypeName)) {
        dataTypeName = ADS.BASE_DATA_TYPES.getTypeByPseudoType(dataTypeName, size)
      }

      if (ADS.BASE_DATA_TYPES.isKnownType(dataTypeName)) {
        const baseDataType = ADS.BASE_DATA_TYPES.find(dataTypeName)

        debugD(`_getDataTypeRecursive(): Data type ${dataTypeName} was not found from PLC - using local pseudo/base data type info`)

        dataType.size = (size == null ? baseDataType.size : size)
        dataType.adsDataType = baseDataType.adsDataType
        dataType.adsDataTypeStr = ADS.ADS_DATA_TYPES.toString(dataType.adsDataType)
        dataType.nameLength = dataTypeName.length
        dataType.name = dataTypeName

      } else {
        //Unknown type
        return reject(new ClientException(this, '_getDataTypeRecursive()', err))
      }
    }

    //Select default values. Edit this to add more to the end-user data type object
    let parsedDataType = {
      name: dataType.name,
      type: dataType.type,
      size: dataType.size,
      offset: dataType.offset,
      adsDataType: dataType.adsDataType,
      adsDataTypeStr: dataType.adsDataTypeStr,
      comment: dataType.comment,
      attributes: (dataType.attributes ? dataType.attributes : []),
      rpcMethods: dataType.rpcMethods,
      arrayData: [],
      subItems: []
    }

    //If data type has subItems, loop them through
    if (dataType.subItemCount > 0) {

      for (let i = 0; i < dataType.subItemCount; i++) {
        //Get the actual data type for subItem
        let subItemType = {}

        //Support for TwinCAT 3 < 4022: If we have empty struct, do nothing. ADS API will not return data type for empty struct.
        if (dataType.subItems[i].size === 0) {
          subItemType = dataType.subItems[i]
        } else {
          subItemType = await _getDataTypeRecursive.call(this, dataType.subItems[i].type, false, dataType.subItems[i].size)

          //Let's keep some data from the parent
          subItemType.type = subItemType.name
          subItemType.name = dataType.subItems[i].name
          subItemType.offset = dataType.subItems[i].offset
          subItemType.comment = dataType.subItems[i].comment
        }

        parsedDataType.subItems.push(subItemType)
      }


      //If the data type has flag "DataType", it's not enum and it's not array
    } else if (((dataType.type === '' && !ADS.BASE_DATA_TYPES.isPseudoType(dataType.name)) || ADS.BASE_DATA_TYPES.isKnownType(dataType.name)) && dataType.flagsStr.includes('DataType') && !dataType.flagsStr.includes('EnumInfos') && dataType.arrayDimension === 0) {
      //Do nothing - this is the final form
      //TODO: Get rid of this

      //Data type is a pseudo data type (pointer, reference, PVOID, UXINT etc..).
    } else if (ADS.BASE_DATA_TYPES.isPseudoType(dataType.name) && dataType.arrayDimension === 0) {

      //TODO: If this somehow fails (dataType.size is unknown) - what to do?
      parsedDataType.name = ADS.BASE_DATA_TYPES.getTypeByPseudoType(dataType.name, dataType.size)

      //If the data type is array
    } else if (dataType.arrayDimension > 0) {
      //Get array subtype
      const arrayType = await _getDataTypeRecursive.call(this, dataType.type, false)

      parsedDataType = arrayType
      //Combining array information (for ARRAY OF ARRAY support)
      parsedDataType.arrayData = dataType.arrayData.concat(parsedDataType.arrayData)

      //If the data type has flag "DataType" and it's enum
    } else if (dataType.flagsStr.includes('DataType') && dataType.flagsStr.includes('EnumInfos')) {

      //Get enum subtype
      const enumType = await _getDataTypeRecursive.call(this, dataType.type, false)

      parsedDataType = enumType
      parsedDataType.enumInfo = dataType.enumInfo

      //Parse enumeration info
      parsedDataType.enumInfo = parsedDataType.enumInfo.map(entry => {
        const temp = {
          ...parsedDataType,
          type: parsedDataType.name
        }

        return {
          ...entry,
          value: _parsePlcVariableToJs.call(this, entry.value, temp)
        }
      })


      //Added because of TwinCAT 2 - If we have empty struct, it's size is not 0 but it has no subitems
    } else if (dataType.subItemCount === 0 && dataType.adsDataType === ADS.ADS_DATA_TYPES.ADST_BIGTYPE) {
      //Do nothing here

      //This is not the final data type, continue parsing
    } else {
      const childType = await _getDataTypeRecursive.call(this, dataType.type, false)

      parsedDataType = childType
    }

    //If this is the first level of data type, the "name" contains actually the type
    if (firstLevel === true) {
      parsedDataType.type = parsedDataType.name
      parsedDataType.name = ''
    }

    resolve(parsedDataType)
  })
}














/**
 * Returns data type information for a data type. 
 * NOTE: Returns only the upmost level info, not subitems of subitems (not recursively for the whole data type). Use _getDataTypeRecursive() to receive the whole type
 * 
 * First looks in the cache, if not found, reads it from the PLC
 * 
 * @param {string} dataTypeName - Data type name in the PLC - Example: 'INT', 'E_SomeEnum', 'ST_SomeStruct' etc. 
 * 
 * @returns {Promise<object>} Returns a promise (async function)
 * - If resolved, data type is returned (object)
 * - If rejected, reading failed and error info is returned (object)
 * 
 * @memberof _LibraryInternals
 */
function _getDataTypeInfo(dataTypeName) {
  return new Promise(async (resolve, reject) => {
    debugD(`_getDataTypeInfo(): Data type requested for ${dataTypeName}`)

    let dataType = null
    dataTypeName = dataTypeName.trim()

    //First, check already downloaded data types
    if (this.metaData.dataTypes[dataTypeName.toLowerCase()]) {
      dataType = this.metaData.dataTypes[dataTypeName.toLowerCase()]
      debugD(`_getDataTypeInfo(): Data type info found from cache for ${dataTypeName}`)

      return resolve(dataType)

    } else {
      //Not found from cache, read from PLC
      _readDataTypeInfo.call(this, dataTypeName)
        .then((res) => {
          dataType = res
          this.metaData.dataTypes[dataTypeName.toLowerCase()] = dataType

          debugD(`_getDataTypeInfo(): Data type info read and cached from PLC for ${dataTypeName}`)

          return resolve(dataType)
        })
        .catch(err => reject(new ClientException(this, '_getDataTypeInfo()', err)))
    }
  })
}










/**
 * Checks received data buffer for full AMS packets. If full packet is found, it is parsed and handled.
 * 
 * Calls itself recursively if multiple packets available. Added also setImmediate calls to prevent event loop from blocking
 * 
 * @memberof _LibraryInternals
 */
function _checkReceivedData() {
  //If we haven't enough data to determine packet size, quit
  if (this._internals.receiveDataBuffer.byteLength < ADS.AMS_TCP_HEADER_LENGTH)
    return

  //There should be an AMS packet, so the packet size is available in the bytes 2..5
  let packetLength = this._internals.receiveDataBuffer.readUInt32LE(2) + ADS.AMS_TCP_HEADER_LENGTH

  //Not enough data yet? quit
  if (this._internals.receiveDataBuffer.byteLength < packetLength)
    return

  //Note: Changed from slice to Buffer.from - Should this be reconsidered?
  //const data = this._internals.receiveDataBuffer.slice(0, packetLength)
  const data = Buffer.from(this._internals.receiveDataBuffer.slice(0, packetLength))
  this._internals.receiveDataBuffer = this._internals.receiveDataBuffer.slice(data.byteLength)

  //Parse the packet, but allow time for the event loop
  setImmediate(_parseAmsTcpPacket.bind(this, data))

  //If there is more, call recursively but allow time for the event loop
  if (this._internals.receiveDataBuffer.byteLength >= ADS.AMS_TCP_HEADER_LENGTH) {
    setImmediate(_checkReceivedData.bind(this))
  }
}











/**
 * Parses an AMS/TCP packet from given (byte) Buffer and then handles it
 * 
 * @param {Buffer} data Buffer that contains data for a single full AMS/TCP packet
 * 
 * @memberof _LibraryInternals
 */
async function _parseAmsTcpPacket(data) {
  const packet = {}
  let parseResult = null

  //1. Parse AMS/TCP header
  parseResult = _parseAmsTcpHeader.call(this, data)
  packet.amsTcp = parseResult.amsTcp
  data = parseResult.data

  //2. Parse AMS header (if exists)
  parseResult = _parseAmsHeader.call(this, data)
  packet.ams = parseResult.ams
  data = parseResult.data

  //3. Parse ADS data (if exists)
  packet.ads = (packet.ams.error ? {} : _parseAdsData.call(this, packet, data))

  //4. Handle the parsed packet
  _onAmsTcpPacketReceived.call(this, packet)
}








/**
 * Parses an AMS/TCP header from given (byte) Buffer
 * 
 * @param {Buffer} data Buffer that contains data for a single full AMS/TCP packet
 * 
 * @returns {object} Object {amsTcp, data}, where amsTcp is the parsed header and data is rest of the data
 * 
 * @memberof _LibraryInternals
 */
function _parseAmsTcpHeader(data) {
  debugD(`_parseAmsTcpHeader(): Starting to parse AMS/TCP header`)

  let pos = 0
  const amsTcp = {}

  //0..1 AMS command (header flag)
  amsTcp.command = data.readUInt16LE(pos)
  amsTcp.commandStr = ADS.AMS_HEADER_FLAG.toString(amsTcp.command)
  pos += 2

  //2..5 Data length
  amsTcp.dataLength = data.readUInt32LE(pos)
  pos += 4

  //Remove AMS/TCP header from data  
  data = data.slice(ADS.AMS_TCP_HEADER_LENGTH)

  //If data length is less than AMS_HEADER_LENGTH,
  //we know that this packet has no AMS headers -> it's only a AMS/TCP command
  if (data.byteLength < ADS.AMS_HEADER_LENGTH) {
    amsTcp.data = data

    //Remove data (basically creates an empty buffer..)
    data = data.slice(data.byteLength)
  }

  debugD(`_parseAmsTcpHeader(): AMS/TCP header parsed: %o`, amsTcp)

  return { amsTcp, data }
}




/**
 * Parses an AMS header from given (byte) Buffer
 * 
 * @param {Buffer} data Buffer that contains data for a single AMS packet (without AMS/TCP header)
 * 
 * @returns {object} Object {ams, data}, where ams is the parsed AMS header and data is rest of the data
 * 
 * @memberof _LibraryInternals
 */
function _parseAmsHeader(data) {
  debugD(`_parseAmsHeader(): Starting to parse AMS header`)

  let pos = 0
  const ams = {}

  if (data.byteLength < ADS.AMS_HEADER_LENGTH) {
    debugD(`_parseAmsHeader(): No AMS header found`)
    return { ams, data }
  }

  //0..5 Target AMSNetId
  ams.targetAmsNetId = _byteArrayToAmsNetIdStr(data.slice(pos, pos + ADS.AMS_NET_ID_LENGTH))
  pos += ADS.AMS_NET_ID_LENGTH

  //6..8 Target ads port
  ams.targetAdsPort = data.readUInt16LE(pos)
  pos += 2

  //8..13 Source AMSNetId
  ams.sourceAmsNetId = _byteArrayToAmsNetIdStr(data.slice(pos, pos + ADS.AMS_NET_ID_LENGTH))
  pos += ADS.AMS_NET_ID_LENGTH

  //14..15 Source ads port
  ams.sourceAdsPort = data.readUInt16LE(pos)
  pos += 2

  //16..17 ADS command
  ams.adsCommand = data.readUInt16LE(pos)
  ams.adsCommandStr = ADS.ADS_COMMAND.toString(ams.adsCommand)
  pos += 2

  //18..19 State flags
  ams.stateFlags = data.readUInt16LE(pos)
  ams.stateFlagsStr = ADS.ADS_STATE_FLAGS.toString(ams.stateFlags)
  pos += 2

  //20..23 Data length
  ams.dataLength = data.readUInt32LE(pos)
  pos += 4

  //24..27 Error code
  ams.errorCode = data.readUInt32LE(pos)
  pos += 4

  //28..31 Invoke ID
  ams.invokeId = data.readUInt32LE(pos)
  pos += 4

  //Remove AMS header from data  
  data = data.slice(ADS.AMS_HEADER_LENGTH)

  //ADS error
  ams.error = (ams.errorCode !== null ? ams.errorCode > 0 : false)
  ams.errorStr = ''
  if (ams.error) {
    ams.errorStr = ADS.ADS_ERROR[ams.errorCode]
  }

  debugD(`_parseAmsHeader(): AMS header parsed: %o`, ams)

  return { ams, data }
}










/**
 * Parses ADS data from given (byte) Buffer. Uses packet.ams to determine the ADS command
 * 
 * @param {Buffer} data Buffer that contains data for a single ADS packet (without AMS/TCP header and AMS header)
 * 
 * @returns {object} Object that contains the parsed ADS data
 * 
 * @memberof _LibraryInternals
 */
function _parseAdsData(packet, data) {
  debugD(`_parseAdsData(): Starting to parse ADS data`)

  let pos = 0
  const ads = {}

  if (data.byteLength === 0) {
    debugD(`_parseAdsData(): No ADS data found`)
    return ads
  }

  //Saving the raw buffer data to object too
  ads.rawData = data


  switch (packet.ams.adsCommand) {
    //-------------- Read Write ---------------
    case ADS.ADS_COMMAND.ReadWrite:
    case ADS.ADS_COMMAND.Read:

      //0..3 Ads error number
      ads.errorCode = data.readUInt32LE(pos)
      pos += 4

      if (data.byteLength <= 4) {
        ads.dataLength = 0
        ads.data = Buffer.alloc(0)
        break
      }

      //4..7 Data length (bytes)
      ads.dataLength = data.readUInt32LE(pos)
      pos += 4

      //8..n Data
      ads.data = Buffer.alloc(ads.dataLength)
      data.copy(ads.data, 0, pos)

      break

    //-------------- Write ---------------
    case ADS.ADS_COMMAND.Write:

      //0..3 Ads error number
      ads.errorCode = data.readUInt32LE(pos)
      pos += 4

      break

    //-------------- Device info ---------------
    case ADS.ADS_COMMAND.ReadDeviceInfo:

      //0..3 Ads error number
      ads.errorCode = data.readUInt32LE(pos)
      pos += 4

      ads.data = {}

      if (data.byteLength <= 4) {
        break
      }

      //4 Major version
      ads.data.majorVersion = data.readUInt8(pos)
      pos += 1

      //5 Minor version
      ads.data.minorVersion = data.readUInt8(pos)
      pos += 1

      //6..7 Version build
      ads.data.versionBuild = data.readUInt16LE(pos)
      pos += 2

      //8..24 Device name
      ads.data.deviceName = _trimPlcString(iconv.decode(data.slice(pos, pos + 16), 'cp1252'))

      break

    //-------------- Device status ---------------
    case ADS.ADS_COMMAND.ReadState:

      //0..3 Ads error number
      ads.errorCode = data.readUInt32LE(pos)
      pos += 4

      ads.data = {}

      if (data.byteLength <= 4) {
        break
      }

      //4..5 ADS state
      ads.data.adsState = data.readUInt16LE(pos)
      ads.data.adsStateStr = ADS.ADS_STATE.toString(ads.data.adsState)
      pos += 2

      //6..7 Device state
      ads.data.deviceState = data.readUInt16LE(pos)
      pos += 2

      break

    //-------------- Add notification ---------------
    case ADS.ADS_COMMAND.AddNotification:

      //0..3 Ads error number
      ads.errorCode = data.readUInt32LE(pos)
      pos += 4

      ads.data = {}

      if (data.byteLength <= 4) {
        break
      }

      //4..7 Notification handle
      ads.data.notificationHandle = data.readUInt32LE(pos)
      pos += 4

      break

    //-------------- Delete notification ---------------
    case ADS.ADS_COMMAND.DeleteNotification:

      //0..3 Ads error number
      ads.errorCode = data.readUInt32LE(pos)
      pos += 4

      break

    //-------------- Notification ---------------
    case ADS.ADS_COMMAND.Notification:

      ads.data = _parseAdsNotification.call(this, data)

      break

    //-------------- WriteControl ---------------
    case ADS.ADS_COMMAND.WriteControl:

      //0..3 Ads error number
      ads.errorCode = data.readUInt32LE(pos)
      pos += 4

      break

    default:
      //Unknown command, return a custom error
      debug(`_parseAdsResponse: Unknown ads command in response: ${packet.ams.adsCommand}`)

      ads.error = true
      ads.errorStr = `Unknown ADS command for parser: ${packet.ams.adsCommand} (${packet.ams.adsCommandStr})`
      ads.errorCode = -1

      return ads
      break
  }


  //Ads error code, if exists
  ads.error = (ads.errorCode !== null ? ads.errorCode > 0 : false)

  if (ads.error) {
    ads.errorStr = ADS.ADS_ERROR[ads.errorCode]
  }

  debugD(`_parseAdsData(): ADS data parsed: %o`, ads)

  return ads
}












/**
 * Handles the parsed AMS/TCP packet and actions/callbacks etc. related to it.
 * 
 * @param {object} packet Fully parsed AMS/TCP packet, includes AMS/TCP header and if available, also AMS header and ADS data
 *  * 
 * @memberof _LibraryInternals
 */
async function _onAmsTcpPacketReceived(packet) {
  debugD(`_onAmsTcpPacketReceived(): A parsed AMS packet received with command ${packet.amsTcp.command}`)

  switch (packet.amsTcp.command) {
    //-------------- ADS command ---------------
    case ADS.AMS_HEADER_FLAG.AMS_TCP_PORT_AMS_CMD:
      packet.amsTcp.commandStr = 'Ads command'

      _onAdsCommandReceived.call(this, packet)
      break


    //-------------- AMS/TCP port unregister ---------------
    case ADS.AMS_HEADER_FLAG.AMS_TCP_PORT_CLOSE:
      packet.amsTcp.commandStr = 'Port unregister'
      //TODO: No action at the moment
      break




    //-------------- AMS/TCP port register ---------------
    case ADS.AMS_HEADER_FLAG.AMS_TCP_PORT_CONNECT:
      packet.amsTcp.commandStr = 'Port register'

      //Parse data
      packet.amsTcp.data = {
        //0..5 Own AmsNetId
        localAmsNetId: _byteArrayToAmsNetIdStr(packet.amsTcp.data.slice(0, ADS.AMS_NET_ID_LENGTH)),
        //5..6 Own assigned ADS port
        localAdsPort: packet.amsTcp.data.readUInt16LE(ADS.AMS_NET_ID_LENGTH)
      }

      if (this._internals.amsTcpCallback !== null) {
        this._internals.amsTcpCallback(packet)
      } else {
        debugD(`_onAmsTcpPacketReceived(): Port register response received but no callback was assigned (${packet.amsTcp.commandStr})`)
      }
      break



    //-------------- AMS router note ---------------
    case ADS.AMS_HEADER_FLAG.AMS_TCP_PORT_ROUTER_NOTE:
      packet.amsTcp.commandStr = 'Port router note'

      //Parse data
      packet.amsTcp.data = {
        //0..3 Router state
        routerState: packet.amsTcp.data.readUInt32LE(0)
      }

      _onRouterStateChanged.call(this, packet)

      break






    //-------------- Get local ams net id response ---------------
    case ADS.AMS_HEADER_FLAG.GET_LOCAL_NETID:
      packet.amsTcp.commandStr = 'Get local net id'
      //TODO: No action at the moment
      break





    default:
      packet.amsTcp.commandStr = `Unknown AMS/TCP command ${packet.amsTcp.command}`
      debug(`_onAmsTcpPacketReceived(): Unknown AMS/TCP command received: "${packet.amsTcp.command}" - Doing nothing`)
      //TODO: No action at the moment
      break
  }
}












/**
 * Handles incoming ADS commands
 * 
 * @param {object} packet Fully parsed AMS/TCP packet, includes AMS/TCP header, AMS header and ADS data
 *  * 
 * @memberof _LibraryInternals
 */
async function _onAdsCommandReceived(packet) {
  debugD(`_onAdsCommandReceived(): A parsed ADS command received with command ${packet.ams.adsCommand}`)

  switch (packet.ams.adsCommand) {

    //-------------- ADS notification ---------------
    case ADS.ADS_COMMAND.Notification:
      //Try to find the callback with received notification handles
      packet.ads.data.stamps.forEach(async stamp => {
        stamp.samples.forEach(async sample => {

          if (this._internals.activeSubscriptions[sample.notificationHandle]) {
            const sub = this._internals.activeSubscriptions[sample.notificationHandle]
            debug(`_onAdsCommandReceived(): Notification received for handle "${sample.notificationHandle}" (%o)`, sub.target)

            //First we parse the data from received byte buffer
            try {
              const parsedValue = await sub.dataParser(sample.data)

              parsedValue.timeStamp = stamp.timeStamp

              //Then lets call the users callback
              sub.callback && sub.callback(
                parsedValue,
                sub
              )
            } catch (err) {
              debug(`_onAdsCommandReceived(): Ads notification received but parsing Javascript object failed: %o`, err)
              this.emit('ads-client-error', new ClientException(this, `_onAdsCommandReceived`, `Ads notification received but parsing data to Javascript object failed. Subscription: ${JSON.stringify(sub)}`, err, sub))
            }

          } else {
            debugD(`_onAdsCommandReceived(): Ads notification received with unknown notificationHandle "${sample.notificationHandle}" - Doing nothing`)
            this.emit('ads-client-error', new ClientException(this, `_onAdsCommandReceived`, `Ads notification received but it has unknown notificationHandle (${sample.notificationHandle}). Use unsubscribe() to save resources.`))
          }
        })
      })
      break


    //-------------- All other ADS commands ---------------
    default:
      //Try to find the callback with received invoke id and call it
      if (this._internals.activeAdsRequests[packet.ams.invokeId]) {
        const adsRequest = this._internals.activeAdsRequests[packet.ams.invokeId]

        //Clear timeout
        clearTimeout(adsRequest.timeoutTimer)

        //Call callback
        adsRequest.callback.call(this, packet)
      }
      else {
        debugD(`_onAdsCommandReceived(): Ads command received with unknown invokeId "${packet.ams.invokeId}" - Doing nothing`)
        this.emit('ads-client-error', new ClientException(this, `_onAdsCommandReceived`, `Ads command received with unknown invokeId "${packet.ams.invokeId}".`, packet))
      }

      break
  }
}













/**
 * Parses received ADS notification data (stamps) from given (byte) Buffer
 * 
 * @param {Buffer} data Buffer that contains ADS notification stamp data
 * 
 * @returns {object} Object that contains the parsed ADS notification
 * 
 * @memberof _LibraryInternals
 */
function _parseAdsNotification(data) {
  let pos = 0
  const packet = {}

  //0..3 Data length
  packet.dataLength = data.readUInt32LE(pos)
  pos += 4

  //4..7 Stamp count
  packet.stampCount = data.readUInt32LE(pos)
  pos += 4

  packet.stamps = []

  //Parse all stamps
  for (let stamp = 0; stamp < packet.stampCount; stamp++) {
    const newStamp = {}

    //0..7 Timestamp (Converting Windows FILETIME to Date)
    newStamp.timeStamp = new Date(new long(data.readUInt32LE(pos), data.readUInt32LE(pos + 4)).div(10000).sub(11644473600000).toNumber())
    pos += 8

    //8..11 Number of samples
    newStamp.count = data.readUInt32LE(pos)
    pos += 4

    newStamp.samples = []

    //Parse all samples for this stamp
    for (let sample = 0; sample < newStamp.count; sample++) {
      const newSample = {}

      //0..3 Notification handle
      newSample.notificationHandle = data.readUInt32LE(pos)
      pos += 4

      //4..7 Data length
      newSample.dataLength = data.readUInt32LE(pos)
      pos += 4

      //8..n Data
      newSample.data = data.slice(pos, pos + newSample.dataLength)
      pos += newSample.dataLength

      newStamp.samples.push(newSample)
    }
    packet.stamps.push(newStamp)
  }

  return packet
}










/**
 * Sends an ADS command with given data to the PLC
 * 
 * @param {number} adsCommand - ADS command to send (see ADS.ADS_COMMAND)
 * @param {Buffer} adsData - Buffer object that contains the data to send
 * @param {number} [targetAdsPort] - Target ADS port (optional) - default is this.settings.targetAdsPort
 * @param {string} [targetAmsNetId] - Target AmsNetID (optional) - default is this.settings.targetAmsNetId
 * 
 * @returns {Promise<object>} Returns a promise (async function)
 * - If resolved, command was sent successfully and response was received. The received reponse is parsed and returned (object)
 * - If rejected, sending, receiving or parsing failed and error info is returned (object)
 * 
 * @memberof _LibraryInternals
 */
function _sendAdsCommand(adsCommand, adsData, targetAdsPort = null, targetAmsNetId = null) {
  return new Promise(async (resolve, reject) => {

    //Check that next free invoke ID is below 32 bit integer maximum
    if (this._internals.nextInvokeId >= ADS.ADS_INVOKE_ID_MAX_VALUE)
      this._internals.nextInvokeId = 0

    //Creating the data packet object
    const packet = {
      amsTcp: {
        command: ADS.AMS_HEADER_FLAG.AMS_TCP_PORT_AMS_CMD,
        commandStr: ADS.AMS_HEADER_FLAG.toString(ADS.AMS_HEADER_FLAG.AMS_TCP_PORT_AMS_CMD)
      },
      ams: {
        targetAmsNetId: (targetAmsNetId == null ? this.connection.targetAmsNetId : targetAmsNetId),
        targetAdsPort: (targetAdsPort == null ? this.connection.targetAdsPort : targetAdsPort),
        sourceAmsNetId: this.connection.localAmsNetId,
        sourceAdsPort: this.connection.localAdsPort,
        adsCommand: adsCommand,
        adsCommandStr: ADS.ADS_COMMAND.toString(adsCommand),
        stateFlags: ADS.ADS_STATE_FLAGS.AdsCommand,
        stateFlagsStr: '',
        dataLength: adsData.byteLength,
        errorCode: 0,
        invokeId: this._internals.nextInvokeId++
      },
      ads: {
        rawData: adsData
      }
    }
    packet.ams.stateFlagsStr = ADS.ADS_STATE_FLAGS.toString(packet.ams.stateFlags)


    debugD(`_sendAdsCommand(): Sending an ads command ${packet.ams.adsCommandStr} (${adsData.byteLength} bytes): %o`, packet)

    //Creating a full AMS/TCP request
    try {
      var request = _createAmsTcpRequest.call(this, packet)
    } catch (err) {
      return reject(new ClientException(this, '_sendAdsCommand()', err))
    }

    //Registering callback for response handling
    this._internals.activeAdsRequests[packet.ams.invokeId] = {
      callback: function (response) {
        debugD(`_sendAdsCommand(): Response received for command ${packet.ams.adsCommandStr} with invokeId ${packet.ams.invokeId}`)

        //Callback is no longer needed, delete it
        delete this._internals.activeAdsRequests[packet.ams.invokeId]

        if (response.ams.error) {
          return reject(new ClientException(this, '_sendAdsCommand()', 'Response with AMS error received', response))
        } else if (response.ads.error) {
          return reject(new ClientException(this, '_sendAdsCommand()', 'Response with ADS error received', response))
        }

        return resolve(response)
      },

      timeoutTimer: setTimeout(function (client) {
        debug(`_sendAdsCommand(): Timeout for command ${packet.ams.adsCommandStr} with invokeId ${packet.ams.invokeId} - No response`)

        //Callback is no longer needed, delete it
        delete client._internals.activeAdsRequests[packet.ams.invokeId]

        //Create a custom "ads error" so that the info is passed onwards
        const adsError = {
          ads: {
            error: true,
            errorCode: -1,
            errorStr: `Timeout - no response in ${client.settings.timeoutDelay} ms`
          }
        }
        return reject(new ClientException(this, '_sendAdsCommand()', `Timeout - no response in ${client.settings.timeoutDelay} ms`, adsError))

      }, this.settings.timeoutDelay, this)
    }

    //Write the data 
    try {
      _socketWrite.call(this, request)
    } catch (err) {
      return reject(new ClientException(this, '_sendAdsCommand()', `Error - Socket is not available`, err))
    }
  })
}








/**
 * Creates an AMS/TCP request from given packet
 * 
 * @param {object} packet Object containing the full AMS/TCP packet
 * 
 * @returns {Buffer} Full created AMS/TCP request as a (byte) Buffer
 * 
 * @memberof _LibraryInternals
 */
function _createAmsTcpRequest(packet) {
  //1. Create ADS data
  const adsData = packet.ads.rawData

  //2. Create AMS header
  const amsHeader = _createAmsHeader.call(this, packet)

  //3. Create AMS/TCP header
  const amsTcpHeader = _createAmsTcpHeader.call(this, packet, amsHeader)

  //4. Create full AMS/TCP packet
  const amsTcpRequest = Buffer.concat([amsTcpHeader, amsHeader, adsData])

  debugD(`_createAmsTcpRequest(): AMS/TCP request created (${amsTcpRequest.byteLength} bytes)`)

  return amsTcpRequest
}










/**
 * Creates an AMS header from given packet
 * 
 * @param {object} packet Object containing the full AMS/TCP packet
 * 
 * @returns {Buffer} Created AMS header as a (byte) Buffer
 * 
 * @memberof _LibraryInternals
 */
function _createAmsHeader(packet) {
  //Allocating bytes for AMS header
  const header = Buffer.alloc(ADS.AMS_HEADER_LENGTH)
  let pos = 0

  //0..5 Target AMSNetId
  Buffer.from(_amsNetIdStrToByteArray(packet.ams.targetAmsNetId)).copy(header, 0)
  pos += ADS.AMS_NET_ID_LENGTH

  //6..8 Target ads port
  header.writeUInt16LE(packet.ams.targetAdsPort, pos)
  pos += 2

  //8..13 Source ads port
  Buffer.from(_amsNetIdStrToByteArray(packet.ams.sourceAmsNetId)).copy(header, pos)
  pos += ADS.AMS_NET_ID_LENGTH

  //14..15 Source ads port
  header.writeUInt16LE(packet.ams.sourceAdsPort, pos)
  pos += 2

  //16..17 ADS command
  header.writeUInt16LE(packet.ams.adsCommand, pos)
  pos += 2

  //18..19 State flags
  header.writeUInt16LE(packet.ams.stateFlags, pos)
  pos += 2

  //20..23 Data length
  header.writeUInt32LE(packet.ams.dataLength, pos)
  pos += 4

  //24..27 Error code
  header.writeUInt32LE(packet.ams.errorCode, pos)
  pos += 4

  //28..31 Invoke ID
  header.writeUInt32LE(packet.ams.invokeId, pos)
  pos += 4

  debugD(`_createAmsHeader(): AMS header created (${header.byteLength} bytes)`)

  if (debugIO.enabled) {
    debugIO(`_createAmsHeader(): AMS header created: %o`, header.toString('hex'))
  }

  return header
}









/**
 * Creates an AMS/TCP header from given packet and AMS header
 * 
 * @param {object} packet Object containing the full AMS/TCP packet
 * @param {Buffer} amsHeader Buffer containing the previously created AMS header
 * 
 * @returns {Buffer} Created AMS/TCP header as a (byte) Buffer
 * 
 * @memberof _LibraryInternals
 */
function _createAmsTcpHeader(packet, amsHeader) {
  //Allocating bytes for AMS/TCP header
  const header = Buffer.alloc(ADS.AMS_TCP_HEADER_LENGTH)
  let pos = 0

  //0..1 AMS command (header flag)
  header.writeUInt16LE(packet.amsTcp.command, pos)
  pos += 2

  //2..5 Data length
  header.writeUInt32LE(amsHeader.byteLength + packet.ams.dataLength, pos)
  pos += 4

  debugD(`_createAmsTcpHeader(): AMS/TCP header created (${header.byteLength} bytes)`)

  if (debugIO.enabled) {
    debugIO(`_createAmsTcpHeader(): AMS/TCP header created: %o`, header.toString('hex'))
  }

  return header
}







/**
 * Writes given message to console if settings.hideConsoleWarnings is false
 * 
 * @param {string} str Message to console.log() 
 * 
 * @memberof _LibraryInternals
 */
function _console(str) {
  if (this.settings.hideConsoleWarnings !== true)
    console.log(`${PACKAGE_NAME}: ${str}`)
}













/**
 * **Helper:** Marges objects together recursively. Used for example at writeSymbol() to combine active values and new (uncomplete) object values
 * 
 * Based on https://stackoverflow.com/a/34749873/8140625 by Salakar and https://stackoverflow.com/a/49727784/8140625
 * 
 * Later modified to work with in both case-sensitive and case-insensitive ways (as the PLC is case-insensitive too). 
 * Also fixed isObjectOrArray to return true only when input is object literal {} or array (https://stackoverflow.com/a/16608074/8140625)
 * 
 * @param {boolean} isCaseSensitive True = Object keys are merged case-sensitively --> target['key'] !== target['KEY'], false = case-insensitively
 * @param {object} target Target object to copy data to
 * @param {...object} sources Source objects to copy data from
 * 
 * @returns {object} Merged object
 * 
 * @memberof _LibraryInternals
 */

function _deepMergeObjects(isCaseSensitive, target, ...sources) {
  if (!sources.length) return target

  const isObjectOrArray = (item) => {
    return (!!item) && ((item.constructor === Object) || Array.isArray(item))
  }

  //Checks if object key exists
  const keyExists = (obj, key, isCaseSensitive) => {
    if (isCaseSensitive === false) {
      return !!Object.keys(obj).find(objKey => objKey.toLowerCase() === key.toLowerCase())
    } else {
      return (obj[key] != null)
    }
  }

  //Returns object value by key
  const getValue = (obj, key, isCaseSensitive) => {
    if (isCaseSensitive === false) {
      return obj[Object.keys(obj).find(objKey => objKey.toLowerCase() === key.toLowerCase())]
    } else {
      return obj[key]
    }
  }

  //Sets object value obj[key] to value - If isCaseSensitive == false, obj[KeY] == obj[key]
  const setValue = (obj, key, value, isCaseSensitive) => {
    if (isCaseSensitive === false) {
      Object.keys(obj).forEach(objKey => {
        if (objKey.toLowerCase() === key.toLowerCase()) {
          obj[objKey] = value
        }
      });
    } else {
      //Case-sensitive is easy
      obj[key] = value
    }
  }

  const source = sources.shift()

  if (isObjectOrArray(target) && isObjectOrArray(source)) {
    for (const key in source) {

      if (isObjectOrArray(source[key])) {
        //If source is object
        if (keyExists(target, key, isCaseSensitive)) {
          //Target has this key, copy value
          setValue(target, key, Object.assign({}, target[key]), isCaseSensitive)
        } else {
          //Target doesn't have this key, add it)
          Object.assign(target, { [key]: {} })
        }
        //As this is an object, go through it recursively
        _deepMergeObjects(isCaseSensitive, getValue(target, key, false), source[key])

      } else {
        //Source is not object

        if (keyExists(target, key, isCaseSensitive)) {
          //Target has this key, copy value
          setValue(target, key, source[key], isCaseSensitive)
        } else {
          //Target doesn't have this key, add it
          Object.assign(target, { [key]: source[key] })
        }
      }
    }
  }

  return _deepMergeObjects(isCaseSensitive, target, ...sources)
}







/**
 * **Helper:** Trims the given PLC string until en mark (\0, 0 byte) is found
 * 
 * @param {string} plcString String to trim
 * 
 * @returns {string} Trimmed string
 * 
 * @memberof _LibraryInternals
 */
function _trimPlcString(plcString) {
  let parsedStr = ''

  for (let i = 0; i < plcString.length; i++) {
    if (plcString.charCodeAt(i) === 0) break

    parsedStr += plcString[i]
  }

  return parsedStr
}






/**
 * **Helper:** Converts byte array (Buffer) to AmsNetId string
 * 
 * @param {Buffer|array} byteArray Buffer/array that contains AmsNetId bytes
 * 
 * @returns {string} AmsNetId as string
 * 
 * @memberof _LibraryInternals
 */
function _byteArrayToAmsNetIdStr(byteArray) {
  return byteArray.join('.')
}




/**
 * **Helper:** Converts AmsNetId string to byte array
 * 
 * @param {string} byteArray String that represents an AmsNetId
 * 
 * @returns {array} AmsNetId as array
 * 
 * @memberof _LibraryInternals
 */
function _amsNetIdStrToByteArray(str) {
  return str.split('.').map(x => parseInt(x))
}





exports.ADS = ADS
exports.Client = Client