张高兴的 .NET IoT 入门指南:(七)制作一个气象站


距离上一篇《张高兴的 .NET Core IoT 入门指南》系列博客的发布已经过去 2 年的时间了,2 年的时间 .NET 版本发生了巨大的变化,.NET Core 也已不复存在,因此本系列博客更名为 《张高兴的 .NET IoT 入门指南》,我也重新审阅了之前的内容进行了相应的更改以保证内容的时效性。

和单片机不同,使用 Linux 开发板、现成的传感器套件以及合适的后端技术几乎可以做成任何东西。为了更好的整合前面章节介绍的内容,本文将制作一个简单的气象站(也许叫环境信息收集装置更合适),至于为何选择制作一个气象站,因为难度不高制作不复杂,并且温湿度传感器花费较低的价格即可获得,可以以低廉的价格换取一个 cool stuff。本文将使用 .NET 6 编写一个控制台应用程序,通过本文你可以学到:

  1. I2C I2cDevice 类的使用;
  2. 摄像头设备 VideoDevice 类的使用;
  3. Iot.Device.Bindings NuGet 包的使用;
  4. 时序数据库 TimescaleDB 的简单使用;
  5. Quartz 定时任务的使用;
  6. 在控制台应用中进行依赖注入;
  7. 使用 Docker 拉取镜像、部署应用。
  • VideoDevice 类是使用 PInvoke 操作实现的,依赖于 Video for Linux 2(V4L2),因此还需要安装 V4L2 工具:

    sudo apt install v4l-utils
    

    实现时还引用了 System.Drawing NuGet 包,因此还需要安装 System.Drawing 的前置依赖:

    sudo apt install libc6-dev libgdiplus libx11-dev
    

    编写代码

    项目地址:https://github.com/ZhangGaoxing/weather-metrics

    项目结构

    创建一个控制台应用和类库,项目结构如下:

    image

    项目依赖

    WeatherMetrics.ConsoleApp 添加如下 NuGet 包引用:

    
       
       
       
       
       
       
    
    

    WeatherMetrics.Models 添加如下 NuGet 包引用:

    
       
    
    

    数据库上下文与实体类

    TimescaleDB 本质上就是一个 PostgreSQL 数据库,因此数据库访问使用 Npgsql 驱动。首先添加实体类 Metrics.cs

    public class Metrics
    {
        [Column("time")]
        public DateTime Time { get; set; } = DateTime.Now;
    
        [Column("device_id")]
        public string DeviceId { get; set; }
    
        [Column("weather_type")]
        public string WeatherType { get; set; }
    
        [Column("temperature")]
        public double Temperature { get; set; }
    
        [Column("humidity")]
        public double Humidity { get; set; }
    
        [Column("pressure")]
        public double Pressure { get; set; }
    
        [Column("image_base64")]
        public string ImageBase64 { get; set; }
    }
    

    接着添加数据库上下文 WeatherContext.cs

    public class WeatherContext : DbContext
    {
        private readonly string _connectString;
    
        public WeatherContext(string connectString)
        {
            _connectString = connectString;
        }
    
        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
            optionsBuilder.UseNpgsql(_connectString);
        }
    
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity()
                .ToTable("metrics")
                .HasNoKey();
        }
    }
    

    这里使用了一个传递数据库连接字符串的构造函数,连接字符串从 appsettings.json 文件中读取。由于 metrics 表是无主键的,还需要使用 HasNoKey() 进行标记。EF Core 由于使用了实体跟踪,因此无法对无主键的表进行修改,只能通过执行 SQL 的方式插入数据,在 Metrics.cs 中新增方法:

    public static bool Insert(DbContext context, Metrics metrics)
    {
       int row = context.Database.ExecuteSqlRaw("INSERT INTO metrics VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6})", metrics.Time, metrics.DeviceId, metrics.WeatherType, metrics.Temperature, metrics.Humidity, metrics.Pressure, metrics.ImageBase64);
    
       return row > 0;
    }
    

    ?? 警告

    请不要在 SQL 中使用字符串内插。

    配置文件

    appsettings.json 中添加如下内容:

    {
      // 数据库连接字符串 
      "ConnectionString": "Server=localhost;Port=54321;Database=WeatherMetrics;User Id=postgres;Password=@Passw0rd;",
      // 定时任务设置
      "QuartzCron": "0 0/1 * * * ? *",
      // 心知天气的配置
      "Xinzhi": {
        "Key": "",
        "Location": "34.24:117.16"
      }
    }
    

    初始化与依赖注入配置

    新建一个静态类 AppConfig,用于保存依赖注入的 ServiceProvider 变量:

    public static class AppConfig
    {
        public static IServiceProvider ServiceProvider { get; set; }
    }
    

    Program.cs 中添加初始化代码:

    // 读取配置文件
    var config = new ConfigurationBuilder()
        .AddJsonFile("appsettings.json")
        .Build();
    
    // 实例化数据库上下文
    using WeatherContext context = new WeatherContext(config["ConnectionString"]);
    
    // 配置 I2C,实例化传感器
    I2cConnectionSettings i2cSettings = new I2cConnectionSettings(busId: 0, deviceAddress: Bmx280Base.SecondaryI2cAddress);
    using I2cDevice i2c = I2cDevice.Create(i2cSettings);
    using Bme280 bme = new Bme280(i2c);
    
    // 实例化摄像头
    VideoConnectionSettings videoSettings = new VideoConnectionSettings(busId: 0, captureSize: (640, 480));
    using VideoDevice video = VideoDevice.Create(videoSettings);
    
    // 配置依赖注入
    AppConfig.ServiceProvider = new ServiceCollection()
        .AddSingleton(config)
        .AddSingleton(context)
        .AddSingleton(bme)
        .AddSingleton(video)
        .BuildServiceProvider();
    

    配置定时任务

    定时任务通过 appsettings.json 中的 QuartzCron 字段设置。Cron 表达式分为 7 个部分,从左至右分别代表:Seconds、Minutes、Hours、DayofMonth、Month、DayofWeek 以及 Year。* 出现的部分表示任意值都会触发定时任务,/ 左侧表示触发的起始时间,右侧表示触发间隔,以 appsettings.json 中的为例,表示从每小时的第 0 分开始触发,每一分钟触发一次。

    新建 MetricsJob 类,用于实现定时任务:

    public class MetricsJob : IJob
    {
        public Task Execute(IJobExecutionContext context)
        {
            return Task.Run(async () =>
            {
                // TODO:在此处实现定时任务
                // 需要完成传感器的读取,心知天气的请求,数据库的插入
            });
        }
    }
    

    传感器的读取

    MetricsJob 类中添加方法:

    private Metrics GetMetrics()
    {
        // 获取依赖注入的 Bme280 对象
        Bme280 bme = (Bme280)AppConfig.ServiceProvider.GetService(typeof(Bme280));
    
        // 设置传感器的电源模式
        bme.SetPowerMode(Bmx280PowerMode.Normal);
    
        // 设置读取精度
        bme.PressureSampling = Sampling.UltraHighResolution;
        bme.TemperatureSampling = Sampling.UltraHighResolution;
        bme.HumiditySampling = Sampling.UltraHighResolution;
    
        // 读取数据
        bme.TryReadPressure(out UnitsNet.Pressure p);
        bme.TryReadTemperature(out UnitsNet.Temperature t);
        bme.TryReadHumidity(out UnitsNet.RelativeHumidity h);
    
        // 传感器休眠
        bme.SetPowerMode(Bmx280PowerMode.Sleep);
    
        return new Metrics
        {
            DeviceId = Dns.GetHostName(),
            Temperature = Math.Round(t.DegreesCelsius, 2),
            Humidity = Math.Round(h.Percent, 2),
            Pressure = Math.Round(p.Pascals, 2)
        };
    }
    

    摄像头捕获图像

    MetricsJob 类中添加方法:

    private string GetImage()
    {
        VideoDevice video = (VideoDevice)AppConfig.ServiceProvider.GetService(typeof(VideoDevice));
    
        byte[] image = video.Capture();
        return Convert.ToBase64String(image);
    }
    

    心知天气 API 请求

    通过请求心知天气 API 获得当前位置的天气名称,需要提前在 https://www.seniverse.com/api 申请 API Key。在 MetricsJob 类中添加方法:

    private async Task GetXinzhiWeatherAsync()
    {
        IConfigurationRoot config = (IConfigurationRoot)AppConfig.ServiceProvider.GetService(typeof(IConfigurationRoot));
    
        using HttpClient client = new HttpClient();
    
        try
        {
            var json = await client.GetStringAsync($"https://api.seniverse.com/v3/weather/now.json?key={config["Xinzhi:Key"]}&location={config["Xinzhi:Location"]}&language=zh-Hans&unit=c");
            return (string)JsonConvert.DeserializeObject(json).results[0].now.text;
        }
        catch (Exception)
        {
            return string.Empty;
        }
    }
    

    完善定时任务

    public Task Execute(IJobExecutionContext context)
    {
        return Task.Run(async () =>
        {
            var metrics = GetMetrics();
            metrics.WeatherType = await GetXinzhiWeatherAsync();
            metrics.ImageBase64 = GetImage();
    
            WeatherContext context = (WeatherContext)AppConfig.ServiceProvider.GetService(typeof(WeatherContext));
    
            Metrics.Insert(context, metrics);
        });
    }
    

    创建定时任务触发器

    Program.cs 中添加:

    // 创建一个触发器
    var trigger = TriggerBuilder.Create()
        .WithCronSchedule(config["QuartzCron"])
        .Build();
    
    // 创建任务
    var jobDetail = JobBuilder.Create()
        .WithIdentity("job", "group")
        .Build();
    
    // 绑定调度器
    ISchedulerFactory factory = new StdSchedulerFactory();
    var scheduler = await factory.GetScheduler();
    await scheduler.ScheduleJob(jobDetail, trigger);
    await scheduler.Start();
    

    这样一个一分钟采集一次数据的简易气象站就完成了。

    部署应用

    发布到文件

    1. 切换到 WeatherMetrics.ConsoleApp 项目运行发布命令:
    dotnet publish -c release -r linux-arm
    
    1. 将发布后的文件通过 FTP 等方式复制到 Linux 开发板;
    2. WeatherMetrics.ConsoleApp 文件增加可执行权限
    sudo chmod +x WeatherMetrics.ConsoleApp
    
    1. 运行程序
    sudo ./WeatherMetrics.ConsoleApp
    

    构建 Docker 镜像

    1. 查看 TimescaleDB 容器的 IP,并修改 appsettings.json 的数据库连接字符串:
    docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' timescaledb
    
    1. 在项目的根目录中创建 Dockerfile,并将整个项目复制到 Linux 开发板中:
    FROM mcr.microsoft.com/dotnet/core/sdk:6.0-focal-arm32v7 AS build
    WORKDIR /app
    
    # publish app
    COPY src .
    WORKDIR /app/WeatherMetrics.ConsoleApp
    RUN dotnet restore
    RUN dotnet publish -c release -r linux-arm -o out
    
    ## run app
    FROM mcr.microsoft.com/dotnet/core/runtime:6.0-focal-arm32v7 AS runtime
    WORKDIR /app
    COPY --from=build /app/WeatherMetrics.ConsoleApp/out ./
    
    # install native dependencies
    RUN apt update && \
        apt install -y --allow-unauthenticated v4l-utils libc6-dev libgdiplus libx11-dev
    
    ENTRYPOINT ["dotnet", "WeatherMetrics.ConsoleApp.dll"]
    
    1. 切换到项目目录,构建镜像:
    docker build -t weather-metrics -f Dockerfile .
    
    1. 运行镜像:
    docker run --rm -it --device /dev/video0 --device /dev/i2c-0 weather-metrics
    

    后续工作

    程序运行一段时间后,使用标准的 SQL 查询一下数据:

    SELECT * FROM metrics
    ORDER BY time DESC
    

    image

    硬件是软件的基础,对收集到的数据后续可以使用其他技术进行处理,比如可以使用 ASP.NET 编写 WEB 应用对数据进行展示,或者可以使用 ML.NET 构建机器学习模型对天气进行预测等等。