好久没写东西了,趁周末做个简单的小手工,用上位机采集TVOC和CH₂O传器的读数并记录到数据库,同时在网页展示历史数据曲线。
| 设备 | 接口 | 用途 |
| 立创泰山派开发板 | 上位机 | |
| CH344 | USB | USB转4路串口 |
| zp16空气质量模组 | UART | TVOC |
| zp08-CH₂O模组 | UART | CH₂O |
| PS-VOC-200 | UART | TVOC(实际CH₂O) |
| WZ-S | UART | CH₂O |
| AGS2602 | IIC | TVOC |
| AGS02MA | IIC | TVOC |
| GT-U8 | UART | GPS/BD定位模块 |


开发板是24年1月刚上市的时候买的,吃灰到现在,板载1* USB2接口,1* 40pin GPIO。传感器不同型号各选了几个,用于横向对比。USB下挂一个CH344,提供4路UART,同时板载GPIO提供UART3,IIC2和IIC3。
总体而言,市面上这些几十块百来块的传感器100%都是玩具,不用说精度,也不谈定量,即使连定性都无法做到。以这些廉价传感器为核心的检测产品也几乎都是玩具水平的存在。
尤其要吐槽的是这款PS-VOC-200模组,实在过于离谱,官方把甲醛传感器在官网、店铺页面上改了个名字,直接当TVOC传感器来卖。实际是一款WZ-H3型号的甲醛传感器。
这几个串口传感器不管是抄袭仿冒也好,贴牌也好,应该都是同源的,数据结构都一样。接线的时候IIC接口需要给SDA, SCL接上拉电阻。
// 0: 开始 1: 气体类型 2: 单位 3: 小数位数
// 4: 读数高位 5: 读数低位
// 6: 量程高位 7: 量程低位 8: 校验

GPS结构定义:
public sealed record GPS(
double Latitude,
double Longitude,
DateTime UTCNow,
double Speed);由于板载接口UART和i2c接口编号是固定的,所以可以直接硬编码或者配置文件加载,而USB转出来的串口端口号不固定,只能自动检测,所以同类型的传感器可以接到相同类型的接口,用同一套逻辑统一识别和处理。串口和I2C的Helper:
public static class SPortHelper
{
const byte IIC_ADDR = 0x1A;
const byte IIC_VOC = 0x00;
private static GPS gps = new(0, 0, DateTime.Now, 0);
private static readonly Dictionary<string, SerialPort> dictWorkingPorts = [];
private static readonly Dictionary<string, List<byte>> dictQueues = [];
private static bool gpsloop = false;
private static SerialPort? gpsPort = null;
// 0: 开始标记 1: 气体类型 2: 数值单位 3: 小数位数
// 4: 测值高位 5: 测值低位
// 6: 量程高位 7: 量程低位 8: 校验
// 0x17 CH2O, 0x34 VOC,0x02 ppm, 0x11 mg/m3
// FF 17 04 00 00 1E 13 88 2C ZE08, CH2O, ppb(ug/m3 = ppb*1.25)
// FF 34 11 02 00 00 03 E8 CE ZP16, VOC, mg/m3, 2位小数
// FF 17 04 00 00 01 13 88 49 WZ-H3,CH2O, ppb(ug/m3 = ppb*1.25)
private static void Sp_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
byte[] buf = new byte[32];
var sp = (SerialPort)sender;
string spName = sp.PortName;
string logName = spName.Replace("/dev/tty", "");
int len = sp.Read(buf, 0, buf.Length);
if (!dictQueues.TryGetValue(spName, out var frame))
{
frame = [];
dictQueues.Add(spName, frame);
}
for (int i = 0; i < len; i++)
{
byte b = buf[i];
frame.Add(b);
if (frame.Count == 9)
{
if (frame[0] != 0xFF)
{
frame.Clear();
continue;
}
else
{
//0ch2o, 1tvoc
//0ppb, 1mg/m3
int gas = 0;
switch (frame[1])
{
case 0x34:
case 0x20:
gas = 1;
break;
case 0x17:
break;
default:
frame.Clear();
continue;
}
int unit = 0;
switch (frame[2])
{
case 0x04:
break;
case 0x11:
unit = 1;
break;
default:
frame.Clear();
continue;
}
double dig = Math.Pow(10, frame[3]);
double val = Math.Round(((frame[4] << 8) | frame[5]) / dig, 3);
if (frame[1] == 0x17 && frame[2] == 0x04)
{//甲醛单位如果是ppb,转为mg/m3
val *= 0.00125;
unit = 1;
}
General.Log($"{logName}:\t{(gas == 0 ? "CH2O" : "TVOC")}: {val:F6} {(unit == 0 ? "ppb" : "mg/m3")}");
using DBHelper helper = new();
GPS? g = Volatile.Read(ref gps);
string sql = "INSERT INTO `iaq` (`created`, `type`, `val`, `unit`, `dev`, `lat`, `lng`) VALUES(" +
$"NOW(), {gas}, {val}, {unit}, '{logName.Replace("/dev/tty", "")}', {g?.Latitude ?? 0}, {g?.Longitude ?? 0})";
int ret = helper.Query(sql, [], out string msg);
if (ret < 0) General.Log($"{sql}\r\n{msg}");
frame.Clear();
}
}
}
}
public static void SPInit()
{
var gasPorts = SerialPort.GetPortNames().Where(n => n.Contains("ttyacm", StringComparison.OrdinalIgnoreCase)).ToArray();
General.Log($"串口检测:{string.Join(", ", gasPorts)}\r\n当前工作端口:{string.Join(", ", dictWorkingPorts)}");
List<string> old = [];
foreach (var port in dictWorkingPorts)
{
if (!gasPorts.Contains(port.Key))
{
port.Value.DataReceived -= Sp_DataReceived;
old.Add(port.Key);
}
}
General.Log($"移除失效串口:{string.Join(", ", old)}");
foreach (string port in old)
{
dictWorkingPorts.Remove(port);
dictQueues.Remove(port);
}
foreach (string port in gasPorts)
{
if (dictWorkingPorts.ContainsKey(port))
{
General.Log($"跳过工作串口:{port}");
continue;
}
SerialPort sp = new(port, 9600);
sp.DataReceived += Sp_DataReceived;
try
{
sp.Open();
dictWorkingPorts.Add(port, sp);
dictQueues.Add(port, []);
General.Log($"添加工作串口:{port}");
}
catch (Exception)
{
sp.DataReceived -= Sp_DataReceived;
General.Log($"串口打开失败:{port}");
}
}
// ttyUSB0, GPS
var gpsPortName = SerialPort.GetPortNames().FirstOrDefault(n => n.Contains("ttys3", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(gpsPortName))
{
General.Log($"找不到GPS端口 ttyS3");
gpsPort = null;
return;
}
gpsPort = new(gpsPortName, 9600);
try
{
gpsPort.Open();
General.Log($"开始侦听GPS ttyS3");
}
catch (Exception ex)
{
General.Log($"无法打开GPS端口 ttyS3:\r\n{ex.Message}");
gpsPort?.Close();
gpsPort = null;
return;
}
if (!gpsloop)
{
Task.Run(() =>
{
gpsloop = true;
while (true)
{
Task.Delay(2000);
if (gpsPort is null) continue;
string line;
try
{
line = gpsPort.ReadLine();
}
catch (Exception ex)
{
General.Log($"GPS端口读取错误:\r\n{ex}");
continue;
}
Match match = Regex.Match(line, "(?<=$.?.?RMC,)([^,]*),([AV]),([^,]*),(.?),([^,]*),(.?),([^,]*),[^,]*,(\\d{6}).*\\*\\d{2}");
if (line.Contains("RMC"))
{
// 1time, 3lat, 4NS, 5lng, 6EW, 7spd, 8cog, 9date
//$GNRMC,HHmmss.fff,A,aa.aaaa,N,aaaaa.aaaaa,E,0.00,272.57,ddMMyy,,,A,V*06
string[] parts = line.Split(',');
if (parts[2] != "A")
{
General.Log($"无效RMC(V): {line}");
continue;
}
string _time = $"{parts[9]}{(parts[1].Contains(".") ? parts[1].Substring(0, 9) : parts[1])}";
string _lat = parts[3];
bool isNorth = parts[4] == "N";
string _lng = parts[5];
bool isEast = parts[6] == "E";
string _spd = parts[7];
if (string.IsNullOrEmpty(_time) || string.IsNullOrEmpty(_lat) || string.IsNullOrEmpty(_lng) || string.IsNullOrEmpty(_spd))
{
//General.Log($"无效RMC(A): {line}");
continue;
}
else
{
if (!DateTime.TryParseExact(
_time,
"ddMMyyHHmmss.ff",
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out DateTime utc)) continue;
double lat = 0;
double lng = 0;
if (_lat.Contains('.') && _lng.Contains('.'))
{
var regLat = Regex.Match(_lat, "^(\\d+)(\\d{2}\\.\\d+)");
var regLng = Regex.Match(_lng, "^(\\d+)(\\d{2}\\.\\d+)");
if (regLat.Success && regLng.Success)
{
lat = Convert.ToInt32(regLat.Groups[1].Value) + Convert.ToDouble(regLat.Groups[2].Value) / 60;
lng = Convert.ToInt32(regLng.Groups[1].Value) + Convert.ToDouble(regLng.Groups[2].Value) / 60;
}
Volatile.Write(ref gps, new(
lat * (isNorth ? 1 : -1),
lng * (isEast ? 1 : -1),
utc,
Convert.ToDouble(_spd)));
//General.Log($"RMC: {_lng}{parts[6]}, {_lat}{parts[4]}, {_spd}, {_time}");
}
else
{
//General.Log($"无效RMC(UTC,{_time}): {line}");
}
}
}
}
});
}
}
public static void IICInit()
{
General.Log("Init IIC Devices...");
List<string> files = [];
foreach (var dirI2c in Directory.GetDirectories("/sys/bus/i2c/devices", "i2c-*"))
{
string nameFile = Path.Combine(dirI2c, "name");
var content = File.ReadAllText(nameFile).Trim();
if (content.Contains("rk3x-i2c", StringComparison.Ordinal))
{
files.Add(nameFile);
}
}
foreach (var file in files)
{
General.Log($"IIC-INIT:\t{file}");
// 系统里的IIC端口号固定不变,实际可以不经过前面的检测代码,在这里直接写死。
var match = Regex.Match(file, $"(?<=i2c-)(2|3)(?=/)");
if (match.Success)
{
Task.Run(async () =>
{
int id = Convert.ToInt32(match.Value);
I2cDevice dev = null;
try
{
var setting = new I2cConnectionSettings(id, IIC_ADDR);
dev = I2cDevice.Create(setting);
}
catch (Exception ex)
{
General.Log($"IIC-{id}: Open EXP:\t{ex.Message}");
return;
}
while (true)
{
byte[] buf = new byte[5];
try
{
dev.WriteRead([IIC_VOC], buf);
}
catch (Exception ex)
{
General.Log($"IIC-{id}: WR EXP:\t{ex.Message}");
continue;
}
double v = buf[1] << 16 | buf[2] << 8 | buf[3];
byte crc = 0xFF;
for (int i = 0; i < 4; i++)
{
crc ^= buf[i];
for (int bit = 0; bit < 8; bit++)
{
if ((crc & 0x80) != 0)
crc = (byte)((crc << 1) ^ 0x31);
else
crc <<= 1;
}
}
bool valid = crc == buf[4];
bool ready = (buf[0] & 0x01) == 0;
if (!valid)
{
General.Log($"IIC-{id}[CRC_FAIL]: \t{Convert.ToHexString(buf)},{crc:X2}");
}
else
{
if (!ready)
{
General.Log($"IIC-{id}[NOT_READY]: \t{Convert.ToHexString(buf)},{crc:X2}");
}
else
{
bool ppb = (buf[0] & 0x10) == 0x10;
if (!ppb) v /= 1000D;
General.Log($"IIC-{id}:\t{v:F6} {(ppb ? "ppb" : "mg/m3")}");
using DBHelper helper = new();
GPS? g = Volatile.Read(ref gps);
string sql = "INSERT INTO `iaq` (`created`, `type`, `val`, `unit`, `dev`, `lat`, `lng`) VALUES(" +
$"NOW(), 1, {v}, {(ppb ? 0 : 1)}, 'IIC-{id}', {g?.Latitude ?? 0}, {g?.Longitude ?? 0})";
int ret = helper.Query(sql, [], out string msg);
if (ret < 0) General.Log($"{sql}\r\n{msg}");
}
}
await Task.Delay(5000);
}
});
}
else
{
General.Log($"not IIC:\t{file}");
}
}
}
}index.cshtml。地图用OpenStreetMap+leaflet,折线图只绘制了平均值曲线,最大值和平均值差异不大,直接绘制散点,否则两条线叠一起看不清楚。
@using System.Security.Cryptography
@using System.Text.Json;
@using static sensors.Controllers.HomeController
@{
ViewData["Title"] = "Home Page";
double lat = ViewBag.Latitude;
double lng = ViewBag.Longitude;
Dictionary<string, List<IAQAvgDot>> data = ViewBag.Data;
int idx = 1;
}
@if (lat > 0 && lng > 0)
{
<div class="row">
<div class="col-12">
<div style="width: 100%; height: 400px;" id="map"></div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const lat = @lat;
const lng = @lng;
const lines = @Html.Raw(JsonSerializer.Serialize(data.Select(kv => $"{kv.Key}: {kv.Value.Last().Avg}").ToArray()));
const map = L.map('map', {
zoomControl: false,
dragging: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false
}).setView([lat, lng], 17);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
const marker = L.circleMarker([lat, lng], {
radius: 6,
color: 'red',
fillColor: 'red',
fillOpacity: 1
});
marker.bindTooltip('<strong>@DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")</strong><br />' + lines.join('<br />'),
{
permanent: true,
direction: 'top',
offset: [0, -8]
});
marker.addTo(map);
</script>
}
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script>
@foreach (var kv in data)
{
var series = kv.Value;
idx++;
<div class="card mt-4">
<div class="card-body">
<h5>@Html.Raw(kv.Key)</h5>
</div>
<div>
<canvas id="chart-@idx"></canvas>
</div>
</div>
<script>
var ctx = document.getElementById('chart-@idx').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: [@Html.Raw(string.Join(",", kv.Value.Select(d => $"'{d.Time:MM-dd HH:mm:ss}'")))],
datasets: [{
label: 'AVG',
data: [@string.Join(",", kv.Value.Select(d => d.Avg.ToString("0.######")))],
borderColor: 'rgb(75, 192, 192)',
borderWidth: 2,
fill: false
}, {
label: 'MAX',
data: [@string.Join(",", kv.Value.Select(d => d.Max.ToString("0.######")))],
backgroundColor: 'rgba(239, 68, 68, 0.8)',
borderWidth: 0,
pointBackgroundColor: '#ef4444',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 8,
pointHoverRadius: 12,
pointHoverBorderWidth: 3,
pointStyle: 'circle',
showLine: false,
}]
},
options: {
responsive: true,
scales: {
y: {
min: 0
}
}
}
});
</script>
}
只看曲线走向倒是勉强有点相关性。


接上充电宝就可以拿着到处跑。外带的时候如果想实时看数据,提前在屋里在开发板上连一次手机热点即可。
