好久没写东西了,趁周末做个简单的小手工,用上位机采集TVOC和CH₂O传器的读数并记录到数据库,同时在网页展示历史数据曲线。

设备接口用途
立创泰山派开发板上位机
CH344USBUSB转4路串口
zp16空气质量模组UARTTVOC
zp08-CH₂O模组UARTCH₂O
PS-VOC-200UARTTVOC(实际CH₂O)
WZ-SUARTCH₂O
AGS2602IICTVOC
AGS02MAIICTVOC
GT-U8UARTGPS/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>
}

只看曲线走向倒是勉强有点相关性。

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