这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 开源硬件 » 树莓派5开发方案创意赛--04成果贴

共1条 1/1 1 跳转至

树莓派5开发方案创意赛--04成果贴

助工
2026-01-05 14:02:26     打赏

(1)项目介绍

本项目是基于树莓派5的低功耗多合一空气质量监测终端。

项目概述本项目旨在构建一个低功耗、便携式的环境空气质量监测终端。系统以树莓派5为核心控制器,通过I²C总线实时采集SEK-SEN66多合一传感器的多项环境参数(包括二氧化碳浓度、氮氧化物浓度、环境温度、相对湿度、PM2.5及PM10颗粒物浓度),并结合网络时间协议(NTP)同步获取当地时间。所有数据以清晰、节能的方式刷新显示于电子墨水屏(E-Ink Display),适用于室内环境监测、办公场所健康评估、教育演示或家庭健康看板等场景。系统组成与功能模块
  1. 主控单元

  • 采用树莓派5(Raspberry Pi 5)作为主处理器,提供稳定的I²C通信能力、网络连接支持及Linux系统环境,便于开发与维护。

  1. 传感单元

  • 搭载SEK-SEN66多参数空气质量传感器模块:
    • 支持I²C通信协议,集成CO₂(NDIR原理)、NOₓ(电化学/金属氧化物)、温湿度(高精度数字传感)、PM2.5/PM10(激光散射法)检测;

    • 传感器数据通过标准寄存器读取,实时性高、精度可靠。

  1. 显示单元

  • 配置低功耗黑白两色电子墨水屏(2.13"SPI接口E-Ink屏):
    • 仅在刷新时耗电,静态显示零功耗;

    • 屏幕布局分区显示:时间、温度/湿度、CO₂ (ppm)、NOₓ (ppb)、PM2.5/PM10 (μg/m³),支持定时刷新(如每3秒–5分钟可配置)。


(2)硬件介绍(包含方案核心器件介绍)


image.png


(3)整体设计思路/功能效果(包含功能框图、软件流程图、具体思路)

1.功能框图


功能框图.png

2.软件流程图   

flow.jpg


3.具体思路


  1. 模块化与分层架构:分离了硬件驱动、数据处理、Web服务和用户界面,代码结构清晰。

  2. 低功耗与性能优化:
    1. E-ink部分刷新:仅在数据变化时局部刷新,极大降低功耗。

    2. 边缘触发指示器:状态图标仅在空气质量等级变化或每10次刷新时才更新,避免不必要的屏幕操作。

    3. 动态重采样算法:Web端从数据库抽取数据时,自动将大数据集压缩至1024点以内,保证图表性能。

  3. 鲁棒性设计:
    1. 传感器数据硬过滤:针对SEN66传感器的常见无效输出(如65535)进行了过滤,防止异常值污染数据库和界面。

    2. 线程安全的数据共享:使用threading.Lock保护全局传感器数据,防止多线程竞争。

    3. 全面的错误处理与日志:关键操作都有try-catch和logging记录。

  4. 丰富的用户交互:
    1. 三界面E-ink交互:欢迎界面、中文主界面、英文主界面。

    2. 双物理按键控制:实现语言切换和采样频率调整。

    3. 完整的Web可视化看板:支持图表缩放、平移、自动刷新,并高亮异常值。

  5. 数据持久化与可视化:
    1. SQLite3本地存储:时间序列存储所有有效数据。

    2. 专业级Web看板:使用Chart.js实现6参数并行监测,支持交互式探索。


(4)具体实现情况

  1. 通过I2C接口获取SEK-SEN66多合一传感器数据;

  2. 并且通过SPI接口点亮了电子墨水屏;

  3. 新增电子墨水屏上的按钮驱动,实现两个功能:按键A切换中文,按键更改传感器采集数据的间隔时间;

  4. 传感器数据在第一次采集时不稳定,如 VOC, NOX 数据溢出,新增过滤机制;

  5. 数据持久化,把有效的传感器数据保存到本地数据库 Sqlite3 中,并记下对应的时间点;

  6. 创建 Flask  Web 服务器,提供网页端显示传感器数据的能力;

  7. 网页端显示6个图表,分别显示 温度,湿度,二氧化碳,PM2.5, VOC, NOX 数据;

  8. 各个图标有标题,正常值范围(数据在正常值范围,线条显示为绿色,超出范围,线条显示为红色);图表部分可以缩放;X轴分段标注了时间;鼠标悬停在采样点上可以显示数据值和采样时间;

  9. 在电子墨水屏上以文字的形式显示各个传感器数值,如果有数据超出正常范围,在电子墨水屏右侧显示一个哭脸;如果数据都在正常范围,在电子墨水屏右侧显示一张笑脸;


(5)关键代码介绍
  1. 数据库模块

实现两个函数:
  • db_init() 数据库初始化,创建数据库和建立表格

  • db_save_sensor_data() 把传感器数据保存到数据库;


# ======================== 数据库模块 ========================
def db_init():
    """初始化 SQLite3 数据库"""
    with sqlite3.connect(DB_PATH) as conn:
        conn.execute(
            """CREATE TABLE IF NOT EXISTS air_data (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            time DATETIME DEFAULT (datetime('now','localtime')),
            temp REAL, humi REAL, co2 REAL, voc REAL, nox REAL, pm25 REAL
        )"""
        )
        conn.commit()
    logging.info("数据库初始化完毕")

def db_save_sensor_data(d):
    """保存有效传感器数据"""
    try:
        with sqlite3.connect(DB_PATH) as conn:
            conn.execute(
                "INSERT INTO air_data (temp, humi, co2, voc, nox, pm25) VALUES (?,?,?,?,?,?)",
                (d["temp"], d["humi"], d["co2"], d["voc"], d["nox"], d["pm25"]),
            )
            conn.commit()
    except Exception as e:
        logging.error(f"DB Error: {e}")


另外还有一个从数据库中取出数据的操作,放在 Flask 框架中:

@app.route("/api/data")
def get_sampled_data():
    """动态重采样:按需从数据库抽取最多 1024 个点"""
    start = request.args.get("start")
    end = request.args.get("end")

    with sqlite3.connect(DB_PATH) as conn:
        conn.row_factory = sqlite3.Row
        if start and end:
            rows = conn.execute(
                "SELECT * FROM air_data WHERE time BETWEEN ? AND ? ORDER BY time ASC",
                (start, end),
            ).fetchall()
        else:
            rows = conn.execute(
                "SELECT * FROM air_data WHERE time > datetime('now', '-30 day') ORDER BY time ASC"
            ).fetchall()

        raw_list = [dict(r) for r in rows]
        total = len(raw_list)
        if total <= 1024:
            return jsonify(raw_list)

        step = total / 1024.0
        return jsonify([raw_list[int(i * step)] for i in range(1024)])


5. Flask 网页服务器它实现了一个动态重采样的实时数据可视化看板。
  • get_sampled_data() 动态采样,按需从数据库抽取最多1024个点,支持时间范围查询,与前端图表缩放功能联动;

  • index() 实现了一个完整的 HTML 单页应用,核心是生成一个 3x2 的图表网格;


# ======================== Web 服务模块 =====================
app = Flask(__name__)

@app.route("/api/data")
def get_sampled_data():
    """动态重采样:按需从数据库抽取最多 1024 个点"""
    start = request.args.get("start")
    end = request.args.get("end")

    with sqlite3.connect(DB_PATH) as conn:
        conn.row_factory = sqlite3.Row
        if start and end:
            rows = conn.execute(
                "SELECT * FROM air_data WHERE time BETWEEN ? AND ? ORDER BY time ASC",
                (start, end),
            ).fetchall()
        else:
            rows = conn.execute(
                "SELECT * FROM air_data WHERE time > datetime('now', '-30 day') ORDER BY time ASC"
            ).fetchall()

        raw_list = [dict(r) for r in rows]
        total = len(raw_list)
        if total <= 1024:
            return jsonify(raw_list)

        step = total / 1024.0
        return jsonify([raw_list[int(i * step)] for i in range(1024)])

@app.route("/")
def index():
    return render_template_string(
        """
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8"><title>空气质量实时监测看板</title>
        <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom"></script>
        <style>
            body { font-family: sans-serif; background: #f4f7f6; padding: 20px; }
            .grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; width: 90%; margin: auto; }
            .card { background: white; padding: 20px; border-radius: 8px; position: relative; height: 400px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
            .btns { position: absolute; top: 10px; right: 10px; z-index: 10; }
            button { font-size: 12px; cursor: pointer; padding: 4px 10px; border: 1px solid #ccc; border-radius: 4px; background: #fff; margin-left: 5px;}
            h1 { text-align: center; color: #333; margin-bottom: 30px; }
        </style>
    </head>
    <body>
        <h1>空气质量实时监测看板</h1>
        <div class="grid" id="chart-grid"></div>
        <script>
            Chart.register(ChartZoom);
            // 配置 3x2 布局的数据模型
            const cfgArr = [
                {k:'temp', n:'温度', min:22, max:26, ymin:0, ymax:50, step:10, u:'℃'},
                {k:'humi', n:'湿度', min:40, max:60, ymin:0, ymax:100, step:10, u:'%'},
                {k:'co2',  n:'二氧化碳', min:400, max:1500, ymin:0, ymax:2500, step:200, u:'ppm'},
                {k:'pm25', n:'PM2.5', min:0, max:300, ymin:0, ymax:1000, step:100, u:'μg/m³'},
                {k:'voc',  n:'VOC', min:0, max:500, ymin:0, ymax:1000, step:100, u:''},
                {k:'nox',  n:'NOX', min:0, max:500, ymin:0, ymax:1000, step:100, u:''}
            ];
            const charts = {};
            const pauseTimers = {};

            async function fetchData(start, end) {
                let url = `/api/data${start ? `?start=${start}&end=${end}` : ''}`;
                const r = await fetch(url);
                return await r.json();
            }

            async function refresh(key, isManual=false) {
                const chart = charts[key];
                if(isManual) {
                    pauseTimers[key] = true;
                    clearTimeout(chart.pTimeout);
                    chart.pTimeout = setTimeout(() => pauseTimers[key] = false, 10000);
                }
                if(!isManual && pauseTimers[key]) return;

                const x = chart.scales.x;
                const start = chart.data.labels[Math.max(0, Math.floor(x.min))];
                const end = chart.data.labels[Math.min(chart.data.labels.length-1, Math.ceil(x.max))];
                const data = await fetchData(start, end);
                chart.data.labels = data.map(d => d.time);
                chart.data.datasets[0].data = data.map(d => d[key]);
                chart.update('none');
            }

            cfgArr.forEach(c => {
                const box = document.createElement('div');
                box.className = 'card';
                // 标题格式化:标量不加单位
                const titleStr = `${c.n}, 正常值范围: ${c.min}~${c.max}${c.u ? ' ' + c.u : ''}`;

                box.innerHTML = `<div class="btns">
                    <button onclick="charts['${c.k}'].zoom(1.1); refresh('${c.k}', true)">放大</button>
                    <button onclick="charts['${c.k}'].zoom(0.9); refresh('${c.k}', true)">缩小</button>
                    <button onclick="charts['${c.k}'].resetZoom(); pauseTimers['${c.k}']=false; refresh('${c.k}')">恢复默认视图</button>
                </div><canvas id="c-${c.k}"></canvas>`;
                document.getElementById('chart-grid').appendChild(box);

                const ctx = document.getElementById(`c-${c.k}`).getContext('2d');
                charts[c.k] = new Chart(ctx, {
                    type: 'line',
                    data: { labels: [], datasets: [{
                        label: titleStr, data: [], pointRadius: 0, borderWidth: 2, tension: 0.1,
                        segment: { borderColor: ctx => (ctx.p0.parsed.y < c.min || ctx.p0.parsed.y > c.max) ? 'red' : 'green' }
                    }]},
                    options: {
                        responsive: true, maintainAspectRatio: false,
                        onHover: () => { pauseTimers[c.k]=true; clearTimeout(charts[c.k].pTimeout); charts[c.k].pTimeout = setTimeout(()=>pauseTimers[c.k]=false, 10000); },
                        plugins: {
                            legend: { labels: { font: { size: 20 } } },
                            tooltip: { callbacks: { label: (ctx) => `时间: ${ctx.label} | 数值: ${ctx.raw.toFixed(3)} ${c.u}` } },
                            zoom: {
                                pan: { enabled: true, mode: 'x', onPanComplete: () => refresh(c.k, true) },
                                zoom: { wheel: { enabled: true }, mode: 'x', onZoomComplete: () => refresh(c.k, true) }
                            }
                        },
                        scales: {
                            y: { min: c.ymin, max: c.ymax, ticks: { stepSize: c.step } },
                            x: { ticks: {
                                maxRotation: 45, minRotation: 45, autoSkip: false,
                                callback: function(val, idx) { return (idx % 100 === 0) ? this.getLabelForValue(val).split(' ')[0] : ''; }
                            }}
                        }
                    }
                });
                refresh(c.k);
            });
            setInterval(() => { cfgArr.forEach(c => refresh(c.k)); }, 3000);
        </script>
    </body>
    </html>
    """
    )


6. 传感器数据状态指示根据空气质量判断结果(is_good),在屏幕固定位置(坐标(200, 40))显示对应表情图标(笑脸/哭脸),并严格控制刷新频率。三种触发条件:
  • is_good != g_ui_last_indicator_state 状态变化触发,空气质量从“良好”变为“不良”或反之,立即更新图标已提供关键反馈;

  • g_ui_indicator_refresh_count >= 10 周期保活触发,即使状态长期不变,每第10次调用也强制刷新一次,防止因物流残留或微小电荷变化导致显示模糊;

  • is_force 强制刷新触发,外部调用者(如 draw_gui 函数在全屏刷新时)要求无条件刷新,确保显示一致性;


def sensor_result_indicate(is_good, is_force=False):
    """边缘触发更新图片。is_good 改变或每10次刷新执行一次"""
    global g_ui_last_indicator_state, g_ui_indicator_refresh_count
    g_ui_indicator_refresh_count += 1
    if (
        is_good != g_ui_last_indicator_state
        or g_ui_indicator_refresh_count >= 10
        or is_force
    ):
        img_path = HAPPY_IMG if is_good else UNHAPPY_IMG
        eink.bitmap_file(200, 40, img_path)
        eink.flush(eink.PART)
        g_ui_last_indicator_state = is_good
        g_ui_indicator_refresh_count = 0
7. 传感器读取线程

初始化 SEK-SEN66 传感器,并每隔一定时间采集数据,数据有效则保存到数据库中。

def check_sensor_data_valid(d):
    """过滤无效大值数据,不保存不显示"""
    # 针对 SEN66 的常见无效输出进行硬过滤
    if d["pm25"] >= 65535 or d["co2"] >= 65535 or d["voc"] >= 3276 or d["nox"] >= 3276:
        return False
    return True

def sensor_worker():
    """传感器采集后台线程"""
    global g_sensor_latest_data, g_sensor_sample_interval_id
    sen = sen66lib(os.path.join(BASE_DIR, "libs/sen66.so"))
    sen.initialize_sensor()
    sen.start_continuous_measurement()

    while True:
        raw = sen.read_measured_values()
        processed = {
            "temp": raw["temperature"],
            "humi": raw["humidity"],
            "co2": raw["co2"],
            "voc": raw["voc_index"],
            "nox": raw["nox_index"],
            "pm25": raw["mass_concentration_pm2p5"],
        }
        if check_sensor_data_valid(processed):
            with g_sensor_data_lock:
                g_sensor_latest_data = processed
            db_save_sensor_data(processed)
        time.sleep(g_sensor_sample_intervals[g_sensor_sample_interval_id])


8. 电子墨水屏界面绘制draw_gui 函数是人机交互系统的核心绘制引擎,负责将传感器数据、界面状态和硬件特性高效结合,在低功耗屏幕上实现清晰的多语言界面。

根据指定的刷新模式和语言,将当前空气质量数据以格式化文本和状态图标的形式绘制到电子墨水屏上。

def draw_gui(mode,):
    """Eink GUI 绘制核心方法"""
    global g_sensor_latest_data
    with g_sensor_data_lock:
        d = g_sensor_latest_data.copy()

    if mode == eink.FULL:
        eink.clear_screen()

    def fmt(val, key):
        if not d:
            return "--".ljust(6)
        # 温度、湿度为浮点;其余显示为整数
        res = f"{val:.2f}" if key in ["temp", "humi"] else str(int(val))
        return res[:6].ljust(6)

    y = 0
    items = [
        ("温度(℃):", "Temp(℃):", "temp"),
        ("湿度(%):", "Humi(%):", "humi"),
        ("二氧化碳:", "CO2(ppm):", "co2"),
        ("PM2.5:", "PM2.5:", "pm25"),
        ("VOC指数:", "VOC Index:", "voc"),
        ("NOx指数:", "NOx Index:", "nox"),
    ]
    for cn, en, k in items:
        eink.set_text_cursor(0, y)
        eink.print_str(cn if lang == "cn" else en)
        eink.set_text_cursor(120, y)
        eink.print_str(fmt(d.get(k, 0.0), k))
        y += 20

    if d:
        is_good = (
            THRESHOLDS["temp"][0] <= d["temp"] <= THRESHOLDS["temp"][1]
            and THRESHOLDS["humi"][0] <= d["humi"] <= THRESHOLDS["humi"][1]
            and THRESHOLDS["co2"][0] <= d["co2"] <= THRESHOLDS["co2"][1]
            and THRESHOLDS["pm25"][0] <= d["pm25"] <= THRESHOLDS["pm25"][1]
        )
        sensor_result_indicate(is_good, is_force=(mode == eink.FULL))
    eink.flush(mode)


(6)功能展示(包含成果视频,或GIF文件)


视频链接:

https://www.bilibili.com/video/BV16hiEBrECt/



图片展示:

IMG_20251227_132504.jpg


image.png

image.png

image.png


(7)技术难点与解决方案/心得体会


此项目的意义:
  1. 居住环境的“健康哨兵”,室内空气质量直接影响人的认知能力、睡眠质量和呼吸系统健康;

  2. 基准数据驱动的科学决策,该系统通过 SQLite3 数据库和实时看板提供了历史分析能力;

  3. 边缘计算与隐私保护,所有传感器数据都存储在本地树莓派中,不上传至云端;









关键词: 树莓派5    

共1条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]