@
目录前言
IoT平台需要监控设备的运行状态,统计和分析设备传感器数据,使用图表展示是比较常见的场景。使用图表和表格数据组合的Dashboard也可以放在首页作为大屏展示。
分析
因为我们设备上报的数据都是存储到时序库influxdb中的,所以我们按照时间统计数据是很方便的,但是设备上报的数据频率和我们需要的统计周期可能并不一致,例如设备5s上报一次传感器数据,但是我们希望2小时统计一次这两小时内的最高值、最低值,或者平均值。查看文档发现aggregateWindow函数可以满足我们的需求,也就是所有的计算都可以在influxdb中完成。结合MASA Blazor现成的ECharts组件可以轻松完成图表制作。
方案
我们可以现在influxdb的UI管理界面点击DataExplorer中调试我们的查询脚本,我提前准备了一些数据
from(bucket: "IoTDemos")
|> range(start: 2023-07-17T16:00:00Z,stop:2023-07-18T16:00:00Z)
|> filter(fn: (r) => r._measurement == "AirPurifierDataPoint"
and r.ProductId == "c85ef7e5-2e43-4bd2-a939-07fe5ea3f459"
and r.DeviceName == "284202304230001")
|> aggregateWindow(every: 2h, fn: mean)
|> fill(value: 0.0)
我们这里只对接下来用到的一个查询语句做简单介绍,其他语法与函数请参考influxdb官方文档
- from(bucket: "IoTDemos")表示我们要查询的库。
- |> range(start: 2023-07-17T16:00:00Z,stop:2023-07-18T16:00:00Z) 代表我们查询的时间范围,这里时间范围还有很多写法,例如range(start: -10h)代表查询最近十小时之内的数据,这里有个需要注意的地方,range(start: -1d),可以这样写来查一天之内的数据,但是这一天是按照UTC时间来统计的。
- filter(fn: (r) => r._measurement == "AirPurifierDataPoint"
and r.ProductId == "c85ef7e5-2e43-4bd2-a939-07fe5ea3f459"
and r.DeviceName == "284202304230001")
filter过滤函数,可以限定我们的查询条件,这里的条件为从AirPurifierDataPoint表中,并限定了设备名称和产品ID,这里如果只查一个字段可以添加查询条件,例如添加 and r._field == "PM_25",将只返回PM_25的值 - |> aggregateWindow(every: 2h, fn: mean) 由于设备上报数据比较频繁(我这里模拟的数据是5s上报一次),但是当我展示给用户图表的时候,我不希望上面有那么密集的点,这时候就可以使用aggregateWindow函数,每2小时统计一次平均值,mean代表算数平均值。这样我查询24小时数据最多可以得到12个数据点,每个点为这两小时数据的平均值。
- |> fill(value: 0.0) 如果某个时间范围没有数据,那么这个时间范围统计出来的平均值会是"null",而不是0,我们可以使用这个函数将null替换为0.
我们执行一下,可以看到得到了36条数据(共3个字段,每个字段一天12条汇总数据)
编写代码
定义数据类
我们先定义一个ECharts需要的数据类
public class EChartsData
{
/// <summary>
/// 设备名称
/// </summary>
public string DeviceName { get; set; }
public List<FieldData> FieldDataList { get; set; }
}
public class FieldData
{
/// <summary>
/// 字段名称
/// </summary>
public string FieldName { get; set; }
/// <summary>
/// 时间点列表(X轴)
/// </summary>
public List<DateTime> DateTimes { get; set; }
/// <summary>
/// 数据点列表(Y轴)
/// </summary>
public List<double> Values { get; set; }
}
编写查询方法
然后我们在MASA.IoT.Core项目的TimeSeriesDbClient类中添加一个查询的方法
public async Task<EChartsData> GetDeviceDataPointListAsync(GetDeviceDataPointListOption option)
{
var query =
$@"from(bucket: ""{_bucket}"")
|> range(start: {option.UTCStartDateTimeStr},stop:{option.UTCStopDateTimeStr})
|> filter(fn: (r) => r._measurement == ""AirPurifierDataPoint""
and r.ProductId == ""{option.ProductId}""
and r.DeviceName == ""{option.DeviceName}"")
|> aggregateWindow(every: 2h, fn: mean)
|> fill(value: 0.0)";
var tables = await _client.GetQueryApi().QueryAsync(query, _org);
var fieldList = tables.SelectMany(table => table.Records).Select(o => o.GetField()).Distinct();
var eChartsData = new EChartsData
{
DeviceName = option.DeviceName,
FieldDataList = new List<FieldData>()
};
var fluxRecords = tables.SelectMany(table => table.Records);
foreach (var field in fieldList)
{
eChartsData.FieldDataList.Add(new FieldData
{
FieldName = field,
DateTimes = fluxRecords.Where(o => o.GetField()== field).Select(o => o.GetTime().Value.ToDateTimeUtc())
.ToList(),
Values = fluxRecords.Where(o => o.GetField() == field).Select(o => (double)o.GetValue()).ToList(),
});
}
return eChartsData;
}
这里查询方法与写入类似,通过SDK提供的_client.GetQueryApi()方法获取查询api,然后通过QueryAsync方法查询我们拼凑的语句。查询的结果是tables集合,我们可以通过GetTime()和GetValue()方法来拿到时间和对应的值。
由于influxdb存储的时间都是UTC时间,所以查询条件需要转换成UTC时间,使用o.GetValue()获取到的是object类型,我们需要转换成double。
添加ECharts图表
接下来我们开始在UI项目中添加ECharts图表,第一步先在MASA.IoT.UI项目的_Host.cshtml文件中部分添加echarts的js文件
<script src="https://cdn.masastack.com/npm/echarts/5.1.1/echarts.min.js"></script>
使用MASA Blazor创建页面就相对简单很多了,首先有一个设备列表页面,展示设备的名称和在线状态,当点击设备右侧的按钮时,弹出抽屉页面,显示我们的ECharts图表,这里还使用了Tab组件,方便以后扩展设备相关其他功能
@page "/DeviceList"
@using MASA.IoT.Core.Contract.Device
@using MASA.IoT.UI.Components
<PageTitle>设备列表</PageTitle>
<h1>设备列表</h1>
<MDataTable Headers="_headers" Items="deviceList" Class="elevation-1" Page="_options.PageIndex" ItemsPerPage="_options.PageSize"
ServerItemsLength="_totalCount">
<ItemColContent>
@if (context.Header.Value == "actions")
{
<MIcon Small Class="mr-2" OnClick="()=>EditItem(context.Item)">mdi-pencil</MIcon>
}
else if (context.Header.Value == nameof(DeviceListViewModel.OnLineStates))
{
<EnumChip Value="context.Item.OnLineStates"></EnumChip>
}
else
{
@context.Value
}
</ItemColContent>
</MDataTable>
<PDrawer Width="1000" Value="ShowDrawer" ValueChanged="DrawerChangedAsync">
<ActivatorContent>
</ActivatorContent>
<ChildContent>
<MTabs ValueChanged="TabsValueChanged">
<MTab>图表</MTab>
</MTabs>
<MTabsItems @bind-Value="_tabIndex">
<MTabItem>
<MCard Flat>
<MECharts Class="rounded-3" Option="_optionECharts" Height="350"></MECharts>
</MCard>
</MTabItem>
</MTabsItems>
</ChildContent>
</PDrawer>
页面逻辑代码如下:
using BlazorComponent;
using MASA.IoT.Common.Helper;
using MASA.IoT.Core.Contract.Device;
using MASA.IoT.UI.Caller;
using Microsoft.AspNetCore.Components;
namespace MASA.IoT.UI.Pages
{
public partial class DeviceList : ComponentBase
{
StringNumber _tabIndex;
private object _optionECharts = new();
private int _totalCount = 0;
private MqttHelper mqttHelper { get; set; }
private List<DeviceListViewModel> deviceList { get; set; } = new();
private bool ShowDrawer { get; set; }
[Inject]
private DeviceCaller _deviceCaller { get; set; }
private readonly DeviceListOption _options = new()
{
PageIndex = 1,
PageSize = 10,
};
private List<DataTableHeader<DeviceListViewModel>> _headers = new()
{
new DataTableHeader<DeviceListViewModel>
{
Text= "设备名称",
Align= DataTableHeaderAlign.Start,
Sortable= false,
Value= nameof(DeviceListViewModel.DeviceName)
},
new DataTableHeader<DeviceListViewModel>
{
Text= "在线状态",
Align= DataTableHeaderAlign.Start,
Sortable= false,
Value= nameof(DeviceListViewModel.OnLineStates)
},
new DataTableHeader<DeviceListViewModel>
{
Text= "Actions",
Value= "actions",
Sortable=false,
Width="100px",
Align=DataTableHeaderAlign.Center,
}
};
private async Task DrawerChangedAsync()
{
ShowDrawer = !ShowDrawer;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var paginatedList = await _deviceCaller.DeviceListAsync(new DeviceListOption { PageIndex = 1, PageSize = 10, ProductId = new Guid("C85EF7E5-2E43-4BD2-A939-07FE5EA3F459") });
deviceList = paginatedList.Result.ToList();
_totalCount = (int)paginatedList.Total;
StateHasChanged();
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task EditItem(DeviceListViewModel item)
{
var eChartsData = await _deviceCaller.GetDeviceDataPointList(new GetDeviceDataPointListOption { ProductId = Guid.Parse("c85ef7e5-2e43-4bd2-a939-07fe5ea3f459"), DeviceName = item.DeviceName, StartDateTime = DateTime.Today, StopDateTime = DateTime.Today.AddDays(1) });
if (eChartsData != null)
{
_optionECharts = GetEChartsData(eChartsData);
}
ShowDrawer = true;
}
private async Task TabsValueChanged(StringNumber value)
{
_tabIndex = value;
}
private dynamic GetEChartsData(EChartsData data)
{
return new
{
tooltip = new
{
trigger = "axis"
},
legend = new
{
Data = new[] { "Pm2.5", "Humidity", "Temperature" }
},
XAxis = new
{
Type = "category",
Data = data.FieldDataList.First().DateTimes.Select(o => o.ToLocalTime().ToString("t"))
},
YAxis = new
{
Min = 10,
Max = 100,
Type = "value",
},
Series = new[]
{
new
{
Name ="Pm2.5",
Type = "line",
Smooth = true,
Data = data.FieldDataList.First(o => o.FieldName=="PM_25").Values
},
new
{
Name ="Humidity",
Type = "line",
Smooth = true,
Data = data.FieldDataList.First(o => o.FieldName=="Humidity").Values
},
new
{
Name ="Temperature",
Type = "line",
Smooth = true,
Data = data.FieldDataList.First(o => o.FieldName=="Temperature").Values
}
}
};
}
}
}
这里查询当天数据(StartDateTime = DateTime.Today, StopDateTime = DateTime.Today.AddDays(1)),GetEChartsData方法返回匿名对象用于ECharts展示,其他内容相对简单不过多赘述。
效果
效果如下
总结
influxdb的自带统计函数很多,可以满足业务上的绝大多数需求,而且还可以自定义函数,结合MASA Blazor和ECharts可以轻松打造丰富直观的Dashboard。另外Influxdb的UI界面也支持定义Dashboard,目前支持八种图表展示。
完整代码在这里:https://github.com/sunday866/MASA.IoT-Training-Demos