张高兴的 .NET IoT 入门指南:(七)制作一个气象站
距离上一篇《张高兴的 .NET Core IoT 入门指南》系列博客的发布已经过去 2 年的时间了,2 年的时间 .NET 版本发生了巨大的变化,.NET Core 也已不复存在,因此本系列博客更名为 《张高兴的 .NET IoT 入门指南》,我也重新审阅了之前的内容进行了相应的更改以保证内容的时效性。
和单片机不同,使用 Linux 开发板、现成的传感器套件以及合适的后端技术几乎可以做成任何东西。为了更好的整合前面章节介绍的内容,本文将制作一个简单的气象站(也许叫环境信息收集装置更合适),至于为何选择制作一个气象站,因为难度不高制作不复杂,并且温湿度传感器花费较低的价格即可获得,可以以低廉的价格换取一个 cool stuff。本文将使用 .NET 6 编写一个控制台应用程序,通过本文你可以学到:
- I2C
I2cDevice类的使用; - 摄像头设备
VideoDevice类的使用; Iot.Device.BindingsNuGet 包的使用;- 时序数据库
TimescaleDB的简单使用; Quartz定时任务的使用;- 在控制台应用中进行依赖注入;
- 使用
Docker拉取镜像、部署应用。
- VideoDevice 类是使用 PInvoke 操作实现的,依赖于 Video for Linux 2(V4L2),因此还需要安装 V4L2 工具:
sudo apt install v4l-utils实现时还引用了
System.DrawingNuGet 包,因此还需要安装System.Drawing的前置依赖:sudo apt install libc6-dev libgdiplus libx11-dev编写代码
项目地址:https://github.com/ZhangGaoxing/weather-metrics
项目结构
创建一个控制台应用和类库,项目结构如下:

项目依赖
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 TaskGetXinzhiWeatherAsync() { 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(); 这样一个一分钟采集一次数据的简易气象站就完成了。
部署应用
发布到文件
- 切换到
WeatherMetrics.ConsoleApp项目运行发布命令:
dotnet publish -c release -r linux-arm- 将发布后的文件通过 FTP 等方式复制到 Linux 开发板;
- 为
WeatherMetrics.ConsoleApp文件增加可执行权限
sudo chmod +x WeatherMetrics.ConsoleApp- 运行程序
sudo ./WeatherMetrics.ConsoleApp构建 Docker 镜像
- 查看 TimescaleDB 容器的 IP,并修改
appsettings.json的数据库连接字符串:
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' timescaledb- 在项目的根目录中创建
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"]- 切换到项目目录,构建镜像:
docker build -t weather-metrics -f Dockerfile .- 运行镜像:
docker run --rm -it --device /dev/video0 --device /dev/i2c-0 weather-metrics后续工作
程序运行一段时间后,使用标准的 SQL 查询一下数据:
SELECT * FROM metrics ORDER BY time DESC
硬件是软件的基础,对收集到的数据后续可以使用其他技术进行处理,比如可以使用 ASP.NET 编写 WEB 应用对数据进行展示,或者可以使用 ML.NET 构建机器学习模型对天气进行预测等等。
- 切换到