距離上一篇《張高興的 .NET Core IoT 入門指南》系列部落格的釋出已經過去 2 年的時間了,2 年的時間 .NET 版本發生了巨大的變化,.NET Core 也已不復存在,因此本系列部落格更名為 《張高興的 .NET IoT 入門指南》,我也重新審閱了之前的內容進行了相應的更改以保證內容的時效性。
和微控制器不同,使用 Linux 開發板、現成的感測器套件以及合適的後端技術幾乎可以做成任何東西。為了更好的整合前面章節介紹的內容,本文將製作一個簡單的氣象站(也許叫環境資訊收集裝置更合適),至於為何選擇製作一個氣象站,因為難度不高製作不復雜,並且溫溼度感測器花費較低的價格即可獲得,可以以低廉的價格換取一個 cool stuff。本文將使用 .NET 6 編寫一個控制檯應用程式,通過本文你可以學到:
- I2C
I2cDevice
類的使用; - 攝像頭裝置
VideoDevice
類的使用; Iot.Device.Bindings
NuGet 包的使用;- 時序資料庫
TimescaleDB
的簡單使用; Quartz
定時任務的使用;- 在控制檯應用中進行依賴注入;
- 使用
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 映象進行部署。
- 拉取 TimescaleDB 映象:
docker pull timescale/timescaledb:latest-pg14
- 建立卷,用於持久化資料庫資料:
docker volume create tsdb_data
- 執行映象,埠對映為
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
- 使用熟悉的資料庫管理工具(如 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
專案結構
建立一個控制檯應用和類庫,專案結構如下:
專案依賴
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();
這樣一個一分鐘採集一次資料的簡易氣象站就完成了。
部署應用
釋出到檔案
- 切換到
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 構建機器學習模型對天氣進行預測等等。