# ip-cam record debug [toc] ## 已知問題 1. 向 AXIS camera 請求錄影清單時被 CORS 規範阻擋 ```info! 47e1d370-d6ed-11ef-8226-77f21a8cf4ae:1 Access to XMLHttpRequest at 'http://192.168.1.57/axis-cgi/record/list.cgi?starttime=2025-04-22T16:00:00.000Z&stoptime=2025-04-23T16:00:00.000Z' from origin 'http://192.168.1.223:8080' has been blocked by CORS policy: Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response. ``` 2. 驗證失敗 ![image](https://hackmd.io/_uploads/ByHOPlPxll.png) ## 原因推測 1. AXIS camera 缺少如 `Access-Control-Allow-Headers` `Access-Control-Allow-Origin` 等 HTTP headers 2. 因曾修改 Network / HTTP 至 Recommanded ,導致無法使用 Basic 驗證 ## 解決方法 1. [透過 `Custom HTTP header API` 設定,新增所需要 header](https://developer.axis.com/vapix/network-video/custom-http-header-api/) - 設定單一設備 - 以管理員身份登入 AXIS camera - 發送 POST 請求: `http://<camera-ip>/axis-cgi/customhttpheader.cgi` - 請求內容(JSON): ```json= { "apiVersion": "1.0", "method": "set", "params": { "Access-Control-Allow-Origin": "http://<允許請求來源>", "Access-Control-Expose-Headers": "WWW-Authenticate", "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Headers": "authorization" } } ``` - 以 curl 發送請求 (根據當前認證狀態更改) ``` curl -u <username>:<password> \ -X POST "http://<camera-ip>/axis-cgi/customhttpheader.cgi" \ -H "Content-Type: application/json" \ -d '{ "apiVersion": "1.0", "method": "set", "params": { "Access-Control-Allow-Origin": "http://192.168.1.223:8080", "Access-Control-Expose-Headers": "WWW-Authenticate", "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Headers": "authorization" } }' ``` - 設定大量設備 (未驗證) - API 腳本 - Thingsboard rule chain - HAProxy - AXIS Device Manager 2. - [將 AXIS camera 憑證設定更改至 Basic](https://hackmd.io/GFaUtmecQIGMekFN_qZlQQ#Authentication-Policy) ![image](https://hackmd.io/_uploads/rJqKxbDexe.png) - 更改認證方式為 Digest 認證 - 需添加 `"Access-Control-Expose-Headers": "WWW-Authenticate"` 於 Custom API - 將第一次 request 的 response 傳入 `getDigestAuth` function ,作為第二次 request 的 `authorization` ## Origin HTML code :::spoiler ```html= <!DOCTYPE HTML> <div class="container"> <div class="fixed-box"></div> <div class="camera-bar"> <div class="grid-wrap"> <span class="material-icons"> videocam </span> <select name="grid" id="camera-select" [(ngModel)]="deviceSelected" (change)="handleChange()"> <option [value]="item.ip" *ngFor="let item of objArr;"> {{ item.name }}&nbsp;&nbsp;{{ item.label }} </option> </select> </div> <div class="grid-wrap"> <input type="date" id="date-input" name="date" [(ngModel)]="timePick" (change)="handleChange()" /> </div> <div class="time-form"> <div class="form-item time-picker" [style.display]="isDisplay"> <div class="time-container"> <div class="input-group clockpicker"> <div class="icon-box"> Start <span class="material-icons time-icon"> schedule </span> </div> <input type="text" class="time-input clock-input grid-wrap" id="startClock"> </div> </div> </div> <div class="form-item time-picker" [style.display]="isDisplay"> <div class="time-container"> <div class="input-group clockpicker"> <div class="icon-box"> End <span class="material-icons time-icon"> schedule </span> </div> <input type="text" class="time-input clock-input grid-wrap" id="endClock"> </div> </div> </div> </div> </div> <div class="empty-box" *ngIf="!isRecordExist"> <div class="txt-c" style="color: gray;">無攝影紀錄</div> <div class="txt-c" *ngIf=" timePick === '' "> 請使用篩選器選擇&nbsp;"攝影機"&nbsp;與&nbsp;"日期"&nbsp;查看錄影紀錄 </div> </div> <div class="camera-container" *ngIf="isRecordExist"> <div class="video-wrap" *ngFor="let item of recordingArr;" [id]=item.id> <div class="video-box" (click)="expandView(item)"> <img class="cam-img" [src]="imgSrcObj[deviceSelected]" alt="recBgImg.png" /> <div class="video-info"> <div class="info-txt" style="font-weight: 500; font-size: 14px;"> {{ item.date }}</div> <div class="info-txt" style="font-weight: 500; font-size: 14px;"> {{ showTimePeriod(item.startTime, item.endTime) }} </div> <!--<div style="font-size: 14px;">mimetype:&nbsp;{{ item.mimetype }}</div>--> </div> </div> </div> </div> </div> ``` ::: ## Origin JS code :::spoiler ```javascript= // let base = `http://10.1.1.53`; // let host = `10.1.1.53`; let count = 0; const publicKey = 'swae'; const privateKey = '5Giotlead'; let encoding = 'x-h264'; let column = 3; const imgSrcObj = { '10.1.1.51': '/api/images/public/jF2DhlzLT1T9EdsIpuXZp11Bp812OtuM', '10.1.1.52': '/api/images/public/lkkl8t3Wv5Tt5yMmeOAAZZ0J8JmAaSDO', '10.1.1.53': '/api/images/public/PBXpBQhZzXXiTAEgC80THrR1QoCFh6KO', '10.1.1.54': '/api/images/public/p62WYcPMpFQqNriOLdKccixyBxH8kgzv', '10.1.1.55': '/api/images/public/OCLI6uXjxDBJDd7tzC1YiCVnC35YrhiK' }; self.onInit = function() { self.ctx.$scope.isRecordExist = false; self.ctx.$scope.timePick = ''; self.ctx.$scope.deviceSelected = ''; self.ctx.$scope.objArr = []; self.ctx.$scope.recordingArr = []; self.ctx.$scope.recordingId = ''; self.ctx.$scope.htmlTemplate = 'Hello World'; self.ctx.$scope.handleChange = handleChange; self.ctx.$scope.handleClockChange = handleClockChange; self.ctx.$scope.showTimePeriod = showTimePeriod; self.ctx.$scope.expandView = expandView; self.ctx.$scope.playFinished = true; self.ctx.$scope.timeToStart = 0; self.ctx.$scope.imgSrcObj = imgSrcObj; //console.log(window.mediaStreamLibrary); self.ctx.$scope.pipelines = window .mediaStreamLibrary.pipelines; self.ctx.$scope.isRtcpBye = window .mediaStreamLibrary.isRtcpBye; const da = self.ctx.data; if (da.length > 0 && da[0].dataKey.type !== 'function') { self.ctx.$scope.deviceSelected = (da[0].data[ 0])[1]; } da.forEach((item, i) => { const obj = {}; obj.id = item.datasource.entityId; obj.name = item.datasource.entityName; obj.label = item.datasource.entityLabel; if (item.dataKey.type !== 'function') { obj.ip = (item.data[0])[1]; } self.ctx.$scope.objArr.push(obj); self.ctx.$scope.objArr.sort((a, b) => a .name.slice(-2) - b.name.slice(- 2)); }); // imgSrcArr.forEach((item, i) => { // const obj = {}; // obj.name = `No${i+1}`; // obj.src = item; // self.ctx.$scope.objArr.push(obj); // }); // self.ctx.$scope.emptyArr = Array(column * 3 - 5).fill() // .map((x, i) => i); //錄影紀錄設定只能選取一個月內的影像紀錄 const dateInput = document.getElementById( 'date-input'); const today = new Date(); // const yesterday = new Date(today); // yesterday.setDate(today.getDate() - 1); // const yesterdayString = yesterday.toISOString() // .split('T')[0]; const todayString = today.toISOString() .split('T')[0]; const thirtyDaysAgo = new Date(today); thirtyDaysAgo.setDate(today.getDate() - 30); const thirtyDaysAgoString = thirtyDaysAgo .toISOString().split('T')[0]; dateInput.min = thirtyDaysAgoString; dateInput.max = todayString; var clockInput = $('.clockpicker') .clockpicker({ placement: 'bottom', autoclose: true }); $('.clockpicker').clockpicker() .find('input').change(function(){ // TODO: time changed handleClockChange(); }); }; function expandView(obj) { const screenWidth = window.screen.width; const screenHeight = window.screen.height; self.ctx.$scope.htmlTemplate = ` <style> .dialog-container{ width: 100%; height: 100%; box-sizing: border-box; background: #00000099; position: relative; } .content-wrap{ width: 100%; box-sizing: border-box; background: #00000099; position: absolute; padding: 6px 12px; bottom: 0; display: flex; flex-direction: row; justify-content: space-between; color: #ffffff; } .text{ line-height: 1.5; z-index: 50; } .close-icon{ display: block; cursor: pointer; z-index: 50; } .l-video { max-width: 1440px; width: 100%; height: 100%; object-fit: contain; } button { color: #cecece; background: rgba(0, 0, 0, 0.5); border: none; margin: 1px 1px 2px; } .player { position: relative; overflow: hidden; background: #000000; } .player__video { display: block; } .player__controls { display: flex; justify-content: center; align-items: flex-end; position: absolute; bottom: 0; width: 100%; transform: translateY(100%) translateY(-5px); transition: all 0.3s; flex-wrap: wrap; background: rgba(0, 0, 0, 0.1); } .player:hover .player__controls { transform: translateY(0); bottom: 6px; } .player:hover .progress { height: 16px; } .player:hover #hover-time { visibility: visible; } .progress { flex: 10; position: relative; display: flex; flex-basis: 100%; height: 5px; transition: height 0.3s; background: rgba(0, 0, 0, 0.5); cursor: pointer; } .progress__filled { width: 50%; background: #ffc600; flex: 0; flex-basis: 0%; } #hover-time { width: 50px; height: 20px; border-radius: 4px; text-align: center; color: #ffffff; background: #0000008c; position: fixed; top: 0; left: -300px; visibility: hidden; cursor: default; } </style> <div class="dialog-container"> <div class="content-wrap"> <div class="text">${showTimePeriod(obj.startTime, obj.endTime)}</div> <span class="material-icons close-icon" (click)="close()"> tablet </span> </div> <div class="player"> <canvas class="l-video"></canvas> <video id="camera-video" autoplay class="player__video viewer l-video"> </video> <div class="player__controls"> <div class="progress"> <div class="progress__filled"></div> </div> <button class="player__button toggle" title="Toggle Play">►</button> </div> <div id="hover-time">00:00</div> </div> </div>`; self.ctx.detectChanges(); const $injector = self.ctx.$scope.$injector; const customDialog = $injector.get(self.ctx .servicesMap .get('customDialog')); //use dialog service setTimeout(function() { customDialog.customDialog(self.ctx.$scope .htmlTemplate, dialogController).subscribe(); }, 100); setTimeout(function() { const vidoe_duration = calTimePeriod(obj .startTime, obj.endTime); /*get element we need*/ const player = document.querySelector( ".player"); const video = player.querySelector( ".viewer"); const progress = player.querySelector( ".progress"); const progressBar = player.querySelector( ".progress__filled"); const toggle = player.querySelector( ".toggle"); const skipButtons = player.querySelectorAll( "[data-skip]"); const ranges = player.querySelectorAll( ".player__slider"); const hoverTime = document.getElementById( "hover-time"); function togglePlay() { const method = video.paused ? "play" : "pause"; if (self.ctx.$scope.playFinished && method === "play") { setTimeout(() => { self.ctx.$scope .pipeline = play( self.ctx.$scope .deviceSelected, obj.mimetype); }, 0); } else { video[method](); } } /*控制影片的播放*/ video.addEventListener("click", togglePlay); toggle.addEventListener("click", togglePlay); function updateButton() { const icon = this.paused ? "►" : "❚❚"; toggle.textContent = icon; } /*讓播放鍵的圖示改變*/ video.addEventListener("play", updateButton); video.addEventListener("pause", updateButton); function skip() { video.currentTime += parseFloat(this .dataset.skip); } /*調整影片的快進和倒退*/ skipButtons.forEach((button) => button .addEventListener("click", skip)); function handleProgress() { let percent = 0; if (self.ctx.$scope.timeToStart !== 0) { percent = ((self.ctx.$scope .timeToStart + video .currentTime) / vidoe_duration) * 100; } else { percent = (video.currentTime / vidoe_duration) * 100; } if (progressBar && self.ctx.$scope .playFinished === false) { progressBar.style.flexBasis = `${percent}%`; } } /*持續更新時間軸*/ video.addEventListener("timeupdate", handleProgress); function scrub(e) { const scrubTime = (e.offsetX / progress .offsetWidth) * vidoe_duration; self.ctx.$scope.timeToStart = scrubTime; self.ctx.$scope.pipeline.close(); setTimeout(() => { self.ctx.$scope.pipeline = play(self.ctx.$scope .deviceSelected, obj .mimetype); }, 0); } function showTime(e) { const scrubTime = (e.offsetX / progress .offsetWidth) * vidoe_duration; const formattedTime = new Date( formatTime( scrubTime)) .toLocaleTimeString(); // 設定 hover 時間內容 const hoverTime = document .getElementById("hover-time"); hoverTime.textContent = formattedTime; const x = e.clientX; const y = e.clientY; const elementY = progressBar .getBoundingClientRect().top + window.scrollY; hoverTime.style.top = elementY - 20 + 'px'; hoverTime.style.left = x + 18 + 'px'; } function formatTime(second) { const start = new Date(obj.startTime) .valueOf(); const overallSec = Number(start + second * 1000 ); //changed to milliSecond return overallSec; // const minutes = Math.floor(second / 60); // const seconds = Math.floor(second % 60); // const formattedTime = // `${minutes.toString() // .padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; // return formattedTime; //return localTime; } /*拖拉時間軸*/ let mousedown = false; progress.addEventListener("click", scrub); //progress.addEventListener('mouseover',(e)=> showTime(e)); progress.addEventListener("mousemove", ( e) => showTime(e)); progress.addEventListener("mousemove", ( e) => mousedown && scrub(e)); progress.addEventListener("mousedown", () => (mousedown = true)); progress.addEventListener("mouseup", () => ( mousedown = false)); //rtsp over webSocket self.ctx.$scope.recordingId = obj.id; //let pipeline; self.ctx.$scope.pipeline = play(self.ctx .$scope .deviceSelected, obj.mimetype); }, 200); } function dialogController(instance) { let vm = instance; vm.close = function() { if (self.ctx.$scope.pipeline) { self.ctx.$scope.pipeline.close(); self.ctx.$scope.timeToStart = 0; self.ctx.$scope.playFinished = true; } vm.dialogRef.close(null); }; } function handleChange() { if (self.ctx.$scope.timePick !== '') { handleClockChange(); } else { console.log('請選擇時間'); } } function handleClockChange() { const startClockInput = document.getElementById('startClock'); const endClockInput = document.getElementById('endClock'); self.ctx.$scope.sTime = startClockInput.value; self.ctx.$scope.eTime = endClockInput.value; if (self.ctx.$scope.deviceSelected) { const base = 'http://' + self.ctx.$scope .deviceSelected; const date = self.ctx.$scope.timePick; const taiwanTime = date + 'T00:00:00'; let startTime = timeToUTC(taiwanTime); let stopTime = ''; if (self.ctx.$scope.sTime !== '') { const taiwanDate = date + 'T' + self.ctx.$scope.sTime + ':00'; startTime = timeToUTC(taiwanDate); } if (self.ctx.$scope.eTime !== '') { const dt = date + 'T' + self.ctx.$scope.eTime + ':00'; stopTime = timeToUTC(dt); } getRecordingList(startTime, stopTime, base); } else { alert('請選擇設備'); } } async function getRecordingList(starttime, stoptime, base) { self.ctx.$scope.recordingArr = []; self.ctx.$scope.recordingId = ''; self.ctx.$scope.isRecordExist = false; const dateTime = new Date(starttime); if (stoptime === '') { stoptime = (new Date(dateTime.setHours(dateTime .getHours() + 24))).toISOString(); } const url = `${base}/axis-cgi/record/list.cgi?starttime=${starttime}&stoptime=${stoptime}`; const uri = `/axis-cgi/record/list.cgi?starttime=${starttime}&stoptime=${stoptime}`; const authorization = getBaseAuth(); const data = await axios.get(`${url}`, { headers: { 'Content-Type': 'application/json', 'Authorization': authorization } }). catch(err => { console.log(err); if (err.response.status === 401) { return axios.get( `${base}${uri}`, { headers: { authorization } }); } self.ctx.$scope.remark = '沒有權限取得錄影清單'; self.ctx.detectChanges(); throw err; }). catch(err => { self.ctx.$scope.remark = '無法取得錄影清單'; self.ctx.detectChanges(); throw err; }); //console.log('getData', xmlTojson(data.data)); const da = xmlTojson(data.data); console.log("data"); console.log(data); console.log("da"); console.log(da); const recordingsNum = da.root.recordings[ '_numberofrecordings']; if (recordingsNum === '0') { self.ctx.$scope.recordingArr = []; self.ctx.$scope.isRecordExist = false; } else if (recordingsNum === '1') { const starttimelocal = da.root.recordings .recording[ '_starttimelocal']; const stoptimelocal = da.root.recordings .recording[ '_stoptimelocal']; const obj = {}; obj.id = da.root.recordings.recording[ '_recordingid']; obj.date = starttimelocal.split('T')[0]; obj.startTime = starttimelocal; obj.endTime = stoptimelocal; obj.mimetype = da.root.recordings.recording .video['_mimetype'].split( '/')[1]; obj.recStatus = da.root.recordings .recording['_recordingstatus']; self.ctx.$scope.recordingArr.push(obj); self.ctx.$scope.isRecordExist = true; } else { const arr = da.root.recordings .recording; arr.forEach((item, i) => { const obj = {}; obj.id = item['_recordingid']; obj.date = item['_starttimelocal'] .split('T')[0]; obj.startTime = item[ '_starttimelocal']; obj.endTime = item[ '_stoptimelocal']; obj.mimetype = item.video[ '_mimetype'].split('/')[1]; obj.recStatus = item[ '_recordingstatus']; self.ctx.$scope.recordingArr.push( obj); self.ctx.$scope.isRecordExist = true; }); } self.ctx.detectChanges(); } // async function getRecordingList(starttime, base) { // self.ctx.$scope.recordingArr = []; // self.ctx.$scope.recordingId = ''; // self.ctx.$scope.isRecordExist = false; // const dateTime = new Date(starttime); // const stoptime = new Date(dateTime.setHours(dateTime // .getHours() + 24)); // const url = // `${base}/axis-cgi/record/list.cgi?starttime=${starttime}&stoptime=${stoptime.toISOString()}`; // const uri = // `/axis-cgi/record/list.cgi?starttime=${starttime}&stoptime=${stoptime.toISOString()}`; // const authorization = getBaseAuth(); // const data = await axios.get(`${url}`, { // headers: { // 'Content-Type': 'application/json', // 'Authorization': authorization // } // }). // catch(err => { // console.log(err); // if (err.response.status === // 401) { // return axios.get( // `${base}${uri}`, { // headers: { // authorization // } // }); // } // self.ctx.$scope.remark = '沒有權限取得錄影清單'; // self.ctx.detectChanges(); // throw err; // }). // catch(err => { // self.ctx.$scope.remark = '無法取得錄影清單'; // self.ctx.detectChanges(); // throw err; // }); // //console.log('getData', xmlTojson(data.data)); // const da = xmlTojson(data.data); // const recordingsNum = da.root.recordings[ // '_numberofrecordings']; // if (recordingsNum === '0') { // self.ctx.$scope.recordingArr = []; // self.ctx.$scope.isRecordExist = false; // } else if (recordingsNum === '1') { // const starttimelocal = da.root.recordings // .recording[ // '_starttimelocal']; // const stoptimelocal = da.root.recordings // .recording[ // '_stoptimelocal']; // const obj = {}; // obj.id = da.root.recordings.recording[ // '_recordingid']; // obj.date = starttimelocal.split('T')[0]; // obj.startTime = starttimelocal; // obj.endTime = stoptimelocal; // obj.mimetype = da.root.recordings.recording // .video['_mimetype'].split( // '/')[1]; // obj.recStatus = da.root.recordings // .recording['_recordingstatus']; // self.ctx.$scope.recordingArr.push(obj); // self.ctx.$scope.isRecordExist = true; // } else { // const arr = da.root.recordings // .recording; // arr.forEach((item, i) => { // const obj = {}; // obj.id = item['_recordingid']; // obj.date = item['_starttimelocal'] // .split('T')[0]; // obj.startTime = item[ // '_starttimelocal']; // obj.endTime = item[ // '_stoptimelocal']; // obj.mimetype = item.video[ // '_mimetype'].split('/')[1]; // obj.recStatus = item[ // '_recordingstatus']; // self.ctx.$scope.recordingArr.push( // obj); // self.ctx.$scope.isRecordExist = // true; // }); // } // self.ctx.detectChanges(); // } // function getDigestAuth(err, method, uri) { // const authDetails = err // .response.headers[ // 'www-authenticate' // ].split(', ') // .map(v => v.split( // '=')); // // console.log( // // authDetails); // ++count; // const nonceCount = ( // '00000000' + // count).slice(-8); // const randomString = // CryptoJS.lib // .WordArray.random( // 12).toString( // CryptoJS.enc.Hex // ); // const cnonce = // randomString // .substring(0, 16); // const realm = // authDetails[0][1] // .replace(/"/g, ''); // const nonce = // authDetails[1][1] // .replace(/"/g, '') + // "=" + // authDetails[1][2] // .replace(/"/g, ''); // const md = (str) => { // return CryptoJS // .MD5(str) // .toString( // CryptoJS // .enc.Hex // ); // }; // const HA1 = md( // `${publicKey}:${realm}:${privateKey}` // ); // const HA2 = md( // `${method}:${uri}` // ); // const response = md( // `${HA1}:${nonce}:${nonceCount}:${cnonce}:auth:${HA2}` // ); // const authorization = // `Digest username="${publicKey}",realm="${realm}",` + // `nonce="${nonce}" ,uri="${uri}",qop="auth",algorithm="MD5",` + // `response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`; // return authorization; // } function getBaseAuth() { const username = 'swae'; const passwd = '5Giotlead'; const basicAuth = 'Basic ' + Base64.encode( username + ':' + passwd); return basicAuth; } function play(host, encoding) { const videoEl = document.querySelector( '#camera-video'); const canvasEl = document.querySelector( 'canvas'); // Grab a reference to the video element let Pipeline; let mediaElement; if (encoding === 'x-h264') { Pipeline = self.ctx.$scope.pipelines .Html5VideoPipeline; mediaElement = videoEl; // hide the other output; videoEl.style.display = ''; canvasEl.style.display = 'none'; } else { Pipeline = self.ctx.$scope.pipelines .Html5CanvasPipeline mediaElement = canvasEl; // hide the other output videoEl.style.display = 'none'; canvasEl.style.display = ''; } // Setup a new pipeline const pipeline = new Pipeline({ ws: { uri: `ws://swae:5Giotlead@${host}/rtsp-over-websocket` }, rtsp: { uri: `rtsp://${host}/axis-media/media.amp?recordingid=${self.ctx.$scope.recordingId}&timestamp=0&videocodec=${encoding}` }, mediaElement, }); // Restart stream on RTCP BYE (stream ended) pipeline.rtsp.onRtcp = (rtcp) => { if (self.ctx.$scope.isRtcpBye(rtcp)) { self.ctx.$scope.timeToStart = 0; self.ctx.$scope.playFinished = true; // setTimeout(() => { // self.ctx.$scope.pipeline = play(host, // encoding); // }, 0); } }; pipeline.ready.then(() => { pipeline.rtsp.play(self.ctx.$scope .timeToStart); self.ctx.$scope.playFinished = false; }); return pipeline; }; function xmlTojson(xmlText) { const x2js = new X2JS(); const jsonObj = x2js.xml_str2json(xmlText); return jsonObj; } function timeToUTC(taiwanTime) { const localDate = new Date(taiwanTime + "+08:00"); const utcDate = localDate.toISOString(); return utcDate; } function showTimePeriod(startTime, stopTime) { let stop = ""; const start = new Date(startTime).toLocaleTimeString(); if(stopTime === ""){ stop = "NOW"; }else{ stop = new Date(stopTime).toLocaleTimeString(); } return `${start} ~ ${stop}`; } function calTimePeriod(startTime, stopTime) { let milliSecDiff = 0; if(stopTime === ""){ milliSecDiff = new Date().valueOf() - new Date(startTime).valueOf(); }else{ milliSecDiff = new Date(stopTime).valueOf() - new Date(startTime).valueOf(); } return milliSecDiff / 1000; } self.onDataUpdated = function() {} self.onDestroy = function() {} ``` :::