Source: modules/discovery.js

const Util = require('../utils/util')
const Soap = require('../utils/soap')
const dgram = require('dgram')

/**
 * @class
 * ONVIF devices support WS-Discovery, which is a mechanism that supports probing a network to
 * find ONVIF capable devices. For example, it enables devices to send Hello messages when
 * they come online to let other devices know they are there. In addition, clients can send Probe
 * messages to find other devices and services on the network. Devices can also send Bye
 * messages to indicate they are leaving the network and going offline.<br>
 * Messages are sent over UDP to a standardized multicast address and UDP port number. All the
 * devices that match the types and scopes specified in the Probe message respond by sending
 * ProbeMatch messages back to the sender.<br>
 * WS-Discovery is normally limited by the network segmentation at a site since the multicast
 * packages typically do not traverse routers. Using a Discovery Proxy could solve that problem, but
 * details about this topic are beyond the scope of this document. For more information, see
 * [ONVIF/Discovery] and [WS-Discovery].
 */
class Discovery {
  constructor () {
    this.soap = new Soap()
    this._MULTICAST_ADDRESS = '239.255.255.250'
    this._PORT = 3702
    this._DISCOVERY_INTERVAL = 150 // ms
    this._DISCOVERY_RETRY_MAX = 3
    this._DISCOVERY_WAIT = 3000 // ms
    this._udp = null
    this._discoveryIntervalTimer = null
    this._discoveryWaitTimer = null
  }

  /**
   * Start a <strong>Discovery</strong> probe.
   * @param {callback=} callback Optional callback, instead of a Promise.
   * @example
   * const OnvifManager = require('onvif-nvt')
   * OnvifManager.add('discovery')
   * OnvifManager.discovery.startProbe().then(deviceList => {
    * console.log(deviceList)
    * // 'deviceList' contains all ONVIF devices that have responded.
    * // If it is empty, then no ONVIF devices
    * // responded back to the broadcast.
   * })
   */
  startProbe (callback) {
    const promise = new Promise((resolve, reject) => {
      let errMsg = ''
      if (typeof callback !== 'undefined' && callback !== null) {
        if ((errMsg = Util.isInvalidValue(callback, 'function'))) {
          reject(new Error('The "callback" argument for startProbe is invalid:' + errMsg))
          return
        }
      }
      this._devices = {}
      this._udp = dgram.createSocket('udp4')

      this._udp.once('error', (error) => {
        reject(error)
      })

      this._udp.on('message', (buf, deviceInfo) => {
        this.soap.parse(buf.toString())
          .then(results => {
            this.parseResult(results, deviceInfo)
          })
          .catch(error => {
          // Do nothing.
            console.error(error)
          })
      })

      this._udp.bind(() => {
        this._udp.removeAllListeners('error')

        this._sendProbe()
          .then(() => {
            // Do nothing.
          })
          .catch(error => {
            reject(error)
          })

        this._discoveryWaitTimer = setTimeout(() => {
          this.stopProbe()
            .then(() => {
              const deviceList = []
              Object.keys(this._devices).forEach((urn) => {
                deviceList.push(this._devices[urn])
              })
              resolve(deviceList)
            })
            .catch(error => {
              reject(error)
            })
        }, this._DISCOVERY_WAIT)
      })
    })

    if (Util.isValidCallback(callback)) {
      promise.then((deviceList) => {
        callback(null, deviceList)
      }).catch(error => {
        callback(error)
      })
    }
    else {
      return promise
    }
  }

  /**
   * Stop a <strong>Discovery</strong> probe.
   * @param {callback=} callback Optional callback, instead of a Promise.
   */
  stopProbe (callback) {
    if (this._discoveryIntervalTimer !== null) {
      clearTimeout(this._discoveryIntervalTimer)
      this._discoveryIntervalTimer = null
    }
    if (this._discoveryWaitTimer !== null) {
      clearTimeout(this._discoveryWaitTimer)
      this._discoveryWaitTimer = null
    }

    const promise = new Promise((resolve, reject) => {
      let errMsg = ''
      if (typeof callback !== 'undefined' && callback !== null) {
        if ((errMsg = Util.isInvalidValue(callback, 'function'))) {
          reject(new Error('The "callback" argument for stopProbe is invalid:' + errMsg))
          return
        }
      }
      if (this._udp) {
        this._udp.close(() => {
          if (this._udp) {
            this._udp.unref()
          }
          this._udp = null
          resolve()
        })
      }
      else {
        resolve()
      }
    })

    if (Util.isValidCallback(callback)) {
      promise.then(() => {
        callback(null)
      }).catch(error => {
        callback(error)
      })
    }
    else {
      return promise
    }
  }

  _sendProbe (callback) {
    let soapTemplate = ''
    soapTemplate += '<?xml version="1.0" encoding="UTF-8"?>'
    soapTemplate += '<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing">'
    soapTemplate += '  <s:Header>'
    soapTemplate += '    <a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>'
    soapTemplate += '    <a:MessageID>uuid:__uuid__</a:MessageID>'
    soapTemplate += '    <a:ReplyTo>'
    soapTemplate += '      <a:Address>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</a:Address>'
    soapTemplate += '    </a:ReplyTo>'
    soapTemplate += '    <a:To s:mustUnderstand="1">urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>'
    soapTemplate += '  </s:Header>'
    soapTemplate += '  <s:Body>'
    soapTemplate += '    <Probe xmlns="http://schemas.xmlsoap.org/ws/2005/04/discovery">'
    soapTemplate += '      <d:Types xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:dp0="http://www.onvif.org/ver10/network/wsdl">dp0:__type__</d:Types>'
    soapTemplate += '    </Probe>'
    soapTemplate += '  </s:Body>'
    soapTemplate += '</s:Envelope>'
    /* eslint-disable no-useless-escape */
    soapTemplate = soapTemplate.replace(/\>\s+\</g, '><')
    soapTemplate = soapTemplate.replace(/\s+/, ' ')
    /* eslint-enable no-useless-escape */

    const soapSet = [];
    ['NetworkVideoTransmitter', 'Device', 'NetworkVideoDisplay'].forEach((type) => {
      let s = soapTemplate
      s = s.replace('__type__', type)
      s = s.replace('__uuid__', Util.createUuidV4())
      soapSet.push(s)
    })

    const soapList = []
    for (let i = 0; i < this._DISCOVERY_RETRY_MAX; i++) {
      soapSet.forEach((s) => {
        soapList.push(s)
      })
    }

    const promise = new Promise((resolve, reject) => {
      const send = () => {
        if (this._udp) {
          const soapEnvelope = soapList.shift()
          if (soapEnvelope) {
            const buf = Buffer.from(soapEnvelope, 'utf8')
            this._udp.send(buf, 0, buf.length, this._PORT, this._MULTICAST_ADDRESS, (error, bytes) => {
              if (error) {
                console.error(error) // TODO: Jeff temp
              }
              this._discoveryIntervalTimer = setTimeout(() => {
                send()
              }, this._DISCOVERY_INTERVAL)
            })
          }
          else {
            resolve()
          }
        }
        else {
          reject(new Error('No UDP connection is available. The init() method might not be called yet.'))
        }
      }
      send()
    })
    return promise
  }

  parseResult (results, deviceInfo) {
    const parsed = results.parsed
    let urn = ''
    const address = deviceInfo.address
    let service = ''
    let xaddrs = []
    let scopes = []
    let types = ''
    let probe = {}

    try {
      if ('Body' in parsed) {
        const body = parsed.Body
        if ('ProbeMatches' in body) {
          const probeMatches = body.ProbeMatches

          // make sure the right data exists
          if (probeMatches !== undefined) {
            if ('ProbeMatch' in probeMatches) {
              const probeMatch = probeMatches.ProbeMatch
              urn = probeMatch.EndpointReference.Address
              xaddrs = probeMatch.XAddrs.split(/\s+/)
              // pick the appropriate service address if there is more than one
              if (xaddrs.length > 1) {
                xaddrs.forEach(addr => {
                  const index = addr.indexOf(deviceInfo.address)
                  if (index !== -1) {
                    service = addr
                  }
                })
              }
              else {
                service = xaddrs[0]
              }
              if (typeof (probeMatch.Scopes) === 'string') {
                scopes = probeMatch.Scopes.split(/\s+/)
              }
              else if (typeof (probeMatch.Scopes) === 'object' && typeof (probeMatch.Scopes._) === 'string') {
                scopes = probeMatch.Scopes._.split(/\s+/)
              }
              if (typeof (probeMatch.Types) === 'string') {
                types = probeMatch.Types.split(/\s+/)
              }
              // added to support Pelco cameras (comes in as an object, not string)
              else if (typeof (probeMatch.Types) === 'object' && typeof (probeMatch.Types._) === 'string') {
                types = probeMatch.Types._.split(/\s+/)
              }
            }
          }
        }
      }
    }
    catch (e) {
      return null
    }

    if (urn && xaddrs.length > 0 && scopes.length > 0) {
      if (!this._devices[urn]) {
        let name = ''
        let hardware = ''
        let location = ''
        scopes.forEach((s) => {
          if (s.indexOf('onvif://www.onvif.org/hardware/') === 0) {
            hardware = s.split('/').pop()
          }
          else if (s.indexOf('onvif://www.onvif.org/location/') === 0) {
            location = s.split('/').pop()
          }
          else if (s.indexOf('onvif://www.onvif.org/name/') === 0) {
            name = s.split('/').pop()
            name = name.replace(/_/g, ' ')
          }
        })
        probe = {
          urn: urn,
          name: name,
          address: address,
          service: service,
          hardware: hardware,
          location: location,
          types: types,
          xaddrs: xaddrs,
          scopes: scopes
        }
        this._devices[urn] = probe
      }
    }
    return probe
  }
}

module.exports = Discovery