Ghi hình chuyển động #4: Onvif

Chúng ta sẽ xây dựng 3 module:

  • fRec: ghi video từ rtsp qua ứng dụng ffmpeg
  • pMotionRecorder: lấy event từ Onvif camera qua phương thức pull
  • sMotionRecorder: lấy event từ Onvif camera qua dữ liệu web

Để đơn giản, các module pMotionRecorder sMotionRecorder được tối ưu cho dòng camera IMOU, tuy không phải model nào cũng tương thích.

  • Script ghi hình imou.js như sau:
    Input: username, password hostname/ip của camera (version 10220215)
const motionRecorder = require('./lib/pMotionRecorder.js')

const info = {
//user, password của camera
  camUser: 'admin',
  camPass: '<password>',
//ip/hostname của camera
  hostname: '<cam_ip>',

//--------- OPTIONS ---------//
//kiểu file video, mặc định mkv
//  outType: 'mp4',
//thư mục chứa video, mặc định /camera
  outFolder: '/mnt/imou',
//độ dài tối đa của clip (giây), mặc định 15
//  clipInterval: 10,
//chụp ảnh, mặc định false
  snapshot: true,
//in ra màn hình debug/normal(default)/quiet
  logLevel: 'debug'
}
new motionRecorder (info)

Thay pMotionRecorder bằng sMotionRecorder nếu dùng module web server

  • Gọi chạy từ crontab, @reboot xem chú thích
0 0 * * * /usr/local/bin/node /path/to/imou.js > ~/imou.log

Script fRec

//Record video from Onvif camera
//fRec.js - version 20220215"
//LNT <lnt@lyle.info>

const childProcess = require('child_process')
const fs = require('fs')

class ffRecorder {
  constructor (options) {
    this.opts = options
    if (!fs.existsSync(this.opts.outFolder))
      fs.mkdirSync(this.opts.outFolder, { recursive: true });
  }

  mRecord() {
    let clip = [
      '-hide_banner',
      '-rtsp_transport', 'tcp',
      '-i', this.opts.url,
      '-c', 'copy',
      '-f', 'segment',
      '-segment_time', this.opts.clipInterval,
      '-strftime', 1,
      this.opts.outFolder + '/' + '%Y%m%d-%H%M%S.' + this.opts.outType
    ]
    let pict = [
      '-r', 1/this.opts.clipInterval,
      '-strftime', 1,
      this.opts.outFolder + '/' + '%Y%m%d-%H%M%S.jpg'
    ]
    let args = (this.opts.snapshot) ? clip.concat(pict) : clip
    return childProcess.spawn('ffmpeg', args, {detached: false, stdio: 'ignore'})
  }

  startRecording() {
    if (!this.wStream) {
      this.wStream = this.mRecord()
      if (this.opts.logLevel != 'quiet')
        console.log(new Date().toLocaleTimeString(), 'Start recording...')
    }
  }

  stopRecording() {
    if (this.wStream) {
      this.wStream.kill()
      this.wStream = null
      if (this.opts.logLevel != 'quiet')
        console.log((new Date()).toLocaleTimeString(), 'Stop recording!')
    }
  }
}

module.exports = ffRecorder

Script pMotionRecorder

//Record video from Onvif camera
//pMotionRecorder.js - version 20220215"
//LNT <lnt@lyle.info>

const fRec = require('./fRec.js')
    , Cam  = require('onvif').Cam
    , flow = require('nimble')

class pMotionRecorder {
  constructor(options){
    let opts = options
    if (!opts.logLevel) opts.logLevel = 'normal'
    if (!opts.outType) opts.outType = 'mkv'
    if (!opts.outFolder) opts.outFolder = '/camera'
    if (!opts.snapshot) opts.snapshot = false
    if (isNaN(opts.clipInterval)) opts.clipInterval = 15
    this.rec = new fRec({
      outFolder: opts.outFolder,
      outType: opts.outType,
      snapshot: opts.snapshot,
      clipInterval: opts.clipInterval,
      logLevel: opts.logLevel
    })
    let oCam = new Cam (
      {
        hostname: opts.hostname,
        username: opts.camUser,
        password: opts.camPass
      },
      (err) => {
        if (err) { console.log(err); return }
        const logLevel = opts.logLevel
        if (logLevel != 'quiet') console.log('Connected to ONVIF Device')
        flow.series([
          (callback) => {
            oCam.getDeviceInformation((err, info, xml) => {
              if (logLevel == 'debug') console.log(info)
              if (logLevel != 'silent') console.log('[', (new Date()).toLocaleDateString(), ']')
              callback();
            })
          },
          (callback) => {
            oCam.getStreamUri((err, data, xml) => {
              if (err) { console.log(err); return }
              this.rec.opts.url = (data.uri).replace(oCam.hostname, oCam.username + ':' + oCam.password + '@' + oCam.hostname)
              callback();
            })
          },
          (callback) => {
            oCam.on('event', (camMsg, xml) => {
              let eTopic = camMsg.topic._.split(':').pop(),
                  eOperation = camMsg.message.message.$.PropertyOperation,
                  eValue = camMsg.message.message.data.simpleItem.$.Value
              this.processEvent(eTopic, eOperation, eValue, logLevel)
              callback()
            })
          }
        ])
      }
    )
  }
  processEvent (event, operation, value, logLevel) {
    if (logLevel == 'debug')
      console.log((new Date()).toLocaleTimeString(), event, '>>' , operation, '>>', value)
    if (event == 'VideoSource/MotionAlarm' && operation == 'Changed') {
      if (value) this.rec.startRecording(); else this.rec.stopRecording()
    }
  }
}

module.exports = pMotionRecorder

Script sMotionRecorder

Script này dùng module onvif mới nhất từ github, bản cũ hơn sẽ báo lỗi phương thức subscribe

//Record video from Onvif camera
//sMotionRecorder.js - version 20220215"
//LNT <lnt@lyle.info>

const fRec = require('./fRec.js')
    , Cam  = require('onvif').Cam
    , flow = require('nimble')
    , http = require('http')
    , { execSync } = require('child_process')
    , ip = require('ip')

class sMotionRecorder {
  constructor (options) {
    let opts = options
    if (!opts.logLevel) opts.logLevel = 'normal'
    if (!opts.outType) opts.outType = 'mkv'
    if (!opts.outFolder) opts.outFolder = '/camera'
    if (!opts.snapshot) opts.snapshot = false
    if (isNaN(opts.clipInterval)) opts.clipInterval = 15
    const serverPort = 8899
    const logLevel = opts.logLevel
    this.rec = new fRec ({
      outFolder: opts.outFolder,
      outType: opts.outType,
      snapshot:opts.snapshot,
      clipInterval: opts.clipInterval,
      logLevel: logLevel
    })
    this.processEvent = (event, operation, value) => {
      if (logLevel == 'debug')
        console.log((new Date()).toLocaleTimeString(), event, '>>' , operation, '>>', value)
      if (event == 'VideoSource/MotionAlarm' && operation === 'Changed'){
        if (value) this.rec.startRecording()
        else this.rec.stopRecording()
      }
    }
    let oCam = new Cam ({
      hostname: opts.hostname,
      port: 80,
      username: opts.camUser,
      password: opts.camPass,
      timeout: 10000,
      preserveAddress: true
    },
    (err) => {
      if (err) { console.log(err); return; }
      if (logLevel != 'quiet') console.log('Connected to ONVIF Device')
      flow.series([
        (callback) => {
          oCam.getDeviceInformation((err, info, xml) => {
            if (logLevel == 'debug') console.log(info)
            if (logLevel != 'quiet') console.log('[', (new Date()).toLocaleDateString(), ']')
          })
          callback()
        },
        (callback) => {
          let camId = Math.random().toString(36).substring(2, 10)
          let receiveUrl = 'http://' + ip.address() + ':' + serverPort + '/events/' + camId
          oCam.subscribe (
            {url: receiveUrl},
            (err, subscription, xml) => {
               if (err) { console.log(err); return }
            }
          )
          if (logLevel == 'debug') console.log('Subscribed to ' + receiveUrl)
          callback()
        },
        (callback) => {
          oCam.getStreamUri((err, data, xml) => {
            if (err) { console.log(err); return }
            this.rec.opts.url = (data.uri).replace(oCam.hostname, oCam.username + ':' + oCam.password + '@' + oCam.hostname)
          })
          callback();
        }
      ])
    })
    http.createServer((req, res) => {
      let body = ''
      req.on('data', chunk => { body += chunk })
      req.on('end', () => {
        if (req.method == "POST") {
          let eTopic = body.match(/tns1:([^<]+)/)[1],
              eOperation = body.match(/PropertyOperation="([^"]+)/)[1],
              eValue = body.match(/Name="State" Value="([^"]+)/)[1]
          this.processEvent(eTopic, eOperation, eValue === 'true')
        }
        res.writeHead(200, { "Content-Type": "text/plain" });
      })
    }).listen(serverPort)
  }
}

module.exports = sMotionRecorder

Chú thích

  • Một ứng dụng nodejs thường dùng khá nhiều module, qua lời gọi
require('/path/to/module.js')

Ứng dụng sẽ báo lỗi nếu đường dẫn đến module không đúng.

  • Camera IMOU không có giao diện web, khi đó dùng password là mã bảo mật in dưới đáy camera
  • Để không phải sử dụng nhiều module ngoài, sMotionRecorder dùng Regular Expression để phân tích xml …
  • Ứng dụng không dùng ffmpeg để lấy snapshot mà lấy qua camera nên giảm tải cho RPi rất nhiều.
  • logLevel: ‘normal’ //mặc định
  • Việc khởi tạo event service bình thường nhanh, nhưng có lúc chậm, khi đó script không nhận được event từ camera, tuy nhiên rồi sẽ hoạt động sau vài phút nếu camera mới reset.
  • Để ứng dụng node.js chạy khi reboot, phải dùng pm2 hay forever… Các cách thông thường không tác dụng
# gọi chạy ứng dụng và ghi vào danh sách quản lý
pm2 start app_name
pm2 start --name imou /path/to/imou.js

# tự chạy các ứng dụng trong danh sách khi reboot
pm2 startup

Cài đặt

Phải cài đặt npm nodejs trước, xem tại đây. Ngoài ra cần có ffmpeg.

  • Tải về và giải nén gói onvif-motion-recorder
  • Chuyển vào thư mục onvif-motion-recorder và chạy lệnh npm i
# onvif-motion-recorder, version 20220217
wget http://pi.lyle.info/wp-content/uploads/2022/02/onvif-motion-recorder-v20220217.zip
unzip onvif-motion-recorder-v20220217.zip
cd onvif-motion-recorder
npm i

Sửa các option trong file imou.js, chủ yếu là camPass hostname, rồi chạy bằng lệnh

node imou.js
  • Với camera hiệu khác, sao chép imou.js thành tên file khác và sửa lại nội dung

Comments Off on Ghi hình chuyển động #4: Onvif

Filed under Software

Comments are closed.