張高興的 .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 拉取鏡像、部署應用。

硬件需求

名稱 描述 數量
Orange Pi Zero Linux 開發板 x1
BME280 提供溫度、溼度以及氣壓數據 x1
USB 攝像頭 提供環境圖像 x1
杜邦線 傳感器與開發板的連接線 若干

電路

image

傳感器 接口 開發板接口
BME280 SDA TWI0_SDA (Pin 3)
SCL TWI0_SCK (Pin 5)
VCC 5V (Pin 4)
GND GND (Pin 6)
USB 攝像頭 USB USB

準備工作

配置 TimescaleDB 數據庫

TimescaleDB 是一款基於 PostgreSQL 插件的時序數據庫。考慮到收集的環境數據是按時間進行索引,並且數據基本上都是插入,沒有更新的需求,因此選用了時序數據庫作爲數據存儲。TimescaleDB 是 PostgreSQL 的一款插件,可以通過先安裝 PostgreSQL 之後再安裝插件的形式部署 TimescaleDB,這裏直接使用 TimescaleDB 的 Docker 鏡像進行部署。

  1. 拉取 TimescaleDB 鏡像:
docker pull timescale/timescaledb:latest-pg14
  1. 創建卷,用於持久化數據庫數據:
docker volume create tsdb_data
  1. 運行鏡像,端口映射爲 54321,密碼配置爲弱密碼 @Passw0rd
docker run -d --name timescaledb -p 54321:5432 --restart=always -e POSTGRES_PASSWORD='@Passw0rd' -e TZ='Asia/Shanghai' -e ALLOW_IP_RANGE=0.0.0.0/0 -v tsdb_data:/var/lib/postgresql timescale/timescaledb:latest-pg14
  1. 使用熟悉的數據庫管理工具(如 Navicat)創建數據庫 WeatherMetrics
CREATE DATABASE "WeatherMetrics"
WITH OWNER = postgres ENCODING = 'UTF8';

CREATE TABLE metrics (
   time TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT 'now()',
   device_id VARCHAR(50) NULL,
   weather_type VARCHAR(50) NULL,
   temperature DECIMAL(5, 2) NULL,
   humidity DECIMAL(5, 2) NULL,
   pressure DECIMAL(8, 2) NULL,
   image_base64 TEXT NULL
);

SELECT create_hypertable('metrics', 'time');

time 表示採集數據的時間,device_id 記錄採集設備的 id,weather_type 記錄從心知天氣獲取的天氣名,temperature 記錄傳感器獲取的溫度,humidity 記錄傳感器獲取的溼度,pressure 記錄傳感器獲取的氣壓,image_base64 記錄攝像頭採集的圖像。

💡 提示

在數據庫中存儲任何字符類型以外的數據都是愚蠢的,這裏是爲了演示,並且只是低分辨率的圖像。

超表(hypertable)是 TimescaleDB 的一個重要概念,由若干個塊(chunks)組成,將超表中的數據按照時間列(即 metrics 表中的 time 字段)分成若干個塊存儲,而使用 PostgreSQL 層面上的表(table)實現 SQL 接口的暴露,因此使用 create_hypertable() 將錶轉換爲超表。上面創建的 metrics 表並不是真正意義上的表,表中不存在主鍵字段,而是類似視圖(view)一樣的抽象結構。

安裝攝像頭的依賴庫

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 包引用:

<ItemGroup>
   <PackageReference Include="Iot.Device.Bindings" Version="2.0.0" />
   <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
   <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
   <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
   <PackageReference Include="Quartz" Version="3.3.3" />
   <PackageReference Include="System.Device.Gpio" Version="2.0.0" />
</ItemGroup>

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

<ItemGroup>
   <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.3" />
</ItemGroup>

數據庫上下文與實體類

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<Metrics>()
            .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<string> 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<dynamic>(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<MetricsJob>()
    .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 構建機器學習模型對天氣進行預測等等。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章