使用MASA Stack+.Net 从零开始搭建IoT平台 第四章 4.4 查询历史数据

发布时间 2023-07-19 17:52:21作者: sunday866

@


前言

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官方文档

https://docs.influxdata.com/influxdb/v2.7/query-data/

  1. from(bucket: "IoTDemos")表示我们要查询的库。
  2. |> range(start: 2023-07-17T16:00:00Z,stop:2023-07-18T16:00:00Z) 代表我们查询的时间范围,这里时间范围还有很多写法,例如range(start: -10h)代表查询最近十小时之内的数据,这里有个需要注意的地方,range(start: -1d),可以这样写来查一天之内的数据,但是这一天是按照UTC时间来统计的。
  3. 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的值
  4. |> aggregateWindow(every: 2h, fn: mean) 由于设备上报数据比较频繁(我这里模拟的数据是5s上报一次),但是当我展示给用户图表的时候,我不希望上面有那么密集的点,这时候就可以使用aggregateWindow函数,每2小时统计一次平均值,mean代表算数平均值。这样我查询24小时数据最多可以得到12个数据点,每个点为这两小时数据的平均值。
  5. |> 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