ThinsBoard 二次开发:ImageMap 上点击设备地图高亮
当我们把很多设备以 Map 方式展现在UI中时,很多设备,密密麻麻,需要通过搜索后高亮显示,此二次开发就实现了。
准备工作
- 使用 ImageMap 做一个Dashboard,可以把 device 拖拽到 Map 中;
效果展示
查看视频
当我们把很多设备以 Map 方式展现在UI中时,很多设备,密密麻麻,需要通过搜索后高亮显示,此二次开发就实现了。
查看视频
此二次开发主要是基于 ThingsBoard UI 界面上的 js 脚本开发,熟悉 js 开发的朋友应该很容易理解。
从 Dashboard 导出 csv 其实有好几种情况,1是 telemetry 数据导出,2是 attributes 数据导出。
导出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();
导出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 doctor
,一步步排查,直到没有错误。这里不多说,不是重点。
需要提前准备好 Android 开发环境和SDK,具体请参考 Android 开发环境要求,主要是能跑起来环境就没问题。
前提是需要一台 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 上用户登录即可
2021.8.14 第一时间将 TB 3.3 的 OTA 功能进行源码分析,基本思路就是 应用了telemetry 和 attributes ,不复杂,自己实现的话也是要这样来做。这里,只是分析了 CoAP 协议层,其他类似。
按照官方文档里面的路径是错误的,需要改成如下url路径才能获取固件:coap://localhost/fw/$access_token?title=$title&version=$versoin
具体文件位置:
/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java
默认支持 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
}
自带了两个不错的查看 OTA 的dashboard,赶紧收藏起来。
firmware:
/application/src/main/data/json/demo/dashboards/firmware.json
software:
/application/src/main/data/json/demo/dashboards/software.json
firmware / software 文件使用 oid 格式存储,隐藏列,无法查看,具体详细内容请自行查看postgresql oid数据类型。
https://thingsboard.io/docs/user-guide/resources/firmware/ 目录下
http_firmware_client.py
mqtt_firmware_client.py
coap_firmware_client.py
基本可以用,但是脚步写的不是很好,有能力的可以自己改。
1、服务有一个属性,标记是不是要升级,设备定期去获取这个属性,有的话就下载,提交服务器“下载中”;
2、固定地址下载:包括固件名字和版本号,下载完成:提交服务器“已下载”;
3、设备验证固件包完整性,提交服务器“已验证”;
4、设备开始更新前,提交服务器“更新中”;
5、设备更新完成后,成功提交“已更新”,失败提交“失败”;
当然,首先必须要有 ThingsBoard 二次开发基础,可以在本博客中搜索“二次开发”,你会得到很多提升。
https://www.npmjs.com/package/angular2-signaturepad,根据提示,加入到 package.json 中,确保模块运行正常。
手工创建或通过命令行创建,sign.component.ts, sign.component.html, sign.component.scss,并且加入到 module.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);
}
);
}
}
<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>
签名提示:
报告结果: