分类 默认分类 下的文章

此二次开发主要是基于 ThingsBoard UI 界面上的 js 脚本开发,熟悉 js 开发的朋友应该很容易理解。
从 Dashboard 导出 csv 其实有好几种情况,1是 telemetry 数据导出,2是 attributes 数据导出。

准备工作

  • Chrome 浏览器
  • javascript 基础知识
  • ThingsBoard UI 二次开发基础知识
  • Dashbaord 开发经验

新建Dashboard

Telemetry 数据导出

1.png

导出csv文件内容:

timestamp,GasCon
2022-3-21 21:29:0,800
2022-3-21 21:44:0,800
2022-3-25 19:59:0,0

自定义 action,js脚本:

let $injector = widgetContext.$scope.$injector;
let attributeService = $injector.get(widgetContext
    .servicesMap.get('attributeService'));
let deviceService = $injector.get(widgetContext.servicesMap
    .get('deviceService'));

console.log(widgetContext);
let data = widgetContext.data;

let dataKeys = [];
// get all data keys to array
widgetContext.datasources[0].dataKeys.forEach(function(
    item) {
    dataKeys.push(item.name);
});

let fileTitle = "export-from-list-" + entityName;

function timeConverter(UNIX_timestamp) {
    var a = new Date(UNIX_timestamp);
    var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
        'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
    ];
    var year = a.getFullYear();
    var month = a.getMonth()+1;
    var date = a.getDate();
    var hour = a.getHours();
    var min = a.getMinutes();
    var sec = a.getSeconds();
    var time = year + '-' + month + '-' + date + ' ' +
        hour + ':' + min + ':' + sec;
    return time;
}

function exportCSV() {
    // first line: header
    var allLine = "";
    dataKeys.unshift("timestamp");
    allLine += dataKeys;
    allLine += "\n";

    var key_length = data.length; // 字段数目
    var limit = data[0]["data"].length; // 数据行数
    var line = new Array(limit + 1); // 包括首行
    for (var l = 0; l < limit; l++)
        line[l] = "";
    data.forEach(
        (item, idx) => {
            var item_data = item["data"];
            var dataKey = item["dataKey"];
            for (var j = 0; j < limit; j++) {
                if (idx == 0) {

                    line[j] += timeConverter(item_data[
                        j][0]);
                    line[j] += ",";
                }
                line[j] += item_data[j][1];
                if (idx < key_length - 1)
                    line[j] += ",";
            }
            line[j] += "\n";
        }
    );
    for (var k = 0; k < limit; k++) {
        allLine += line[k];
        allLine += "\n";
    }

    var exportedFilenmae = fileTitle + '.csv' ||
        'export.csv';

    var blob = new Blob([allLine], {
        type: 'text/csv;charset=utf-8;'
    });
    if (navigator.msSaveBlob) { // IE 10+
        navigator.msSaveBlob(blob,
            exportedFilenmae);
    } else {
        var link = document.createElement("a");
        if (link.download !==
            undefined) { // feature detection
            // Browsers that support HTML5 download attribute
            var url = URL.createObjectURL(blob);
            link.setAttribute("href", url);
            link.setAttribute("download",
                exportedFilenmae);
            link.style.visibility = 'hidden';
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
        }
    }
}

exportCSV();

Attributes 数据导出

2.png

导出csv文件内容:

timestamp,name,detector_tag,area,floor,location,department,equipment_name,gas,setup_date,sensor_date,cal_date,cal_result,ip,hub_port,sub_hub_port,maintenance
2022-3-28 21:25:9,CL96-06,,,,,,,,,,,,,,,
2022-3-28 21:25:9,F-CL96-SRD013-HF,,,,,,,,,,,,,,,
2022-3-28 21:25:9,F-CL96-SRD033-C-NH3,,,,,,,,,,,,,,,
2022-3-28 21:25:9,F-CL96-SRD033-D-NH3,,,,,,,,,,,,,,,
2022-3-28 21:25:9,F-CL96-SRD037-CLF3,,,,,,,,,,,,,,,

自定义 action,js 脚本:

let $injector = widgetContext.$scope.$injector;
let attributeService = $injector.get(widgetContext
    .servicesMap.get('attributeService'));
let deviceService = $injector.get(widgetContext.servicesMap
    .get('deviceService'));

let data = widgetContext.data;
console.log(data);

let dataKeys = [];
// get all data keys to array
widgetContext.datasources[0].dataKeys.forEach(function(
    item) {
    dataKeys.push(item.name);
});

let fileTitle = "export-device-list";

function timeConverter(UNIX_timestamp) {
    var a = new Date(UNIX_timestamp);
    var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
        'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
    ];
    var year = a.getFullYear();
    var month = a.getMonth()+1;
    var date = a.getDate();
    var hour = a.getHours();
    var min = a.getMinutes();
    var sec = a.getSeconds();
    var time = year + '-' + month + '-' + date + ' ' +
        hour + ':' + min + ':' + sec;
    return time;
}

function exportCSV() {
    // first line: header
    var allLine = "";
    var key_length = dataKeys
        .length; // csv字段数目, 不含timestamp
    dataKeys.unshift("timestamp");
    allLine += dataKeys;
    // allLine += "\n";

    var limit = data.length / key_length; // csv数据行数
    var line = new Array(limit + 1); // csv总行数,包括首行
    for (var l = 0; l < limit; l++)
        line[l] = "";

    //var j = 0;
    data.forEach(
        (item, idx) => {
            var item_data = item["data"];

            if (idx % key_length == 0) {
                allLine += "\n"; // 换行
                allLine += timeConverter(item_data[0][0]); // timestamp
                allLine += ",";
            }

            allLine += item_data[0][1]; // value/值

            if ((idx+1) % key_length != 0)
                allLine += ",";
        }
    );
    
    allLine += "\n";
    // for (var k = 0; k < key_length; k++) {
    //     allLine += line[k];
    //     allLine += "\n";
    // }

    var exportedFilenmae = fileTitle + '.csv' ||
        'export.csv';

    var blob = new Blob([allLine], {
        type: 'text/csv;charset=utf-8;'
    });
    if (navigator.msSaveBlob) { // IE 10+
        navigator.msSaveBlob(blob,
            exportedFilenmae);
    } else {
        var link = document.createElement("a");
        if (link.download !==
            undefined) { // feature detection
            // Browsers that support HTML5 download attribute
            var url = URL.createObjectURL(blob);
            link.setAttribute("href", url);
            link.setAttribute("download",
                exportedFilenmae);
            link.style.visibility = 'hidden';
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
        }
    }
}

exportCSV();

ThingsBoard 移动端采用 flutter 来实现,支持 Android 和 iOS,当然,web 也可以。总体测试下来,还是算不错的,虽然还没有 release 版本,但是,完全可以使用。

准备工作

flutter 工作环境

参考官方手册,很简单,下载,解压缩,运行:flutter doctor ,一步步排查,直到没有错误。这里不多说,不是重点。

Android

需要提前准备好 Android 开发环境和SDK,具体请参考 Android 开发环境要求,主要是能跑起来环境就没问题。

iOS

前提是需要一台 macOS 的电脑,配置低了还不行,还需要安装很多软件环境,这个对普通开发者还是有难度,土豪除外。

源码分析

配置自己的服务器

lib/constants/app_constants.dart, thingsBoardApiEndpoint 改成你的服务器地址即可;

汉化过程

大概思路就是各种dart 文件,比如: lib/modules/profile/change_password_page.dart,修改密码页面,把字符串改成中文就好了,没什么其他要求。

编译

运行 flutter build apk,可简单了,结果就是这样:

jiekechoo@jiekechoo flutter_thingsboard_app % flutter build apk --no-tree-shake-icons

Building with sound null safety 

Running Gradle task 'assembleRelease'...                                
Running Gradle task 'assembleRelease'... Done                     212.8s
✓  Built build/app/outputs/flutter-apk/app-release.apk (22.8MB).

apk 文件在 build/app/outputs/flutter-apk/app-release.apk,拿到Android 手机上安装即可使用,非常简单。

中文汉化效果预览

登录

直接使用 ThingsBoard 上用户登录即可

tb-app-login.png

设备

tb-app.png

仪表板

tb-app1.png

2021.8.14 第一时间将 TB 3.3 的 OTA 功能进行源码分析,基本思路就是 应用了telemetry 和 attributes ,不复杂,自己实现的话也是要这样来做。这里,只是分析了 CoAP 协议层,其他类似。

1、CoAP 路径:

按照官方文档里面的路径是错误的,需要改成如下url路径才能获取固件:coap://localhost/fw/$access_token?title=$title&version=$versoin

具体文件位置:
/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java

2、checksum算法

默认支持 SHA256,如果设备端不支持或有其他算法请自取 enum 里面内容。
/common/data/src/main/java/org/thingsboard/server/common/data/ota/ChecksumAlgorithm.java
public enum ChecksumAlgorithm {

MD5,
SHA256,
SHA384,
SHA512,
CRC32,
MURMUR3_32,
MURMUR3_128

}

3、OTA Dashboard

自带了两个不错的查看 OTA 的dashboard,赶紧收藏起来。
firmware:
/application/src/main/data/json/demo/dashboards/firmware.json
software:
/application/src/main/data/json/demo/dashboards/software.json
firmware-dashboard.png

4、OTA 固件存储

firmware / software 文件使用 oid 格式存储,隐藏列,无法查看,具体详细内容请自行查看postgresql oid数据类型。

5、测试脚本

https://thingsboard.io/docs/user-guide/resources/firmware/ 目录下
http_firmware_client.py
mqtt_firmware_client.py
coap_firmware_client.py
基本可以用,但是脚步写的不是很好,有能力的可以自己改。

5、升级步骤

1、服务有一个属性,标记是不是要升级,设备定期去获取这个属性,有的话就下载,提交服务器“下载中”;
2、固定地址下载:包括固件名字和版本号,下载完成:提交服务器“已下载”;
3、设备验证固件包完整性,提交服务器“已验证”;
4、设备开始更新前,提交服务器“更新中”;
5、设备更新完成后,成功提交“已更新”,失败提交“失败”;

准备工作

当然,首先必须要有 ThingsBoard 二次开发基础,可以在本博客中搜索“二次开发”,你会得到很多提升。

增加签名用的 signature pad 包

https://www.npmjs.com/package/angular2-signaturepad,根据提示,加入到 package.json 中,确保模块运行正常。

新增用于签名的组建

手工创建或通过命令行创建,sign.component.ts, sign.component.html, sign.component.scss,并且加入到 module.ts

新增配套 service 处理

页面点击触发签名框弹出,保存签名图片到数据库的请求。

后端图片处理

后端需要将前端提交的数据保存到数据库,还需要将数据展现在前端页面。

代码示例

TS 文件

import { Component, Inject, OnInit, SkipSelf, ViewChild } from '@angular/core';
import { ErrorStateMatcher } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms';
import { ReportService } from '@core/http/report.service';
import { Report, ReportFill } from '@shared/models/report.models';
import { DialogComponent } from '@shared/components/dialog.component';
import { Router } from '@angular/router';

import { SignaturePad } from 'angular2-signaturepad';

export interface ReportSignObservationDialogData {
  report: Report;
}

@Component({
  selector: 'tb-report-sign-observation-dialog',
  templateUrl: './sign-report-observation-dialog.component.html',
  styleUrls: ['./sign-report-observation-dialog.component.scss'],
  providers: [{provide: ErrorStateMatcher, useExisting: ReportSignObservationDialogComponent}]
})
export class ReportSignObservationDialogComponent extends
  DialogComponent<ReportSignObservationDialogComponent, Report> implements OnInit, ErrorStateMatcher {
    @ViewChild(SignaturePad) signaturePad: SignaturePad;
    private signaturePadOptions: Object = { // passed through to szimek/signature_pad constructor
      'minWidth': 2,
      'canvasWidth': 400, // 弹出的窗口宽度
      'canvasHeight': 200, // 弹出的窗口高度
      'backgroundColor': 'rgb(240,240,240)' // 背景颜色
    };

  reportSignFormGroup: FormGroup;
  isReadOnly: boolean;
  reportSign: String;
  report: Report;
  submitted = false;

  constructor(protected store: Store<AppState>,
              protected router: Router,
              @Inject(MAT_DIALOG_DATA) public data: ReportSignObservationDialogData,
              private reportService: ReportService,
              @SkipSelf() private errorStateMatcher: ErrorStateMatcher,
              public dialogRef: MatDialogRef<ReportSignObservationDialogComponent, Report>,
              public fb: FormBuilder) {
    super(store, router, dialogRef);
  }

  ngAfterViewInit() {
    // this.signaturePad is now available
    this.signaturePad.set('minWidth', 2); // set szimek/signature_pad options at runtime
    this.signaturePad.clear(); // invoke functions from szimek/signature_pad API
  }

  signatureImage;
  drawComplete() {
    // will be notified of szimek/signature_pad's onEnd event
    // console.log(this.signaturePad.toDataURL());
    this.reportSign = this.signaturePad.toDataURL();
    this.signatureImage=this.signaturePad.toDataURL(); // 将图片转换成 base64 码
  }

  drawStart() {
    // will be notified of szimek/signature_pad's onBegin event
    // console.log('begin drawing');
  }

  ngOnInit(): void {
    this.buildReportSign();
    this.loadReportSign();
  }

  isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
    const originalErrorState = this.errorStateMatcher.isErrorState(control, form);
    const customErrorState = !!(control && control.invalid && this.submitted);
    return originalErrorState || customErrorState;
  }

  buildReportSign() {
    this.reportSignFormGroup = this.fb.group({  
      file: ''
    });
  }

  loadReportSign() {
    this.reportSignFormGroup = this.fb.group({  
      file: ''
    });
  }

  cancel(): void {
    this.dialogRef.close(null);
  }

  save(): void {
    this.submitted = true;
    // console.log(this.data.report.id, this.reportSign);
    this.report = this.data.report;
    this.reportService.signReport(this.report, encodeURIComponent(this.reportSign.toString())).subscribe( // 调用保存签名的 service
      (report) => {
        this.dialogRef.close(this.report);
      }
    );
  }
}

HTML 文件

<form [formGroup]="reportSignFormGroup" (ngSubmit)="save()">
    <mat-toolbar color="primary">
        <h2 translate>report.sign-report</h2>
        <span fxFlex></span>
        <button mat-icon-button (click)="cancel()" type="button">
      <mat-icon class="material-icons">close</mat-icon>
    </button>
    </mat-toolbar>
    <mat-progress-bar color="warn" mode="indeterminate" *ngIf="isLoading$ | async">
    </mat-progress-bar>
    <div style="height: 4px;" *ngIf="!(isLoading$ | async)"></div>
    <div mat-dialog-content>
        <signature-pad [options]="signaturePadOptions" (onBeginEvent)="drawStart()" (onEndEvent)="drawComplete()"></signature-pad>
    </div>

    <div mat-dialog-actions fxLayoutAlign="end center">
        <button *ngIf="!isReadOnly" mat-raised-button color="primary" type="submit">
      {{ 'action.save' | translate }}
    </button>
        <button mat-button color="primary" type="button" [disabled]="(isLoading$ | async)" (click)="cancel()" cdkFocusInitial>
      {{ (isReadOnly ? 'action.close' : 'action.cancel') | translate }}
    </button>
    </div>
</form>

结果展示

签名提示:
sign.png

报告结果:
sign1.png