基於.NET 製作一個氣象站 IoT 應用

語言: CN / TW / HK

點選“ 閱讀原文 ”獲得最佳閱讀體驗。

和微控制器不同,使用 Linux 開發板、現成的感測器套件以及合適的後端技術幾乎可以做成任何東西。為了更好的整合前面章節介紹的內容,本文將製作一個簡單的氣象站(也許叫環境資訊收集裝置更合適),至於為何選擇製作一個氣象站,因為難度不高製作不復雜,並且溫溼度感測器花費較低的價格即可獲得,可以以低廉的價格換取一個 cool stuff。本文將使用 .NET 6 編寫一個控制檯應用程式,通過本文你可以學到:

  1. I2C I2cDevice 類的使用;

  2. 攝像頭裝置 VideoDevice 類的使用;

  3. Iot.Device.Bindings NuGet 包的使用;

  4. 時序資料庫 TimescaleDB 的簡單使用;

  5. Quartz 定時任務的使用;

  6. 在控制檯應用中進行依賴注入;

  7. 使用 Docker 拉取映象、部署應用。

  • 硬體需求

  • 電路

  • 準備工作

    • 配置 TimescaleDB 資料庫

    • 安裝攝像頭的依賴庫

  • 編寫程式碼

    • 專案結構

    • 專案依賴

    • 資料庫上下文與實體類

    • 配置檔案

    • 初始化與依賴注入配置

    • 配置定時任務

  • 部署應用

    • 釋出到檔案

    • 構建 Docker 映象

  • 後續工作

硬體需求

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

電路

感測器 介面 開發板介面
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
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 記錄攝像頭採集的影象。

:bulb: 提示

在資料庫中儲存任何字元型別以外的資料都是愚蠢的,這裡是為了演示,並且只是低解析度的影象。

超表(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

專案結構

建立一個控制檯應用和類庫,專案結構如下:

專案依賴

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;
}

:warning: 警告

請不要在 SQL 中使用字串內插。

配置檔案

appsettings.json 中新增如下內容:

{
  // 資料庫連線字串 
  "ConnectionString": "Server=localhost;Port=54321;Database=WeatherMetrics;User Id=postgres;[email protected];",
  // 定時任務設定
  "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();

這樣一個一分鐘採集一次資料的簡易氣象站就完成了。

部署應用

釋出到檔案

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 映象

appsettings.json
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' timescaledb
Dockerfile
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

硬體是軟體的基礎,對收集到的資料後續可以使用其他技術進行處理,比如可以使用 ASP.NET 編寫 WEB 應用對資料進行展示,或者可以使用 ML.NET 構建機器學習模型對天氣進行預測等等。