From 0bb425233f5e1af5debe0da276718211f88ebf5c Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Thu, 30 Jul 2026 22:54:07 +0800 Subject: [PATCH 1/3] feat: initial submit of the Mock light-meters --- examples/light-meter/.gitignore | 84 ++++ examples/light-meter/CMakeLists.txt | 47 +++ examples/light-meter/README.md | 65 +++ examples/light-meter/main.cpp | 10 + examples/light-meter/mainwindow.cpp | 385 ++++++++++++++++++ examples/light-meter/mainwindow.h | 100 +++++ .../sensor/mocked/mockedsensor.cpp | 108 +++++ .../light-meter/sensor/mocked/mockedsensor.h | 31 ++ examples/light-meter/sensor/sensor.cpp | 3 + examples/light-meter/sensor/sensor.h | 41 ++ examples/light-meter/ui/breathing_overlay.cpp | 31 ++ examples/light-meter/ui/breathing_overlay.h | 26 ++ examples/light-meter/ui/chart_view.cpp | 123 ++++++ examples/light-meter/ui/chart_view.h | 40 ++ 14 files changed, 1094 insertions(+) create mode 100644 examples/light-meter/.gitignore create mode 100644 examples/light-meter/CMakeLists.txt create mode 100644 examples/light-meter/README.md create mode 100644 examples/light-meter/main.cpp create mode 100644 examples/light-meter/mainwindow.cpp create mode 100644 examples/light-meter/mainwindow.h create mode 100644 examples/light-meter/sensor/mocked/mockedsensor.cpp create mode 100644 examples/light-meter/sensor/mocked/mockedsensor.h create mode 100644 examples/light-meter/sensor/sensor.cpp create mode 100644 examples/light-meter/sensor/sensor.h create mode 100644 examples/light-meter/ui/breathing_overlay.cpp create mode 100644 examples/light-meter/ui/breathing_overlay.h create mode 100644 examples/light-meter/ui/chart_view.cpp create mode 100644 examples/light-meter/ui/chart_view.h diff --git a/examples/light-meter/.gitignore b/examples/light-meter/.gitignore new file mode 100644 index 000000000..ca8b61ee5 --- /dev/null +++ b/examples/light-meter/.gitignore @@ -0,0 +1,84 @@ +# This file is used to ignore files which are generated +# ---------------------------------------------------------------------------- + +*~ +*.autosave +*.a +*.core +*.moc +*.o +*.obj +*.orig +*.rej +*.so +*.so.* +*_pch.h.cpp +*_resource.rc +*.qm +.#* +*.*# +core +!core/ +tags +.DS_Store +.directory +*.debug +Makefile* +*.prl +*.app +moc_*.cpp +ui_*.h +qrc_*.cpp +Thumbs.db +*.res +*.rc +/.qmake.cache +/.qmake.stash +**/.qmlls.ini + +# qtcreator generated files +*.pro.user* +*.qbs.user* +CMakeLists.txt.user* + +# xemacs temporary files +*.flc + +# Vim temporary files +.*.swp + +# Visual Studio generated files +*.ib_pdb_index +*.idb +*.ilk +*.pdb +*.sln +*.suo +*.vcproj +*vcproj.*.*.user +*.ncb +*.sdf +*.opensdf +*.vcxproj +*vcxproj.* + +# MinGW generated files +*.Debug +*.Release + +# Python byte code +*.pyc + +# Binaries +# -------- +*.dll +*.exe + +# Directories with generated files +.moc/ +.obj/ +.pch/ +.rcc/ +.uic/ +/build*/ +/.qtcreator/ diff --git a/examples/light-meter/CMakeLists.txt b/examples/light-meter/CMakeLists.txt new file mode 100644 index 000000000..241c005ef --- /dev/null +++ b/examples/light-meter/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.19) +project(light-meter LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Widgets) + +qt_standard_project_setup() + +qt_add_executable(light-meter + WIN32 MACOSX_BUNDLE + main.cpp + mainwindow.cpp + mainwindow.h + ui/breathing_overlay.cpp + ui/breathing_overlay.h + ui/chart_view.cpp + ui/chart_view.h + sensor/sensor.h sensor/sensor.cpp + sensor/mocked/mockedsensor.h sensor/mocked/mockedsensor.cpp +) + +target_include_directories(light-meter PUBLIC sensor/) + +# 源文件为 UTF-8(无 BOM), 含中文; 告知 MSVC 以 UTF-8 解释源与执行字符集。 +if(MSVC) + target_compile_options(light-meter PRIVATE /utf-8) +endif() + +target_link_libraries(light-meter + PRIVATE + Qt::Core + Qt::Widgets +) + +install(TARGETS light-meter + BUNDLE DESTINATION . + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} +) + +qt_generate_deploy_app_script( + TARGET light-meter + OUTPUT_SCRIPT deploy_script + NO_UNSUPPORTED_PLATFORM_ERROR +) +install(SCRIPT ${deploy_script}) diff --git a/examples/light-meter/README.md b/examples/light-meter/README.md new file mode 100644 index 000000000..f7d50226f --- /dev/null +++ b/examples/light-meter/README.md @@ -0,0 +1,65 @@ +# light-meter —— 桌面照度护眼摆件(Mock) + +一个常驻工位的小摆件:监测桌面环境光照度 `lux`(国标 GB 50034 规定书桌阅读 ≥ **300 lux**, +低于此值界面变红提醒开灯),并用接近度 `ps` 实现「手靠近 → 唤醒亮屏;人离开 ~10s → 息屏」。 + +本阶段为 **Mock**:数据由 `MockedSensor` 产生(正弦波模拟昼夜波动 + 随机抖动), +**不接真硬件**,Windows + Linux 双平台原生可跑。 + +## 功能 + +| 档 | 功能 | 说明 | +|---|---|---| +| P0 | ALS 折线 + lux 大数字 | 当前 lux 大字号 + 30s 滚动折线(自绘 `ChartView`,非 Qt Charts) | +| P0 | 暗光告警 | `lux < 阈值` → 左侧数字区翻红底白字 + 「光线不足,建议开灯」 | +| P0 | 接近唤醒 / 离开息屏 | `ps > 500` → 唤醒;无接近 10s → 全黑呼吸点息屏 | +| P1 | CSV 导出 | 导出 `timestamp,lux` 全量会话到文件 | +| P2 | 暂停 / 继续 | 手动停 / 继采样 | +| P2 | 告警阈值可调 | 滑杆即时改阈值(默认 300) | + +> lux 真值换算(raw ALS × 系数)属真机阶段,Mock 直接产物理量。 + +## 构建(Windows / Linux 相同) + +```bash +# 前提:Qt6 已安装且 CMAKE_PREFIX_PATH 指向它(或 Qt 在 PATH) +cmake -B build -DCMAKE_PREFIX_PATH="<你的 Qt 路径,如 C:/Qt/6.x.x/msvc2022_64>" +cmake --build build --config Release + +# Linux: ./build/light-meter +# Windows: build\Release\light-meter.exe +``` + +## 键位 / 操作 + +| 操作 | 效果 | +|---|---| +| **按住 空格** | 模拟「手靠近」→ `ps` 升高、状态点变化;息屏时瞬间唤醒 | +| **🌙 息屏** | 手动立即进入息屏态(按空格唤醒) | +| **自动息屏(复选框)** | **默认关闭**;勾选后手离开 ~10s 自动息屏 | +| **⏸ 暂停 / ▶ 继续** | 停 / 继采样(折线停止滚动) | +| **📁 导出 CSV** | 把整段会话导出为 `timestamp,lux` | +| **阈值滑杆** | 调整告警线(100–700),即时生效 | + +## 目录结构 + +``` +light-meter/ +├── CMakeLists.txt +├── main.cpp # Qt 入口 +├── mainwindow.{h,cpp} # 三态 UI + 键盘 + 息屏状态机 + CSV 导出(纯代码, 无 .ui) +├── ui/ # 自绘控件(一类一文件) +│ ├── chart_view.{h,cpp} # QPainter 折线(环形缓冲, 局部刷新) +│ └── breathing_overlay.{h,cpp}# 息屏遮罩(全黑 + 呼吸点) +├── sensor/ +│ ├── sensor.{h,cpp} # 抽象数据源契约(pull 模型, std::expected) +│ └── mocked/ +│ └── mockedsensor.{h,cpp} # Mock 数据(正弦 lux + 接近度) +└── README.md +``` + +## 三态如何验证 + +- **运行态**:启动即进入。折线每 200ms 更新,lux 按 ~25s 周期正弦起伏(绿色)。 +- **告警态**:lux 自然跌破阈值时,左侧数字卡翻红;升回阈值以上恢复绿色。 +- **息屏态**:点「🌙 息屏」立即息屏(全屏黑 + 中央呼吸点);或勾「自动息屏」后松开空格约 10s 自动息屏。按空格瞬间唤醒。 diff --git a/examples/light-meter/main.cpp b/examples/light-meter/main.cpp new file mode 100644 index 000000000..9ce26e7b3 --- /dev/null +++ b/examples/light-meter/main.cpp @@ -0,0 +1,10 @@ +#include "mainwindow.h" + +#include + +int main(int argc, char* argv[]) { + QApplication a(argc, argv); + MainWindow w; + w.show(); + return QApplication::exec(); +} diff --git a/examples/light-meter/mainwindow.cpp b/examples/light-meter/mainwindow.cpp new file mode 100644 index 000000000..c8b745a73 --- /dev/null +++ b/examples/light-meter/mainwindow.cpp @@ -0,0 +1,385 @@ +#include "mainwindow.h" +#include "ui/breathing_overlay.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ───────────────────────────────────────────────────────────────────────────── +namespace { +constexpr auto kCssWindow = + "QMainWindow, QSplitter { background:#1e1e2e; }" + "QSplitter::handle { background:#11111b; }"; + +constexpr auto kCssLeftPanel = + "QWidget#left_panel { background:#1e1e2e; }" + "QLabel { color:#cdd6f4; }" + "QCheckBox { color:#cdd6f4; spacing:6px; }" + "QPushButton { background:#313244; color:#cdd6f4; border:1px solid #45475a;" + " border-radius:6px; padding:8px; font-size:13px; }" + "QPushButton:hover { background:#45475a; }" + "QPushButton:pressed { background:#181825; }" + "QSlider::groove:horizontal { height:6px; background:#313244; border-radius:3px; }" + "QSlider::handle:horizontal { width:16px; margin:-6px 0; background:#cdd6f4; border-radius:8px; }" + "QProgressBar { background:#313244; border:none; border-radius:5px; height:12px; }" + "QProgressBar::chunk { background:#27ae60; border-radius:5px; }"; +} // namespace + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) { + setWindowTitle(QStringLiteral("桌面照度 · light-meter")); + resize(1024, 600); + + // 数据源(用户实现的 pull 模型): 先 init, 之后由 200ms 定时器 query_once。 + m_sensor = std::make_unique(); + m_sensor->init(false); // 注: override 不继承基类默认参数, 显式传 false + + buildUi(); + applyStyles(); + + // 采样定时器(200ms) + m_sampleTimer = new QTimer(this); + m_sampleTimer->setTimerType(Qt::PreciseTimer); + connect(m_sampleTimer, &QTimer::timeout, this, &MainWindow::onSampleTick); + m_sampleTimer->start(kSampleMs); + + // 息屏倒计时(单次) + m_idleTimer = new QTimer(this); + m_idleTimer->setSingleShot(true); + connect(m_idleTimer, &QTimer::timeout, this, &MainWindow::enterScreenOff); + + // 呼吸动画(~50ms) + m_breathTimer = new QTimer(this); + connect(m_breathTimer, &QTimer::timeout, this, &MainWindow::onBreathTick); + + // 息屏遮罩(铺满整个窗口, 盖住菜单栏/状态栏); 任意鼠标点击即唤醒 + m_overlay = new BreathingOverlay(this); + m_overlay->installEventFilter(this); +} + +MainWindow::~MainWindow() = default; + +// ── UI 搭建 ────────────────────────────────────────────────────────────────── +void MainWindow::buildUi() { + // 左右分栏: 左 ~330(数字+控制, 低频), 右栏折线(唯一高频刷新区) + auto *splitter = new QSplitter(Qt::Horizontal, this); + splitter->setChildrenCollapsible(false); + auto *leftPanel = new QWidget(splitter); + leftPanel->setObjectName("left_panel"); + auto *rightPanel = new QWidget(splitter); + rightPanel->setObjectName("right_lux_widget"); + splitter->setSizes({330, 694}); + setCentralWidget(splitter); + + // ── 左栏 ── + auto *left = new QVBoxLayout(leftPanel); + left->setContentsMargins(16, 16, 16, 16); + left->setSpacing(10); + + m_clock = new QLabel(QStringLiteral("桌面照度"), leftPanel); + QFont f = m_clock->font(); f.setPointSize(13); m_clock->setFont(f); + left->addWidget(m_clock); + + m_statusLine = new QLabel(QStringLiteral("● 运行"), leftPanel); + left->addWidget(m_statusLine); + + // 数字卡片(lux 大数字 + 进度条 + 副提示), 运行/告警态靠它翻色 + m_numberCard = new QFrame(leftPanel); + m_numberCard->setObjectName("numberCard"); + auto *cardLay = new QVBoxLayout(m_numberCard); + cardLay->setContentsMargins(14, 14, 14, 14); + cardLay->setSpacing(8); + + m_bigLux = new QLabel(QStringLiteral("— lux"), m_numberCard); + QFont bf = m_bigLux->font(); bf.setPointSize(40); bf.setBold(true); + m_bigLux->setFont(bf); + m_bigLux->setAlignment(Qt::AlignCenter); + cardLay->addWidget(m_bigLux); + + m_bar = new QProgressBar(m_numberCard); + m_bar->setRange(0, 100); + m_bar->setTextVisible(false); + cardLay->addWidget(m_bar); + + m_subText = new QLabel(QStringLiteral("明亮 ✓"), m_numberCard); + m_subText->setAlignment(Qt::AlignCenter); + QFont sf = m_subText->font(); sf.setPointSize(12); + m_subText->setFont(sf); + cardLay->addWidget(m_subText); + + left->addWidget(m_numberCard); + + m_pauseBtn = new QPushButton(QStringLiteral("⏸ 暂停"), leftPanel); + m_pauseBtn->setCheckable(true); + m_pauseBtn->setFocusPolicy(Qt::NoFocus); // 不抢焦点: 空格留给"手靠近" + connect(m_pauseBtn, &QPushButton::toggled, this, &MainWindow::onTogglePause); + left->addWidget(m_pauseBtn); + + m_exportBtn = new QPushButton(QStringLiteral("📁 导出 CSV"), leftPanel); + m_exportBtn->setFocusPolicy(Qt::NoFocus); + connect(m_exportBtn, &QPushButton::clicked, this, &MainWindow::onExportCsv); + left->addWidget(m_exportBtn); + + // 阈值滑杆行 + auto *thrRow = new QHBoxLayout; + thrRow->setSpacing(8); + auto *thrLabel = new QLabel(QStringLiteral("阈值"), leftPanel); + m_thresholdSlider = new QSlider(Qt::Horizontal, leftPanel); + m_thresholdSlider->setFocusPolicy(Qt::NoFocus); + m_thresholdSlider->setRange(100, 700); + m_thresholdSlider->setValue(int(m_threshold)); + m_thresholdValue = new QLabel(QString::number(int(m_threshold)), leftPanel); + m_thresholdValue->setMinimumWidth(28); + thrRow->addWidget(thrLabel); + thrRow->addWidget(m_thresholdSlider); + thrRow->addWidget(m_thresholdValue); + left->addLayout(thrRow); + connect(m_thresholdSlider, &QSlider::valueChanged, this, &MainWindow::onThresholdChanged); + + // 息屏控制: 自动息屏开关(默认关) + 手动息屏 + auto *sleepRow = new QHBoxLayout; + sleepRow->setSpacing(8); + m_autoSleepCheck = new QCheckBox(QStringLiteral("自动息屏"), leftPanel); + m_autoSleepCheck->setFocusPolicy(Qt::NoFocus); + m_autoSleepCheck->setToolTip(QStringLiteral("开启后: 手离开 ~10s 自动息屏(默认关闭)")); + m_sleepBtn = new QPushButton(QStringLiteral("🌙 息屏"), leftPanel); + m_sleepBtn->setFocusPolicy(Qt::NoFocus); + sleepRow->addWidget(m_autoSleepCheck); + sleepRow->addWidget(m_sleepBtn); + left->addLayout(sleepRow); + connect(m_autoSleepCheck, &QCheckBox::toggled, this, &MainWindow::onAutoSleepToggled); + connect(m_sleepBtn, &QPushButton::clicked, this, &MainWindow::onManualSleep); + + left->addStretch(1); + + auto *hint = new QLabel(QStringLiteral("按住 空格 = 手靠近\n息屏后: 按空格 或 鼠标点击 唤醒"), + leftPanel); + hint->setStyleSheet("color:#7f8c8d; font-size:11px;"); + left->addWidget(hint); + + // ── 右栏: 折线图 ── + auto *right = new QVBoxLayout(rightPanel); + right->setContentsMargins(8, 8, 8, 8); + m_chart = new ChartView(rightPanel); + m_chart->setThreshold(float(m_threshold)); + right->addWidget(m_chart); +} + +void MainWindow::applyStyles() { + setStyleSheet(QString(kCssWindow) + kCssLeftPanel); + // 数字卡片默认态(绿) + m_numberCard->setStyleSheet( + "QFrame#numberCard { background:#181825; border:1px solid #313244; border-radius:10px; }" + "QLabel { color:#a6e3a1; }"); +} + +// ── 采样 ───────────────────────────────────────────────────────────────────── +void MainWindow::onSampleTick() { + if (m_paused) return; // 暂停: 不采样(空格的唤醒/息屏由键盘事件独立处理) + + // 推进 Mock 的昼夜相位, 再拉一次数据 + m_phase += kPhaseStep; + m_sensor->set_phase(m_phase); + + auto res = m_sensor->query_once(); + if (!res) { + // 极少情况下 NotInited: 尝试重初始化一次 + m_sensor->init(true); + res = m_sensor->query_once(); + if (!res) return; + } + processSample(res->luxury, res->ps); +} + +void MainWindow::processSample(double lux, int ps) { + m_lastLux = lux; + m_chart->pushSample(float(lux)); + m_history.append({QDateTime::currentMSecsSinceEpoch(), float(lux)}); + if (m_history.size() > kMaxHistory) // 常驻应用: 限制 CSV 历史内存 + m_history.remove(0, m_history.size() - kMaxHistory); + + // 时钟 + m_clock->setText(QTime::currentTime().toString(QStringLiteral("HH:mm"))); + + // 大数字 + 进度条 + m_bigLux->setText(QString::number(qRound(lux)) + QStringLiteral(" lux")); + m_bar->setValue(qBound(0, int(lux / kLuxYMax * 100.0), 100)); + + // 告警着色变体 + setAlarmMode(lux < m_threshold); + + // ps 只驱动唤醒/息屏 + resetIdleCountdown(ps); +} + +void MainWindow::setAlarmMode(bool alarm) { + if (alarm) { + m_numberCard->setStyleSheet( + "QFrame#numberCard { background:#c0392b; border:1px solid #e74c3c; border-radius:10px; }" + "QLabel { color:#ffffff; }"); + m_subText->setText(QStringLiteral("光线不足,建议开灯")); + m_statusLine->setText(QStringLiteral("● 告警")); + m_statusLine->setStyleSheet("color:#e74c3c;"); + } else { + m_numberCard->setStyleSheet( + "QFrame#numberCard { background:#181825; border:1px solid #313244; border-radius:10px; }" + "QLabel { color:#a6e3a1; }"); + m_subText->setText(QStringLiteral("明亮 ✓")); + m_statusLine->setText(QStringLiteral("● 运行")); + m_statusLine->setStyleSheet("color:#a6e3a1;"); + } +} + +// ── 接近/息屏状态机 ───────────────────────────────────────────────────────── +void MainWindow::resetIdleCountdown(int ps) { + const bool near = ps > kPsWakeThreshold; + if (near) { + if (m_screenOff) exitScreenOff(); + if (m_idleTimer->isActive()) m_idleTimer->stop(); + } else { + // 开启"自动息屏"后: 无接近即启动 10s 倒计时(含刚启动的头 10s)。 + // 已在计时则不重启, 否则每个 tick(200ms)都重置, 永远到不了 10s。 + if (m_autoSleep && !m_idleTimer->isActive()) + m_idleTimer->start(kScreenOffMs); + } +} + +void MainWindow::enterScreenOff() { + if (m_screenOff) return; + m_screenOff = true; + m_statusLine->setText(QStringLiteral("● 息屏")); + m_overlay->setGeometry(rect()); // 盖住整窗(含菜单栏/状态栏) + m_overlay->raise(); + m_overlay->show(); + m_breathPhase = 0.0; + m_breathTimer->start(50); +} + +void MainWindow::exitScreenOff() { + if (!m_screenOff) return; + m_screenOff = false; + m_breathTimer->stop(); + m_overlay->hide(); + // 恢复运行/告警标签 + setAlarmMode(m_lastLux < m_threshold); +} + +void MainWindow::onBreathTick() { + m_breathPhase += 0.02; // ~2.5s 一个呼吸周期 + m_overlay->setBreath(float(m_breathPhase)); +} + +// ── 键盘: 空格模拟"手靠近" ───────────────────────────────────────────────── +void MainWindow::keyPressEvent(QKeyEvent *event) { + if (event->key() == Qt::Key_Space && !event->isAutoRepeat()) { + m_sensor->set_held(true); // 按下 = 手靠近 + if (m_screenOff) exitScreenOff(); // 瞬间唤醒 + if (m_idleTimer->isActive()) m_idleTimer->stop(); // 不必等下一个 tick + } + QMainWindow::keyPressEvent(event); +} + +void MainWindow::keyReleaseEvent(QKeyEvent *event) { + if (event->key() == Qt::Key_Space && !event->isAutoRepeat()) { + m_sensor->set_held(false); // 松开 = 手离开 + // 开启"自动息屏"时: 松开(即便暂停)直接启动 10s 倒计时 → 到点息屏。 + if (m_autoSleep && !m_idleTimer->isActive()) m_idleTimer->start(kScreenOffMs); + } + QMainWindow::keyReleaseEvent(event); +} + +void MainWindow::resizeEvent(QResizeEvent *event) { + QMainWindow::resizeEvent(event); + if (m_overlay && m_overlay->isVisible()) + m_overlay->setGeometry(rect()); +} + +bool MainWindow::eventFilter(QObject *watched, QEvent *event) { + // 息屏态: 任意鼠标点击 → 无条件唤醒(不区分按键/位置) + if (watched == m_overlay && event->type() == QEvent::MouseButtonPress) { + exitScreenOff(); + return true; // 事件到此为止, 不再下发 + } + return QMainWindow::eventFilter(watched, event); +} + +// ── 暂停 / 阈值 / 导出 ────────────────────────────────────────────────────── +void MainWindow::onTogglePause(bool checked) { + m_paused = checked; + m_pauseBtn->setText(checked ? QStringLiteral("▶ 继续") : QStringLiteral("⏸ 暂停")); + if (checked) { + m_statusLine->setText(QStringLiteral("● 已暂停")); + } else if (!m_screenOff) { + setAlarmMode(m_lastLux < m_threshold); // 立即恢复运行/告警标签 + } +} + +void MainWindow::onThresholdChanged(int value) { + m_threshold = double(value); + m_thresholdValue->setText(QString::number(value)); + m_chart->setThreshold(float(m_threshold)); + setAlarmMode(m_lastLux < m_threshold); // 即时重评估告警 +} + +void MainWindow::onAutoSleepToggled(bool checked) { + m_autoSleep = checked; + if (!checked && m_idleTimer->isActive()) + m_idleTimer->stop(); // 关闭自动息屏: 取消进行中的倒计时 +} + +void MainWindow::onManualSleep() { + enterScreenOff(); // 立即进入息屏态(按空格唤醒) +} + +void MainWindow::onExportCsv() { + if (m_history.isEmpty()) { + statusBar()->showMessage(QStringLiteral("暂无数据可导出"), 3000); + return; + } + const QString docs = + QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + const QString stamp = QDateTime::currentDateTime() + .toString(QStringLiteral("yyyyMMdd_HHmmss")); + const QString def = + QDir(docs).filePath(QStringLiteral("lightmeter_%1.csv").arg(stamp)); + + const QString path = + QFileDialog::getSaveFileName(this, QStringLiteral("导出 CSV"), def, + QStringLiteral("CSV (*.csv)")); + if (path.isEmpty()) return; + + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + QMessageBox::warning(this, QStringLiteral("导出失败"), + QStringLiteral("无法写入文件:\n%1").arg(path)); + return; + } + QTextStream s(&f); + s << "timestamp,lux\n"; + for (const auto &row : m_history) { + const QString ts = QDateTime::fromMSecsSinceEpoch(row.first) + .toString(Qt::ISODateWithMs); + s << ts << ',' << QString::number(row.second, 'f', 1) << '\n'; + } + s.flush(); + f.close(); + + statusBar()->showMessage( + QStringLiteral("已导出 %1 条 → %2").arg(m_history.size()).arg(path), 5000); +} diff --git a/examples/light-meter/mainwindow.h b/examples/light-meter/mainwindow.h new file mode 100644 index 000000000..decbc1ba7 --- /dev/null +++ b/examples/light-meter/mainwindow.h @@ -0,0 +1,100 @@ +#ifndef MAINWINDOW_H +#define MAINWINDOW_H + +#include +#include + +#include + +#include "ui/chart_view.h" +#include "mocked/mockedsensor.h" + +QT_BEGIN_NAMESPACE +class QCheckBox; +class QFrame; +class QLabel; +class QProgressBar; +class QPushButton; +class QSlider; +class QTimer; +QT_END_NAMESPACE + +class BreathingOverlay; + +/// 三态: 运行 / 告警(运行态着色变体) / 息屏。ps(接近度)只在幕后驱动唤醒/息屏。 +class MainWindow : public QMainWindow { + Q_OBJECT + + public: + explicit MainWindow(QWidget *parent = nullptr); + ~MainWindow() override; + + protected: + void keyPressEvent(QKeyEvent *event) override; + void keyReleaseEvent(QKeyEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; + + private slots: + void onSampleTick(); + void onTogglePause(bool checked); + void onExportCsv(); + void onThresholdChanged(int value); + void onBreathTick(); + void onAutoSleepToggled(bool checked); + void onManualSleep(); + + private: + void buildUi(); + void applyStyles(); + + void processSample(double lux, int ps); + void setAlarmMode(bool alarm); + void enterScreenOff(); + void exitScreenOff(); + void resetIdleCountdown(int ps); + + // 集中可调参数(见 tasks.md §10 ✅) + static constexpr int kSampleMs = 200; // 采样周期 + static constexpr double kLuxYMax = 800.0; // Y 轴上界 + static constexpr double kPhaseStep = 0.05; // 每 tick 正弦相位步进(~25s 周期) + static constexpr int kPsWakeThreshold = 500; // ps>500 视为"有接近" + static constexpr int kScreenOffMs = 10000; // 无接近 10s → 息屏 + static constexpr int kMaxHistory = 18000; // CSV 历史上限(~1h @5Hz), 防 OOM + + std::unique_ptr m_sensor; + + ChartView *m_chart = nullptr; + QTimer *m_sampleTimer = nullptr; // 200ms 周期采样 + QTimer *m_idleTimer = nullptr; // 单次: 无接近 10s → 息屏 + QTimer *m_breathTimer = nullptr; // 息屏呼吸动画 + + // 左栏控件 + QFrame *m_numberCard = nullptr; + QLabel *m_clock = nullptr; + QLabel *m_statusLine = nullptr; + QLabel *m_bigLux = nullptr; + QProgressBar *m_bar = nullptr; + QLabel *m_subText = nullptr; + QPushButton *m_pauseBtn = nullptr; + QPushButton *m_exportBtn = nullptr; + QPushButton *m_sleepBtn = nullptr; + QCheckBox *m_autoSleepCheck = nullptr; + QSlider *m_thresholdSlider = nullptr; + QLabel *m_thresholdValue = nullptr; + + BreathingOverlay *m_overlay = nullptr; // 息屏全屏黑 + 呼吸点 + + double m_phase = 0.0; + double m_threshold = 300.0; // 告警阈值(可由滑杆改) + bool m_paused = false; + bool m_screenOff = false; + bool m_autoSleep = false; // 自动息屏开关(默认关; 开启后无接近 10s 自动息屏) + double m_lastLux = kLuxYMax; + double m_breathPhase = 0.0; + + // 完整会话历史(用于 CSV 导出): { 毫秒时间戳, lux } + QVector> m_history; +}; + +#endif // MAINWINDOW_H diff --git a/examples/light-meter/sensor/mocked/mockedsensor.cpp b/examples/light-meter/sensor/mocked/mockedsensor.cpp new file mode 100644 index 000000000..733e3c57d --- /dev/null +++ b/examples/light-meter/sensor/mocked/mockedsensor.cpp @@ -0,0 +1,108 @@ +#include "mockedsensor.h" +#include +#include + +namespace { +class Random { + public: + Random() : m_rng(std::random_device{}()) {} + int int_range(int min, int max) { + std::uniform_int_distribution dist(min, max); + return dist(m_rng); + } + private: + std::mt19937 m_rng; // 引擎重用,避免重复初始化 +}; +} + +struct LuxSource { + public: + LuxSource() : random_source(std::make_unique()){} + void setPhase(double phase_) { + phase = phase_; + } + // 契约: lux ∈ [50, 800]。正弦波 + 抖动, clamp 到契约区间。 + double fetch_lux() const { + return std::clamp(400 + 350*std::sin(phase) + + random_source->int_range(-30, 30), 50.0, 800.0); + } + private: + double phase {0.0}; + std::unique_ptr random_source; +}; + +struct PsSource { + public: + PsSource() : random_source(std::make_unique()){} + void set_held(bool is_held_) { + is_held = is_held_; + } + // 契约: ps 0~1023, >500 视为"有接近"(手靠近)。 + // 手靠近(held) → 高值; 手离开 → 低值, 以便 UI 据此 10s 后息屏。 + int fetch_ps() const { + if (is_held) + return 800 + random_source->int_range(0, 40); // 800~840, >500 近 + return random_source->int_range(0, 40); // 0~40, <500 远 + } + private: + bool is_held {false}; + std::unique_ptr random_source; +}; + +void LuxSourceDeleter::operator()(LuxSource* p) const { + delete p; +} + +void PsSourceDeleter::operator()(PsSource* p) const { + delete p; +}; + + +MockedSensor::MockedSensor() : Sensor() {} + +std::expected MockedSensor::init(bool force_reinit) +{ + if(!lux_source || force_reinit) { + lux_source = std::unique_ptr(new LuxSource); + } + + if(!ps_source || force_reinit) { + ps_source = std::unique_ptr(new PsSource); + } + return {}; +} + +std::expected MockedSensor::query_once() +{ + if(!lux_source || !ps_source){ + return std::unexpected {QueryError::NotInited}; + } + + return { + SensorData { + .luxury = lux_source->fetch_lux(), + .ps = ps_source->fetch_ps() + } + }; +} + +void MockedSensor::set_held(bool is_held) +{ + if(!ps_source) { + return; + } + + ps_source->set_held(is_held); +} + +void MockedSensor::set_phase(double phase) +{ + if(!lux_source) { + return; + } + + lux_source->setPhase(phase); +} + + + diff --git a/examples/light-meter/sensor/mocked/mockedsensor.h b/examples/light-meter/sensor/mocked/mockedsensor.h new file mode 100644 index 000000000..9a0ff8ac5 --- /dev/null +++ b/examples/light-meter/sensor/mocked/mockedsensor.h @@ -0,0 +1,31 @@ +#ifndef MOCKEDSENSOR_H +#define MOCKEDSENSOR_H + +#include + +#include "sensor.h" + +struct LuxSource; +struct PsSource; +struct LuxSourceDeleter { + void operator()(LuxSource* p) const; +}; +struct PsSourceDeleter { + void operator()(PsSource *p) const; +}; + +class MockedSensor : public Sensor { + public: + MockedSensor(); + std::expected init(bool force_reinit) override; + std::expected query_once() override; + + /* Interfaces using mocked datas */ + void set_held(bool is_held); + void set_phase(double phase); + private: + std::unique_ptr lux_source; + std::unique_ptr ps_source; +}; + +#endif // MOCKEDSENSOR_H diff --git a/examples/light-meter/sensor/sensor.cpp b/examples/light-meter/sensor/sensor.cpp new file mode 100644 index 000000000..37b56ee65 --- /dev/null +++ b/examples/light-meter/sensor/sensor.cpp @@ -0,0 +1,3 @@ +#include "sensor.h" + + diff --git a/examples/light-meter/sensor/sensor.h b/examples/light-meter/sensor/sensor.h new file mode 100644 index 000000000..62acfcf4c --- /dev/null +++ b/examples/light-meter/sensor/sensor.h @@ -0,0 +1,41 @@ +#ifndef SENSOR_H +#define SENSOR_H + +#include + +struct SensorData { + double luxury; // light luxury + int ps; // how hand close to the sensor +}; + +/** + * @brief Sensor is the source data class, using in query + * within platfrom inrelatives + */ +class Sensor { + public: + enum class InitError { + Ok, GeneralFailed, DeviceUnavailable + }; + + enum class QueryError { + Ok, NotInited, DeviceUnavailable + }; + + Sensor() = default; + virtual ~Sensor() = default; // 多态基类: 经基类指针 delete 需 virtual 析构 + /** + * @brief manual_init calls for the init of getting sensor's data + * for linux platform, it calls for the open dev files + * @param force_reinit + */ + virtual std::expected init(bool force_reinit = false) = 0; + + /** + * @brief sync read once data + * @return sensor data we read, in windows, it is the mocked data + */ + virtual std::expected query_once() = 0; +}; + +#endif // SENSOR_H diff --git a/examples/light-meter/ui/breathing_overlay.cpp b/examples/light-meter/ui/breathing_overlay.cpp new file mode 100644 index 000000000..667ac09da --- /dev/null +++ b/examples/light-meter/ui/breathing_overlay.cpp @@ -0,0 +1,31 @@ +#include "breathing_overlay.h" + +#include + +#include + +BreathingOverlay::BreathingOverlay(QWidget *parent) : QWidget(parent) { + setAttribute(Qt::WA_NoSystemBackground, true); + setAttribute(Qt::WA_TransparentForMouseEvents, false); + setFocusPolicy(Qt::NoFocus); + hide(); +} + +void BreathingOverlay::setBreath(float t) { + m_t = t; + update(); // 只刷自身矩形 +} + +void BreathingOverlay::paintEvent(QPaintEvent * /*event*/) { + QPainter p(this); + p.fillRect(rect(), QColor(0, 0, 0)); + + constexpr float kTwoPi = 6.28318530717958647692f; // 不用非标准 M_PI(MSVC 下未定义) + const float a = (std::sin(m_t * kTwoPi) + 1.0f) * 0.5f; // 0..1 + const int r = int(5 + 7 * a); + const int alpha = int(70 + 185 * a); + + p.setBrush(QColor(180, 200, 255, alpha)); + p.setPen(Qt::NoPen); + p.drawEllipse(rect().center(), r, r); +} diff --git a/examples/light-meter/ui/breathing_overlay.h b/examples/light-meter/ui/breathing_overlay.h new file mode 100644 index 000000000..6701508ef --- /dev/null +++ b/examples/light-meter/ui/breathing_overlay.h @@ -0,0 +1,26 @@ +#ifndef BREATHING_OVERLAY_H +#define BREATHING_OVERLAY_H + +#include + +/** + * @brief 息屏遮罩: 全屏黑底 + 中央慢呼吸点。 + * + * 纯 paintEvent, 无信号/槽, 故不需要 Q_OBJECT。由 MainWindow 在息屏态 + * show()/raise() 铺满整窗; 按空格唤醒时 hide()。 + */ +class BreathingOverlay : public QWidget { + public: + explicit BreathingOverlay(QWidget *parent = nullptr); + + /// 设置呼吸相位 t(任意单调浮点, 内部取正弦映射到 0..1 亮度)。 + void setBreath(float t); + + protected: + void paintEvent(QPaintEvent *event) override; + + private: + float m_t = 0.0f; +}; + +#endif // BREATHING_OVERLAY_H diff --git a/examples/light-meter/ui/chart_view.cpp b/examples/light-meter/ui/chart_view.cpp new file mode 100644 index 000000000..5383c3734 --- /dev/null +++ b/examples/light-meter/ui/chart_view.cpp @@ -0,0 +1,123 @@ +#include "chart_view.h" + +#include +#include +#include + +namespace { +// 配色(深色护眼): 背景、网格、文本、正常绿、告警红、阈值虚线 +constexpr auto kBg = "#1e1e2e"; +constexpr auto kGridLine = "#313244"; +constexpr auto kAxisText = "#9399b2"; +constexpr auto kGreen = "#27ae60"; +constexpr auto kRed = "#c0392b"; +constexpr auto kThreshold = "#7f8c8d"; +} // namespace + +ChartView::ChartView(QWidget *parent) : QWidget(parent) { + m_buf.resize(kCapacity); + setMinimumSize(360, 220); +} + +void ChartView::pushSample(float lux) { + m_buf[m_head] = lux; + m_head = (m_head + 1) % kCapacity; + if (m_count < kCapacity) ++m_count; + update(); // 只刷自身矩形 +} + +void ChartView::setThreshold(float threshold) { + m_threshold = threshold; + update(); +} + +void ChartView::clear() { + m_head = 0; + m_count = 0; + update(); +} + +float ChartView::mappedY(float lux, int top, int height) const { + const double v = (lux < 0.0) ? 0.0 : (lux > m_yMax ? double(m_yMax) : double(lux)); + return float(top + height - int(v / m_yMax * height)); +} + +void ChartView::paintEvent(QPaintEvent * /*event*/) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + p.fillRect(rect(), QColor(kBg)); + + // 绘图区: 左留 40px 给 Y 轴刻度, 右/上 12px, 下留 28px 给 X 轴 + const QRect plot = rect().adjusted(40, 12, -12, -28); + + // Y 轴刻度 + 横向网格 (0,200,...,800) + for (int v = 0; v <= 800; v += 200) { + const int y = int(mappedY(float(v), plot.top(), plot.height())); + p.setPen(QPen(QColor(kGridLine), 1, Qt::DotLine)); + p.drawLine(plot.left(), y, plot.right(), y); + p.setPen(QColor(kAxisText)); + p.drawText(QRect(plot.left() - 38, y - 8, 34, 16), + Qt::AlignRight | Qt::AlignVCenter, QString::number(v)); + } + + // 阈值虚线 + const int ty = int(mappedY(m_threshold, plot.top(), plot.height())); + p.setPen(QPen(QColor(kThreshold), 1, Qt::DashLine)); + p.drawLine(plot.left(), ty, plot.right(), ty); + p.setPen(QColor(kAxisText)); + p.drawText(QRect(plot.left(), ty - 16, plot.width(), 14), + Qt::AlignRight, QStringLiteral("阈值 %1").arg(int(m_threshold))); + + // 折线 + 面积(右对齐: 最新点恒在 now=最右, 早期数据从右向左滚动填满) + const int n = qMin(m_count, kCapacity); + if (n >= 1) { + const float lastLux = m_buf[(m_head - 1 + kCapacity) % kCapacity]; + const QColor lineColor = (lastLux < m_threshold) ? QColor(kRed) : QColor(kGreen); + + QPainterPath line; + QPainterPath fill; + double lastX = 0.0, lastY = 0.0; + for (int i = 0; i < n; ++i) { + const int idx = (m_head - n + i + kCapacity) % kCapacity; + const float lux = m_buf[idx]; + const double x = plot.left() + + double(i + (kCapacity - n)) / (kCapacity - 1) * plot.width(); + const double y = mappedY(lux, plot.top(), plot.height()); + if (i == 0) { + line.moveTo(x, y); + fill.moveTo(x, plot.bottom()); // 面积左侧从底部垂直升起 + fill.lineTo(x, y); + } else { + line.lineTo(x, y); + fill.lineTo(x, y); + } + lastX = x; lastY = y; + } + // 面积在最末点垂直落到底再闭合 → 只填曲线正下方, 不拉到空数据区的右下角。 + fill.lineTo(lastX, plot.bottom()); + fill.closeSubpath(); + + // 曲线下半透明填充 + QColor fc = lineColor; fc.setAlpha(48); + p.fillPath(fill, fc); + + if (n >= 2) { + p.setPen(QPen(lineColor, 2)); + p.setBrush(Qt::NoBrush); + p.drawPath(line); + } + + // 当前点(now, 最右) + p.setBrush(lineColor); + p.setPen(Qt::NoPen); + p.drawEllipse(QPointF(lastX, lastY), 4.5, 4.5); + } + + // X 轴标签 + p.setPen(QColor(kAxisText)); + p.drawText(QRect(plot.left(), plot.bottom() + 6, plot.width(), 16), + Qt::AlignLeft, QStringLiteral("-30s")); + p.drawText(QRect(plot.left(), plot.bottom() + 6, plot.width(), 16), + Qt::AlignRight, QStringLiteral("now")); +} diff --git a/examples/light-meter/ui/chart_view.h b/examples/light-meter/ui/chart_view.h new file mode 100644 index 000000000..4452e6154 --- /dev/null +++ b/examples/light-meter/ui/chart_view.h @@ -0,0 +1,40 @@ +#ifndef CHART_VIEW_H +#define CHART_VIEW_H + +#include + +/** + * @brief 自绘 lux 折线图(不依赖 Qt Charts)。 + * + * 环形缓冲,容量 150 点(= 30s @ 200ms)。pushSample() 仅触发自身矩形重绘, + * 对 linuxfb / 局部刷新友好。Mock 与真机阶段共用同一组件。 + */ +class ChartView : public QWidget { + Q_OBJECT + public: + explicit ChartView(QWidget *parent = nullptr); + + /// 追加一个采样点并刷新。 + void pushSample(float lux); + /// 设置告警阈值(画虚线),lux 低于此值折线/当前点染红。 + void setThreshold(float threshold); + /// 清空历史。 + void clear(); + + QSize sizeHint() const override { return {520, 320}; } + + protected: + void paintEvent(QPaintEvent *event) override; + + private: + float mappedY(float lux, int top, int height) const; + + static constexpr int kCapacity = 150; ///< 30s @ 200ms + QVector m_buf; ///< 环形缓冲 + int m_head = 0; ///< 下一个写入位置 + int m_count = 0; ///< 已有有效点数(<= kCapacity) + float m_threshold = 300.0f; ///< 告警阈值 + float m_yMax = 800.0f; ///< Y 轴上界(固定) +}; + +#endif // CHART_VIEW_H From be4fb4d530a4e03b5fc12156cfb560cdf926b7e6 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Thu, 6 Aug 2026 23:00:19 +0800 Subject: [PATCH 2/3] feat: real machine --- examples/light-meter/CMakeLists.txt | 11 ++++++ examples/light-meter/mainwindow.cpp | 13 ++++++- examples/light-meter/mainwindow.h | 4 +-- .../sensor/ap3216c/ap3216c_sensor.cpp | 34 ++++++++++++++++++ .../sensor/ap3216c/ap3216c_sensor.h | 35 +++++++++++++++++++ .../light-meter/sensor/mocked/mockedsensor.h | 4 +-- examples/light-meter/sensor/sensor.h | 5 +++ 7 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 examples/light-meter/sensor/ap3216c/ap3216c_sensor.cpp create mode 100644 examples/light-meter/sensor/ap3216c/ap3216c_sensor.h diff --git a/examples/light-meter/CMakeLists.txt b/examples/light-meter/CMakeLists.txt index 241c005ef..7697521f8 100644 --- a/examples/light-meter/CMakeLists.txt +++ b/examples/light-meter/CMakeLists.txt @@ -22,6 +22,17 @@ qt_add_executable(light-meter target_include_directories(light-meter PUBLIC sensor/) +# 真机后端(可选): /dev/ap3216c 桥接, 仅 Linux/板子(POSIX)。默认 OFF = Mock 后端。 +option(USE_REAL_SENSOR "真机后端 /dev/ap3216c(仅 Linux/板子)" OFF) +if(USE_REAL_SENSOR) + if(WIN32 OR NOT UNIX) + message(FATAL_ERROR "USE_REAL_SENSOR 仅 Linux/板子可用(POSIX open/read/close)") + endif() + target_sources(light-meter PRIVATE + sensor/ap3216c/ap3216c_sensor.h sensor/ap3216c/ap3216c_sensor.cpp) + target_compile_definitions(light-meter PRIVATE USE_REAL_SENSOR=1) +endif() + # 源文件为 UTF-8(无 BOM), 含中文; 告知 MSVC 以 UTF-8 解释源与执行字符集。 if(MSVC) target_compile_options(light-meter PRIVATE /utf-8) diff --git a/examples/light-meter/mainwindow.cpp b/examples/light-meter/mainwindow.cpp index c8b745a73..70552c888 100644 --- a/examples/light-meter/mainwindow.cpp +++ b/examples/light-meter/mainwindow.cpp @@ -1,6 +1,12 @@ #include "mainwindow.h" #include "ui/breathing_overlay.h" +#ifdef USE_REAL_SENSOR +#include "ap3216c/ap3216c_sensor.h" +#else +#include "mocked/mockedsensor.h" +#endif + #include #include #include @@ -46,8 +52,13 @@ MainWindow::MainWindow(QWidget *parent) setWindowTitle(QStringLiteral("桌面照度 · light-meter")); resize(1024, 600); - // 数据源(用户实现的 pull 模型): 先 init, 之后由 200ms 定时器 query_once。 + // 数据源(pull 模型): 先 init, 之后由 200ms 定时器 query_once。 + // 后端由编译开关切换: MockedSensor(主机) / Ap3216cSensor(板子, /dev/ap3216c)。 +#ifdef USE_REAL_SENSOR + m_sensor = std::make_unique(); +#else m_sensor = std::make_unique(); +#endif m_sensor->init(false); // 注: override 不继承基类默认参数, 显式传 false buildUi(); diff --git a/examples/light-meter/mainwindow.h b/examples/light-meter/mainwindow.h index decbc1ba7..f806b40ea 100644 --- a/examples/light-meter/mainwindow.h +++ b/examples/light-meter/mainwindow.h @@ -7,7 +7,7 @@ #include #include "ui/chart_view.h" -#include "mocked/mockedsensor.h" +#include "sensor.h" QT_BEGIN_NAMESPACE class QCheckBox; @@ -62,7 +62,7 @@ class MainWindow : public QMainWindow { static constexpr int kScreenOffMs = 10000; // 无接近 10s → 息屏 static constexpr int kMaxHistory = 18000; // CSV 历史上限(~1h @5Hz), 防 OOM - std::unique_ptr m_sensor; + std::unique_ptr m_sensor; ChartView *m_chart = nullptr; QTimer *m_sampleTimer = nullptr; // 200ms 周期采样 diff --git a/examples/light-meter/sensor/ap3216c/ap3216c_sensor.cpp b/examples/light-meter/sensor/ap3216c/ap3216c_sensor.cpp new file mode 100644 index 000000000..eefb4ef0a --- /dev/null +++ b/examples/light-meter/sensor/ap3216c/ap3216c_sensor.cpp @@ -0,0 +1,34 @@ +#include "ap3216c_sensor.h" + +#include +#include + +Ap3216cSensor::Ap3216cSensor(std::string dev, double lux_coeff) + : m_dev(std::move(dev)), m_lux_coeff(lux_coeff) {} + +std::expected +Ap3216cSensor::init(bool force_reinit) { + if (m_fd >= 0) { + if (!force_reinit) return {}; // 已 init, 不重复打开 + ::close(m_fd); + m_fd = -1; + } + m_fd = ::open(m_dev.c_str(), O_RDWR); + if (m_fd < 0) return std::unexpected{InitError::DeviceUnavailable}; + return {}; +} + +std::expected +Ap3216cSensor::query_once() { + if (m_fd < 0) return std::unexpected{QueryError::NotInited}; + + unsigned short db[3] = {0, 0, 0}; // {ir, als, ps}, 与驱动 copy_to_user 顺序一致 + const ssize_t n = ::read(m_fd, db, sizeof(db)); + if (n != static_cast(sizeof(db))) + return std::unexpected{QueryError::DeviceUnavailable}; + + return SensorData{ + .luxury = db[1] * m_lux_coeff, // als → luxury(lux) + .ps = static_cast(db[2]) // ps raw 直传, 不换算 + }; +} diff --git a/examples/light-meter/sensor/ap3216c/ap3216c_sensor.h b/examples/light-meter/sensor/ap3216c/ap3216c_sensor.h new file mode 100644 index 000000000..addb4fb79 --- /dev/null +++ b/examples/light-meter/sensor/ap3216c/ap3216c_sensor.h @@ -0,0 +1,35 @@ +#ifndef AP3216C_SENSOR_H +#define AP3216C_SENSOR_H + +#include + +#include "sensor.h" + +// Ap3216cSensor —— Sensor 的真机后端, 桥接 /dev/ap3216c 字符设备驱动。 +// +// 契约对齐(驱动 ap3216c_read): 每次同步读 IR/ALS/PS 三路寄存器, 填 +// unsigned short[3] = {ir, als, ps}, 立即返回(无等待队列)。 +// query_once() 读一次 → als × lux_coeff 得 luxury, ps 直传。 +// +// 平台: 仅 Linux/板子(POSIX open/read)。Windows 不参与编译 +// (由 CMakeLists 的 USE_REAL_SENSOR 选项 + 平台判断隔离)。 +// +// 注: set_phase / set_held 是 MockedSensor 的"测试数据注入"口, 真机无意义。 +// 本类不 override 它们 —— 若后续把它们作为 Sensor 基类的默认空实现虚方法, +// 则对真机自动 no-op;否则切换后端时 UI 层应避免对真机调用这两个口。 +class Ap3216cSensor : public Sensor { + public: + // dev: 设备节点, 默认 /dev/ap3216c + // lux_coeff: als_raw → luxury(lux) 换算系数, 占位 1.0, 上板标定后回填 + explicit Ap3216cSensor(std::string dev = "/dev/ap3216c", double lux_coeff = 1.0); + + std::expected init(bool force_reinit) override; + std::expected query_once() override; + + private: + std::string m_dev; + int m_fd = -1; // < 0 表示未 init + double m_lux_coeff; +}; + +#endif // AP3216C_SENSOR_H diff --git a/examples/light-meter/sensor/mocked/mockedsensor.h b/examples/light-meter/sensor/mocked/mockedsensor.h index 9a0ff8ac5..6f4c26135 100644 --- a/examples/light-meter/sensor/mocked/mockedsensor.h +++ b/examples/light-meter/sensor/mocked/mockedsensor.h @@ -21,8 +21,8 @@ class MockedSensor : public Sensor { std::expected query_once() override; /* Interfaces using mocked datas */ - void set_held(bool is_held); - void set_phase(double phase); + void set_held(bool is_held) override; + void set_phase(double phase) override; private: std::unique_ptr lux_source; std::unique_ptr ps_source; diff --git a/examples/light-meter/sensor/sensor.h b/examples/light-meter/sensor/sensor.h index 62acfcf4c..e0df99b6b 100644 --- a/examples/light-meter/sensor/sensor.h +++ b/examples/light-meter/sensor/sensor.h @@ -36,6 +36,11 @@ class Sensor { * @return sensor data we read, in windows, it is the mocked data */ virtual std::expected query_once() = 0; + + /// 测试数据注入(Mock 用);真机后端忽略(no-op)。 + /// 留在基类以便 UI 层无差别调用 —— 切换后端时不需改 MainWindow 的调用点。 + virtual void set_phase(double /*phase*/) {} + virtual void set_held(bool /*is_held*/) {} }; #endif // SENSOR_H From bdf01db8f87bf10d035a1f9c0810f22f2557ee63 Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Sat, 8 Aug 2026 00:55:22 +0800 Subject: [PATCH 3/3] feat: finish docs --- document/tutorial/project/index.md | 28 ++ .../light-meter/01_setup_qt6_toolchain.md | 274 ++++++++++++++++ .../light-meter/02_cpp23_utf8_expected.md | 177 ++++++++++ .../project/light-meter/03_sensor_contract.md | 216 ++++++++++++ .../project/light-meter/04_mocked_backend.md | 246 ++++++++++++++ .../project/light-meter/05_three_state_ui.md | 308 ++++++++++++++++++ .../light-meter/06_self_painted_chart.md | 195 +++++++++++ .../project/light-meter/07_cmake_seam.md | 168 ++++++++++ .../light-meter/08_calibrate_to_your_env.md | 145 +++++++++ .../project/light-meter/09_ap3216c_client.md | 191 +++++++++++ .../project/light-meter/10_board_deploy.md | 168 ++++++++++ .../project/light-meter/11_wrap_up.md | 134 ++++++++ .../tutorial/project/light-meter/index.md | 129 ++++++++ project.config.ts | 6 + site/.vitepress/config/index.ts | 6 + site/.vitepress/config/sidebar.ts | 1 + .../public/light-meter/demo-poster.png | Bin 0 -> 546430 bytes site/.vitepress/public/light-meter/demo.mp4 | Bin 0 -> 590364 bytes .../public/light-meter/mock-dark.png | Bin 0 -> 53214 bytes .../public/light-meter/mock-light.png | Bin 0 -> 67136 bytes .../public/light-meter/mock-sleep.png | Bin 0 -> 5629 bytes .../theme/components/ChapterLink.vue | 7 +- 22 files changed, 2398 insertions(+), 1 deletion(-) create mode 100644 document/tutorial/project/index.md create mode 100644 document/tutorial/project/light-meter/01_setup_qt6_toolchain.md create mode 100644 document/tutorial/project/light-meter/02_cpp23_utf8_expected.md create mode 100644 document/tutorial/project/light-meter/03_sensor_contract.md create mode 100644 document/tutorial/project/light-meter/04_mocked_backend.md create mode 100644 document/tutorial/project/light-meter/05_three_state_ui.md create mode 100644 document/tutorial/project/light-meter/06_self_painted_chart.md create mode 100644 document/tutorial/project/light-meter/07_cmake_seam.md create mode 100644 document/tutorial/project/light-meter/08_calibrate_to_your_env.md create mode 100644 document/tutorial/project/light-meter/09_ap3216c_client.md create mode 100644 document/tutorial/project/light-meter/10_board_deploy.md create mode 100644 document/tutorial/project/light-meter/11_wrap_up.md create mode 100644 document/tutorial/project/light-meter/index.md create mode 100644 site/.vitepress/public/light-meter/demo-poster.png create mode 100644 site/.vitepress/public/light-meter/demo.mp4 create mode 100644 site/.vitepress/public/light-meter/mock-dark.png create mode 100644 site/.vitepress/public/light-meter/mock-light.png create mode 100644 site/.vitepress/public/light-meter/mock-sleep.png diff --git a/document/tutorial/project/index.md b/document/tutorial/project/index.md new file mode 100644 index 000000000..5f76c3268 --- /dev/null +++ b/document/tutorial/project/index.md @@ -0,0 +1,28 @@ +--- +title: 应用项目 +--- + + + +## 章节目录 + + + 照度护眼摆件 light-meter —— 从桌面 Mock 到板子真机的造工程全流程 + + +::: tip 这个卷在做什么 +这里放的是「复合项目」——不再是某一个单点知识(怎么编 U-Boot、怎么写字符设备),而是带你把已经学过的零件缝成一个**完整产品**:从一份空的 `CMakeLists.txt`,到一个在板子上会呼吸、会告警、会息屏、能导出数据的常驻应用。 + +light-meter 是第一个项目。后续 [PROJ-001 便携式环境监测站](../../todo/projects/proj-001-env-monitor.md) 等旗舰会陆续加入这个卷。 +::: + +::: info 和其他卷的关系 +本卷的每一个项目都是**自包含的工程教学**:除了一处例外——项目依赖的具体硬件驱动会指给你本仓库对应的驱动章节(例如 light-meter 的 AP3216C 指向 [driver/08](../driver/08_i2c_ap3216c_driver/))——其余的工程本事(C++、Qt、CMake、Mock、部署、标定)都在项目教程里讲够,不需要你先读完 buildroot、practical 再来。 +::: + +## 继续学习 + + + ← 实战演练 + light-meter → + diff --git a/document/tutorial/project/light-meter/01_setup_qt6_toolchain.md b/document/tutorial/project/light-meter/01_setup_qt6_toolchain.md new file mode 100644 index 000000000..f7f755259 --- /dev/null +++ b/document/tutorial/project/light-meter/01_setup_qt6_toolchain.md @@ -0,0 +1,274 @@ +--- +title: 装好 Qt6 与 C++23 工具链 +--- + +# 装好 Qt6 与 C++23 工具链 + +::: info 本节你将学到 +- 为什么 Qt 程序不能用 `g++ main.cpp` 一把梭编译,CMake 这个"构建系统生成器"到底替我们干了什么 +- 一份最小 `CMakeLists.txt` 每一行在说什么,configure 和 build 两步为什么是分开的 +- `find_package(Qt6)` 报"找不到"几乎一定是 `CMAKE_PREFIX_PATH` 没设对,Windows / Linux / macOS 分别填什么 +- 为什么这个项目锁 C++23、`std::expected` 对编译器版本的真实门槛、怎么自查 +- 亲手建一个空白 Qt 程序,看它把窗口弹出来 +::: + +::: tip 前置知识 +会用命令行、用 `gcc` 编译过一个 Hello World,C 就够。本系列不假设你会 CMake 或现代 C++,这两样本章和后面几章会从零讲。整个第一幕都在桌面跑,不需要开发板。 +::: + +## 为什么不能 `g++ main.cpp` 一把梭 + +写 C 的人编译一个程序,大概就是 `gcc main.c -o main` 这么干脆。很多人第一次碰 C++ 和 Qt 也本能地这么干,然后收获满屏的 `undefined reference to 'QApplication::QApplication(int&, char**)'`、`cannot find -lQt6Core`。原因不复杂:Qt 不是语言的一部分,它是一坨装在你硬盘上某个角落的第三方库,编译器既不知道它的头文件在哪,也不知道它的 `.so` 或 `.dll` 在哪。要是你坚持手动编译,命令大概会长成这样: + +```bash +g++ main.cpp \ + -I~/Qt/6.8.0/gcc_64/include \ + -I~/Qt/6.8.0/gcc_64/include/QtCore \ + -L~/Qt/6.8.0/gcc_64/lib \ + -lQt6Core -lQt6Widgets -lQt6Gui \ + -fPIC -std=c++23 \ + -o main +``` + +这还只是一个文件、三个 Qt 模块。等你的项目变成十几个 `.cpp`、用到七八个模块、还要在 Windows 上换成 `.lib` 和反斜杠路径、还要处理 Qt 那个叫 moc 的预处理步骤,手动维护这条命令就彻底不可能了,别跟自己过不去。 + +构建系统就是来解决这件事的。你在一个叫 `CMakeLists.txt` 的文件里用人话声明"项目叫什么、由哪些源文件组成、依赖哪些库",剩下的事交给它。C++ 世界构建系统一堆,真要打起来 CMake 是那个事实标准,Qt 官方也认它,所以这套教程从第一行起就用它,不绕弯子去讲 qmake 那种老古董。 + +## CMake 是个什么东西 + +很多人以为 CMake 是个编译器,它不是。CMake 自己不编译任何东西,它是一个构建系统的生成器:读你的 `CMakeLists.txt`,生成出一份具体的施工单,Linux 上通常是 `Makefile` 配 `make`,Windows 上可能是 Visual Studio 的 `.sln`。真正去调 `g++`、`cl.exe` 干活的,是 `make` 或 VS 这些下游工具,不是 CMake 自己。 + +这个分工解释了一件让新手一直困惑的事:为什么编译一个 CMake 项目永远是两条命令而不是一条。 + +第一步 configure,CMake 读 `CMakeLists.txt`,创建 `build/` 目录,生成施工单。这一步它还得踩一圈点,找你机器上 Qt 装在哪、编译器是什么版本、系统有哪些特性,结果缓存到 `build/CMakeCache.txt` 里。所以 configure 偏慢,但只要 `CMakeLists.txt` 不改,就不用重跑。 + +```bash +cmake -B build # 这就是 configure +``` + +第二步 build,是把施工单交给 `make` 或 `ninja`,让它们真正调编译器,把 `.cpp` 一个个编成 `.o` 再链接成可执行文件。这步才是费 CPU 的那一步,但它是增量的,你只改了一个 `.cpp`,它就只重新编译那一个,别的复用上次的产物。 + +```bash +cmake --build build # 这就是 build +``` + +为什么要分两步。因为"看图纸生成施工单"和"按施工单搬砖"是两件性质完全不同的事,前者是一次性的踩点,后者是高频的重复劳动。分开之后,你日常改代码只需要重跑第二步,不用每次都重新踩点,这对大项目省下来的时间相当可观。 + +::: details 那为什么老教程里直接 make 就行 +你可能在老教程里见过直接 `make` 编一个项目。那是因为那些项目的作者事先帮你跑过 configure、把 `Makefile` 提交进了仓库,或者给了你一个 `./configure` 脚本。CMake 项目默认不提交施工单,`build/` 目录都在 `.gitignore` 里,所以你得自己先 configure 一次生成它。本质还是两步,只是老教程把第一步替你藏起来了。 +::: + +## 一份最小 CMakeLists.txt + +下面这份是我们等会儿要亲手建的那个空白 Qt 程序的 `CMakeLists.txt`,也是 light-meter 那份 `examples/light-meter/CMakeLists.txt` 的核心骨架。light-meter 只是多了个 `USE_REAL_SENSOR` 开关,那个留到第 07 章讲。这里先把基础打牢。 + +```cmake +cmake_minimum_required(VERSION 3.19) +project(hello-qt LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Widgets) + +qt_standard_project_setup() + +qt_add_executable(hello-qt main.cpp) + +target_link_libraries(hello-qt PRIVATE Qt6::Core Qt6::Widgets) +``` + +### 项目声明和 C++ 标准 + +头两行是项目的基本信息。`cmake_minimum_required(VERSION 3.19)` 声明这个脚本至少要 3.19 版的 CMake 才看得懂,CMake 这几年加了不少新语法,这一行是给 CMake 自己设的下限:用老 CMake 跑就直接报错,而不是用旧行为默默跑出错误结果。`project(hello-qt LANGUAGES CXX)` 给项目起名,顺便告诉 CMake 用 C++ 这门语言,于是它会在 configure 阶段去探测你机器上的 C++ 编译器,探不到就报错。 + +接下来两行管 C++ 标准。`set(CMAKE_CXX_STANDARD 23)` 是 CMake 设置 C++ 标准的标准做法,等价于在最终编译命令里加 `-std=c++23`(GCC/Clang)或 `/std:c++23`(MSVC)。这一行为什么这么关键我们马上讲,先看下一行。`set(CMAKE_CXX_STANDARD_REQUIRED ON)` 是配合它用的,默认情况下如果编译器不支持你要的标准,CMake 会偷偷降级,你要 23、编译器只支持到 20,它就悄悄用 20,然后你踩一晚上坑才发现自己根本没在用 C++23。加上 `STANDARD_REQUIRED ON`,编译器不支持就直接报错。新手务必加这一行,能省掉很多"为什么 `std::expected` 找不到"的深夜。 + +### 找到 Qt,定义目标,挂上依赖 + +`find_package(Qt6 6.5 REQUIRED COMPONENTS Core Widgets)` 这一行,意思是去找 Qt6,版本至少 6.5,我只要 Core 和 Widgets 这两个模块,找不到就直接停下来报错。`find_package` 是 CMake 找第三方库的统一接口,它会去找 Qt 官方提供的一个叫 `Qt6Config.cmake` 的"安装说明书",找到之后 `Qt6::Core`、`Qt6::Widgets` 这些目标就能用了。这一行是这一章最大的劝退点,我们紧接着单独开一节讲它为什么经常"找不到"。 + +`qt_standard_project_setup()` 是 Qt6 提供的一个便利函数,一行打开 Qt 推荐的一组默认设置,最重要的是 AUTOMOC,我们放进下面这个折叠框讲。 + +::: details AUTOMOC 是什么,为什么 Qt 程序需要它 +Qt 有个特有的机制叫"信号与槽",第 05 章会细讲。它依赖一个叫 moc(Meta-Object Compiler)的预处理工具:你写的某些类(带 `Q_OBJECT` 宏的)得先被 moc 扫一遍、生成一段额外的 `.cpp`,才能正常编译。手动管这个预处理非常烦。AUTOMOC 就是让 CMake 自动帮你跑 moc,你正常写代码,CMake 在背后替你处理那一步。`qt_standard_project_setup()` 默认就开了 AUTOMOC,所以你不用自己写 `set(CMAKE_AUTOMOC ON)`。本章的空白程序没用 `Q_OBJECT`,但开着 AUTOMOC 无害,也省得第 05 章再回头改 CMakeLists。 +::: + +最后两行定义项目要编出来的东西。`qt_add_executable(hello-qt main.cpp)` 定义一个叫 `hello-qt` 的可执行目标,由 `main.cpp` 编译而来,它是 Qt6 增强版的 `add_executable`,除了编可执行文件还顺手帮你处理 Qt 特有的部署细节(Windows 上的部署脚本、macOS 的 `.app` 包)。这里有个词得先认识,target,目标。一个 CMake 项目就是由一堆 target 拼起来的,每个 target 自带一份源文件、依赖、编译选项,后面你会反复跟它打交道。`target_link_libraries(hello-qt PRIVATE Qt6::Core Qt6::Widgets)` 就是给这个 target 挂依赖,等价于在最终命令里加 `-lQt6Core -lQt6Widgets` 和对应的头文件路径。`PRIVATE` 的意思是这俩库只是我自己实现用的、不向外暴露,对一个最终可执行文件来说 PRIVATE 和 PUBLIC 其实区别不大,但养成习惯写 PRIVATE 总没错,省得以后写库的时候翻车。 + +把这八行连起来读,人话就是:项目叫 hello-qt,用 C++23,依赖 Qt6 的 Core 和 Widgets,把 main.cpp 编成一个可执行文件,链接上 Qt。CMake 的玩法到这就讲得差不多了,你负责声明"要什么",搬砖那部分交给它。 + +## find_package 找不到 Qt:这是第一章最大的劝退点 + +好了,你照着上面建好 `CMakeLists.txt`,信心满满跑 `cmake -B build`,大概率撞上这一条: + +``` +CMake Error at CMakeLists.txt:6 (find_package): + Could not find a package configuration file provided by "Qt6" with any of + the following names: + Qt6Config.cmake + qt6-config.cmake +``` + +这条报错劝退了至少一半第一次碰 Qt 的人。它说的不是"你没装 Qt",而是"我找不到 Qt 的安装说明书 `Qt6Config.cmake`"。区别在于,你可能装了 Qt,但 CMake 不知道它装在哪个文件夹,它不会全盘扫描你的硬盘,只在你明确告诉它的几个地方找。 + +那个"明确告诉它"的机制,就是 `CMAKE_PREFIX_PATH` 这个变量。它的值是一个路径,指向 Qt 的安装根目录,CMake 会在那个路径下的 `lib/cmake/Qt6/` 里找 `Qt6Config.cmake`。所以解法很朴素,把 Qt 装在哪,告诉 CMake。 + +具体填什么,看你的平台和 Qt 是怎么装的。 + +Windows 上,如果你用的是 qt.io 官方安装器的 MSVC 版,Qt 装在类似 `C:\Qt\` 下,按版本和编译器分子目录: + +```bash +cmake -B build -DCMAKE_PREFIX_PATH="C:/Qt/6.8.0/msvc2022_64" +``` + +`6.8.0` 是你装的版本号,`msvc2022_64` 是"配合 Visual Studio 2022 的 64 位版",你装的时候选了什么编译器这里就填什么。用正斜杠,Windows 下 CMake 也认,能省掉反斜杠转义的麻烦。 + +Linux 上走 qt.io 官方安装器的话,默认装在 `~/Qt/`: + +```bash +cmake -B build -DCMAKE_PREFIX_PATH="$HOME/Qt/6.8.0/gcc_64" +``` + +但 Linux 桌面开发其实有更省事的方式,就是用发行版的包管理器。Ubuntu 24.04 上 `sudo apt install qt6-base-dev` 一把下去,Qt 的 `Qt6Config.cmake` 会装在系统标准路径 `/usr/lib/cmake/Qt6/` 下,CMake 默认就会扫到,通常不用再设 `CMAKE_PREFIX_PATH`: + +```bash +sudo apt install qt6-base-dev # 一次 +cmake -B build # 通常直接能找到 +``` + +这里有个坑要注意,发行版仓库里的 Qt 版本可能偏老,Ubuntu 22.04 仓库里就基本拿不到能用的 Qt6。还有件事先打个预防针,板子上那套 Qt 是 Buildroot 编出来的,跟你桌面这套不是同一份,这个版本协调的麻烦事留到第 10 章上板时再细说,这里不展开。 + +macOS 上,qt.io 官方安装器: + +```bash +cmake -B build -DCMAKE_PREFIX_PATH="$HOME/Qt/6.8.0/macos" +``` + +如果你不确定自己该填哪个路径,就去硬盘上找 `Qt6Config.cmake` 这个文件,把它所在路径往上回溯,去掉 `lib/cmake/Qt6/` 那一段,剩下的就是 `CMAKE_PREFIX_PATH` 该填的值。比如文件在 `~/Qt/6.8.0/gcc_64/lib/cmake/Qt6/Qt6Config.cmake`,那 `CMAKE_PREFIX_PATH` 就是 `~/Qt/6.8.0/gcc_64`。 + +## 为什么是 C++23,以及你的编译器够不够新 + +你可能注意到了,`CMakeLists.txt` 里我们写了 `set(CMAKE_CXX_STANDARD 23)`,不是 20 也不是 17。因为这个项目用了一个 C++23 才标准化的东西,`std::expected`,第 02 章会专门讲它是什么、为什么用它。它的头文件是 ``,只有 C++23 起的标准库才提供。 + +这就引出一个实打实的版本门槛,你的编译器得够新,标准库里才有 ``。先别急着想"我装的 GCC 应该够新吧",我们先确认一下,免得第 02 章你写 `#include ` 直接报 `expected: No such file or directory`,然后怀疑人生。 + +各编译器对 `` 的最低可用版本大致是这样: + +| 编译器 | 最低可用版本 | 自查 | +|---|---|---| +| GCC | 14 及以上最稳(12/13 部分支持,容易踩坑) | `gcc --version` | +| Clang + libc++ | 17 及以上 | `clang --version` | +| MSVC | VS 2022 17.10+(`cl` 19.40+) | VS Installer,或命令行 `cl` | + +::: warning Ubuntu 22.04 用户特别注意 +Ubuntu 22.04 默认仓库的 gcc 是 11,远达不到 `` 的要求,你会在第 02 章直接撞 `expected: No such file`。两条出路,升级到 Ubuntu 24.04(默认 gcc-13,仍偏旧但勉强能用),或者用 toolchain PPA 装个 gcc-14: + +```bash +sudo add-apt-repository ppa:ubuntu-toolchain-r/test +sudo apt update && sudo apt install gcc-14 g++-14 +# 用的时候指定 g++-14 而不是 g++ +``` +::: + +最快的确认方法其实不是查版本号,而是直接让编译器试编一段用到 `` 的代码。这招比看版本号靠谱得多,因为有些版本号够了、标准库却没跟上: + +```bash +cat > /tmp/t.cpp <<'EOF' +#include +#include +int main() { + std::expected v = 42; + std::cout << *v << '\n'; +} +EOF +g++ -std=c++23 /tmp/t.cpp -o /tmp/t && /tmp/t # 期望打印 42 +``` + +能编、能跑出 42,你这台机器就 C++23 就绪,放心往下走。报 `expected: No such file or directory` 就是编译器或标准库太旧,按上面那张表升级。 + +有的读者手头就是一台老机器老发行版,升 gcc 要 root、要审批,一时半会儿升不动。说实话没关系,`std::expected` 想解决的事,一个函数可能返回值、也可能返回错误、要把这两种情况都塞进类型里,用 C++17 也能近似,标准库的 `std::variant`,或者干脆返回一个带错误码的 struct。第 02 章会在讲完 `std::expected` 之后补一小段"如果你只有 C++17 怎么办"的 fallback 写法,不至于被工具链卡死。不过本项目主线就是 C++23,能升还是尽量升。 + +## 上手:建一个空白 Qt 程序,弹出窗口 + +讲了一堆原理,该动手了。这一章的目标很朴素,用你刚学的 CMake 知识建一个最小的 Qt 程序,在屏幕上弹出一个空白窗口。我们暂时只用 ASCII 纯英文,中文和那个 `/utf-8` 的坑留到第 02 章专门拆。 + +随便找个空目录,建两个文件: + +```cpp +// main.cpp +#include +#include + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); // 每个 Qt Widgets 程序都得有这一个,管事件循环 + QWidget window; // 一个空白窗口控件 + window.resize(400, 300); + window.setWindowTitle("hello qt"); + window.show(); // 控件默认不显示,要 show() + return app.exec(); // 进入事件循环,直到关窗口才返回 +} +``` + +```cmake +# CMakeLists.txt +cmake_minimum_required(VERSION 3.19) +project(hello-qt LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Widgets) + +qt_standard_project_setup() + +qt_add_executable(hello-qt main.cpp) + +target_link_libraries(hello-qt PRIVATE Qt6::Core Qt6::Widgets) +``` + +然后就是那两步,把 `CMAKE_PREFIX_PATH` 换成你那个平台的值,Linux apt 装的可以省略: + +```bash +cmake -B build -DCMAKE_PREFIX_PATH="<你的 Qt 路径>" # configure +cmake --build build # build +``` + +跑起来: + +```bash +./build/hello-qt # Linux +build\Debug\hello-qt.exe # Windows,Debug 或 Release 看生成器 +open build/hello-qt.app # macOS +``` + +屏幕上弹出一个 400×300 的空白窗口,标题栏写着 hello qt,能关掉、能拖动。很好,工具链通了。这扇破窗口长得不咋地,但它替你证明了一件事,Qt6 装对了、C++23 编译器认了、CMake 的两步走确实把 `.cpp` 变成了能跑的程序,这一章的目的就达到了。 + +`main.cpp` 里那几行 Qt 代码现在看不懂很正常,`QApplication`、`QWidget`、`app.exec()` 这些是 Qt 的入门概念,留到第 05 章搭 light-meter 的 UI 时从"事件循环是什么"开始系统讲。这一章的重点是工具链和 CMake,代码只是用来验证工具链跑通的最小载体,别盯着它纠结。 + +## 这一章的坑 + +第一个坑,`Could not find Qt6`。十有八九是 `CMAKE_PREFIX_PATH` 没设或者设错,回到上面那个"不确定该填哪个路径"的办法,用 `Qt6Config.cmake` 的实际位置回溯。 + +第二个坑在 Linux apt 装的 Qt6 上,版本太旧或者残缺。Ubuntu 22.04 仓库的 Qt6 基本不可用,要么升发行版,要么用 qt.io 官方安装器。`apt show qt6-base-dev` 看一眼版本号就心里有数了。 + +Windows 上的第三个坑是生成器选错。`cmake -B build` 默认可能挑了不是你 Qt 对应的生成器,比如你装的是 MSVC 版 Qt,CMake 却默认用 MinGW 生成器。最省心的办法是用 Visual Studio 直接打开 `CMakeLists.txt`,VS 自带 CMake 支持会自动配好 MSVC 生成器;命令行党可以显式指定 `cmake -B build -G "Visual Studio 17 2022"`。 + +第四个坑比较阴,`CMAKE_CXX_STANDARD_REQUIRED` 没加,你以为在用 C++23 其实偷偷降到了 20,然后第 02 章 `#include ` 报错,你却以为是代码写错了。我们的模板里这一行已经在了,别删。 + +最后一个坑会陪我们走完整个系列,就是 `build/` 目录被搞脏。你改了 `CMAKE_PREFIX_PATH` 或者换了 Qt 版本,但 `cmake --build` 还是老样子,因为 `build/CMakeCache.txt` 缓存了旧的探测结果。解法很粗暴,`rm -rf build` 删掉重来。CMake 的缓存是好东西,但它也会咬人,这个机制第 07 章讲编译开关时还会再撞上。 + +## 小结 + +你可能会想,弹个空白窗口至于讲这么多吗。说实话挺至于的。CMake 那两步走、target 这个概念、`find_package` 跟 `CMAKE_PREFIX_PATH` 怎么对上、C++ 标准号和编译器版本谁对应谁,这些不是装完就完的一次性知识,是后面每一章翻 `CMakeLists.txt` 都要默默读一遍的东西。这章把它们磨一遍,后面 light-meter 的构建脚本就不会有哪行是凭空冒出来的。 + +回头看 light-meter 的 `examples/light-meter/CMakeLists.txt`,你现在应该能看懂除了 `USE_REAL_SENSOR` 那一段之外的每一行。剩下的那一段是编译期切换真机和 Mock 后端的开关,留到第 07 章讲,它搭的就是你这一章学的 `option`、`target_compile_definitions` 那几个机制,逃不掉的。 + +下一章换口味,把源码里冒出来的中文和 `std::expected` 这个 C++23 新家伙一起讲透,期间你会亲手把 `/utf-8` 删掉、制造一次中文乱码,再加回来修好。 + +## 继续学习 + + + ← 项目总览 + 02 C++23 + 中文源码 → + diff --git a/document/tutorial/project/light-meter/02_cpp23_utf8_expected.md b/document/tutorial/project/light-meter/02_cpp23_utf8_expected.md new file mode 100644 index 000000000..f4a165725 --- /dev/null +++ b/document/tutorial/project/light-meter/02_cpp23_utf8_expected.md @@ -0,0 +1,177 @@ +--- +title: C++23 + 中文源码 +--- + +# C++23 + 中文源码:`/utf-8` 与 `std::expected` 的第一口 + +::: info 本节你将学到 +- 为什么 light-meter 的源码里满是中文,在 MSVC 上却不会乱码,`/utf-8` 这个开关到底改了什么 +- 源字符集和执行字符集是两件事,搞混了就是你那一屏的问号和方块 +- `std::expected` 解决的是什么问题,它比异常、返回码、`std::optional`、`std::variant` 好在哪 +- 怎么读一个 `expected`、怎么构造一个错误、`.value()` 在错误时会发生什么 +- 如果你暂时升不到 C++23,用 C++17 的 `std::variant` 怎么近似 +::: + +::: tip 前置知识 +- 第 01 章:你已经能在桌面 cmake 配置加编译一个 Qt 程序,弹出过窗口 +- 知道 C 里函数怎么返回错误,返回码、errno,这就够了。异常和现代 C++ 的错误处理我们从零讲 +::: + +## 两件看起来不搭的事,被同一份 CMakeLists 串着 + +你翻 light-meter 的 `CMakeLists.txt`,会看到两处乍看没关系、其实都跟"C++23"挂钩的设置。一处是 `set(CMAKE_CXX_STANDARD 23)`,这是我们用 `std::expected` 的前提,第 01 章讲过。另一处在结尾那几行: + +```cmake +# 源文件为 UTF-8(无 BOM), 含中文; 告知 MSVC 以 UTF-8 解释源与执行字符集。 +if(MSVC) + target_compile_options(light-meter PRIVATE /utf-8) +endif() +``` + +这五行注释已经把答案写在脸上了,但没说清楚"源字符集""执行字符集"到底是个啥、为什么只有 MSVC 要管、不加会怎样。所以我们先把这件事讲透,再借着 C++23 这个由头,把 `std::expected` 这个贯穿 light-meter 全代码的错误处理类型,从零讲到你能用得顺手。这两件事看着不搭,共同点其实就一条:它们都是你写第一行 light-meter 代码之前,就得先在 CMake 里配好的基础设施,所以放一起讲顺。 + +## 中文源码为什么在 MSVC 上会乱码 + +light-meter 的界面文本几乎全是中文,"桌面照度"、"光线不足,建议开灯"、"已导出"。这些字符串字面量直接写在 `.cpp` 里,而 `.cpp` 文件本身是用 UTF-8 存的。在 GCC 和 Clang 上你什么都不用做,它们默认就按 UTF-8 读你的源码、字符串字面量也按 UTF-8 编进二进制。但 MSVC 不是,这里有个坑。 + +要理解这个坑,得先把两个概念分清楚,它们是两件事,但名字像,初学者经常混。 + +源字符集,是编译器**读你那份 `.cpp` 文件**时,把里面的字节当什么编码来解释。你用 VS Code、用 Vim,默认都把文件存成 UTF-8,所以源字符集理想情况下就该是 UTF-8。 + +执行字符集,是编译器把你写下的字符串字面量,**编进最终二进制里**时用的编码。运行时 `QString`、`std::string` 拿到的字节,就是这个编码的产物。 + +MSVC 的历史默认行为,是把这俩都设成系统区域设置的代码页,简体中文 Windows 上就是 GBK(代码页 936)。可你的源文件明明是 UTF-8 的。于是 MSVC 按 GBK 去读你那份 UTF-8 的源文件,中文字节就对不上号,轻则报一个 C4819 警告告诉你"源文件里有当前代码页无法表示的字符",重则默默把字符串字面量编成一堆乱码字节,程序跑起来界面全是问号和方块。GCC 和 Clang 默认就按 UTF-8 读源码,所以同一份代码在它们上面没事,这就是为什么这个坑是 MSVC 专属的。 + +解法就是 `/utf-8` 这个开关。它等价于同时设了 `/source-charset:utf-8` 和 `/execution-charset:utf-8`,也就是告诉 MSVC,源文件按 UTF-8 读,字符串字面量也按 UTF-8 编进二进制。两个都钉死成 UTF-8,中文就老实了。light-meter 的 CMakeLists 里就是用这几行做的: + +```cmake +if(MSVC) + target_compile_options(light-meter PRIVATE /utf-8) +endif() +``` + +`if(MSVC)` 这个守卫保证这个选项只在 MSVC 上加。GCC 和 Clang 根本不认 `/utf-8`,加了反而报错。这是跨平台 CMake 的常见写法,平台相关的编译选项都用 `if(MSVC)` / `if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")` 这种判断包起来。 + +### 上手:亲手制造一次乱码 + +纸上谈兵不如自己撞一次。打开你第 01 章那个 hello-qt,把窗口标题改成中文: + +```cpp +window.setWindowTitle("你好 Qt"); +``` + +然后在 `CMakeLists.txt` 里加上上面那段 `/utf-8` 的设置,先确保它**在**。重新编译,标题栏正常显示"你好 Qt"。 + +接下来这一步有点贱:把 `if(MSVC) ... endif()` 整段**注释掉**,重新编译。如果你在 Windows 上用 MSVC,大概率会看到两种结果之一,要么编译时蹦一个 C4819 警告,要么编译过了但运行起来标题栏是乱码。把那段加回来,重新编译,又好了。这一趟走下来,"源字符集/执行字符集"这件事就从概念变成肌肉记忆了。GCC 和 Clang 用户做这个实验会发现自己怎么改都正常,这本身就说明了那个 `if(MSVC)` 守卫为什么有必要存在。 + +::: tip 一个常见误区 +有人发现乱码后,跑去把 `.cpp` 文件"另存为 GBK 编码"。这能让你在 MSVC 上不报错,但代价是这份源文件在 Linux/macOS 上、在别人那、在 CI 上全变成乱码,git 里 diff 也跟着乱。正确做法永远是保持源文件 UTF-8,然后让 MSVC 按 UTF-8 读,也就是 `/utf-8`。 +::: + +## std::expected:把错误从返回值里救出来 + +中文的事解决了,接下来是 C++23 真正的主角,`std::expected`。light-meter 里凡是可能失败的操作,`Sensor::init`、`Sensor::query_once`,返回类型全是 `std::expected<...>`,所以这个东西你必须吃透,不然后面读 sensor 那一层会一脸懵。 + +先说它要解决的问题。一个函数除了返回正常结果,还可能失败,失败的时候得把错误信息告诉调用者。这件事 C 和老 C++ 有好几种做法,每一种都有代价。 + +返回码是最朴素的,函数返回 0 表示成功、非 0 表示错误,错误细节塞 errno 或者 out 参数。问题是你太容易忘了检查返回码,编译器不会帮你,于是错误一路被忽略,最后在奇怪的地方炸。异常是 C++ 的"高级"方案,失败就 throw,沿调用栈往上找人接。好处是错误处理和正常逻辑分开了,坏处是控制流隐式、性能有代价,而且嵌入式和实时场景经常禁用异常。`std::optional` 表示"可能有值也可能没有",但它只能告诉你"没有",说不出来"为什么没有",错误信息丢了。`std::variant` 能同时装值和错误,但用起来啰嗦,`std::get`、`std::holds_alternative` 一长串。 + +`std::expected` 就是来填这个坑的。它表示"要么是一个 T 类型的正常值,要么是一个 E 类型的错误",而且把这个意图写进了类型签名里。函数签名 `std::expected parse(...)` 一眼就告诉你:成功给 int,失败给 ParseErr,而且你必须面对错误这个分支,因为它在类型里。 + +### 它长什么样,怎么用 + +读一个 `expected` 最基本的方式,是先判它有没有值,再决定取值还是取错误。`expected` 可以隐式转成 bool(有值为 true),也可以调 `.has_value()`。有值时 `.value()` 取出值,出错时 `.error()` 取出错误。还有一个 `.value_or(default)`,出错就用你给的默认值,省得你自己写判断。 + +构造一个"出错"的 `expected`,得用 `std::unexpected{...}` 把错误包一层。这是因为光写一个错误值进去,编译器分不清你是要存值还是存错误。构造一个"成功"的 `expected` 就简单多了,直接返回那个值就行。 + +一个最小的例子,一个不会除零的除法: + +```cpp +#include +#include + +enum class DivErr { DivByZero }; + +std::expected safe_divide(double a, double b) { + if (b == 0.0) return std::unexpected{DivErr::DivByZero}; // 失败:包一层 unexpected + return a / b; // 成功:直接返回值 +} + +int main() { + auto r = safe_divide(10.0, 0.0); + if (r) { + std::cout << "结果: " << r.value() << '\n'; + } else { + std::cout << "出错: 除零\n"; + } +} +``` + +`safe_divide` 的签名已经把契约写死了:成功给你 double,失败给你 DivErr。调用者拿到返回值,先 `if (r)` 判一下。这条路其实是绕不开的,因为错误就在类型里,你不处理它就过不了编译(你总得决定是取 value 还是取 error)。这就是 `expected` 比返回码强的地方,编译器逼着你面对失败。 + +这里有个坑一定要提前讲。`.value()` 在 `expected` 处于错误状态时会**抛异常**,抛的是 `std::bad_expected_access`。也就是说,你不判就直接 `.value()`,出错时程序不会安安静静返回个垃圾值,而是会抛。说实话这其实是好事,比返回码那种"默默继续跑出诡异结果"安全得多,但你要知道它会发生。想完全不抛,就用 `.value_or()`,或者老老实实先 `if (r)` 判一下。 + +还有一组更高级的链式操作,`and_then`、`or_else`、`transform`,能让你像写管道一样把多个可能失败的调用串起来,不用层层 `if` 嵌套。light-meter 里暂时没用到这套,你先记住 `.value()`、`.error()`、`.value_or()`、`if (r)` 这四样就够读和写了,链式的那套等真需要了再翻 cppreference。 + +### 为什么 light-meter 到处用它 + +往后翻一眼 light-meter 的 `sensor/sensor.h`,你会看到这两个签名: + +```cpp +virtual std::expected init(bool force_reinit = false) = 0; +virtual std::expected query_once() = 0; +``` + +`init` 可能打开设备失败,所以返回 `expected`,注意值类型是 `void`,意思是"成功了不带值,只告诉你成功没成功,失败了给你一个 InitError"。`query_once` 成功带一个 `SensorData`,失败带一个 `QueryError`。这正是 `expected` 最典型的用法:把"正常值"和"错误"都塞进返回类型,逼调用者面对失败。第 03 章我们会把这两个签名掰开揉碎讲,这里你只要建立"`expected` 就是 light-meter 错误处理的通用语言"这个印象就行。 + +`InitError` 和 `QueryError` 都是 `enum class`,这是 C++11 引入的作用域枚举,值不会污染外层命名空间,具体下一章讲。 + +## 如果你只有 C++17:用 variant 近似 + +有的读者手头编译器确实升不到能稳定用 `` 的版本,但又想跟着 light-meter 走。`std::expected` 是 C++23 才标准化的,但它的核心能力,C++17 的 `std::variant` 能近似,只是写起来啰嗦些。把上面那个 safe_divide 用 variant 改写一下,你就能直观感到差别: + +```cpp +#include +#include + +enum class DivErr { DivByZero }; + +std::variant safe_divide_v17(double a, double b) { + if (b == 0.0) return DivErr::DivByZero; + return a / b; +} + +int main() { + auto r = safe_divide_v17(10.0, 0.0); + if (std::holds_alternative(r)) { + std::cout << "结果: " << std::get(r) << '\n'; + } else { + std::cout << "出错\n"; + } +} +``` + +`std::variant` 表示这个值要么是 double 要么是 DivErr,跟 `expected` 的语义其实一样。但你往读取那一侧看,`std::holds_alternative(r)` 判断、`std::get(r)` 取值,这一长串比 `if (r) r.value()` 啰嗦多了。而且 `variant` 不会在类型里区分谁是"值"谁是"错误",纯靠你记着"第一个模板参数是值、第二个是错误"这种约定。`expected` 的好处就是把这条约定写进了类型,读和写都更顺。所以主线还是推荐 C++23 的 `expected`,实在升不动再用 variant 顶上。 + +## 这一章的坑 + +第一个坑,直接 `.value()` 不判断。`expected` 在错误状态下 `.value()` 会抛 `bad_expected_access`,你以为它返回个默认值,结果是程序崩在异常上。要么先 `if (r)`,要么用 `.value_or()`。 + +第二个坑,把 `expected` 和 `optional` 搞混。`optional` 只有"有没有值",`expected` 是"有值还是有错误"。如果你需要知道失败的原因,就用 `expected`;失败就是失败、不关心原因,才用 `optional`。light-meter 全用 `expected`,因为它要区分设备没初始化、设备不可用这些不同错误。 + +第三个坑还是回到中文,源文件存成了 UTF-8 **带 BOM** 的。MSVC 对带 BOM 的文件会自动识别成 UTF-8,这本来是好事,但有些工具链、有些旧编译器对 BOM 处理不一致,会报奇怪的错误。light-meter 的注释里特意写了"无 BOM",就是让你存成 UTF-8 但不要带 BOM。VS Code 右下角状态栏点编码,选"通过编码保存 UTF-8"而不是"UTF-8 with BOM"。 + +第四个坑,`enum class` 的错误值忘了用 `std::unexpected` 包。`return DivErr::DivByZero;` 直接返回一个错误值,编译器会以为你要构造的是值类型那个分支,类型对不上就报错。错误必须 `return std::unexpected{DivErr::DivByZero};` 这样包一层。 + +## 小结 + +MSVC 上中文不乱,靠的就是 `/utf-8` 把源字符集和执行字符集都钉死成 UTF-8,再用 `if(MSVC)` 守住,只在 MSVC 上加。`std::expected` 则是 light-meter 错误处理那套话的语法,它把"值或错误"塞进类型,编译器逼着你面对失败那条路,比返回码安全、比异常轻、写起来也比 variant 顺。这俩都是 light-meter 反复用到的基础设施,后面读代码你会一直撞见它们。 + +下一章我们正式动 light-meter 的第一份代码,`sensor/sensor.h`,那个被 `std::expected` 撑起来的 Sensor 抽象契约。到时候你会发现,刚学的 `expected` 正好就是它返回类型的语言,而 `enum class` 这种 C++ 基础也会一并从零讲起。 + +## 继续学习 + + + ← 01 装好 Qt6 与 C++23 工具链 + 03 Sensor 抽象契约 → + diff --git a/document/tutorial/project/light-meter/03_sensor_contract.md b/document/tutorial/project/light-meter/03_sensor_contract.md new file mode 100644 index 000000000..f6ba40c6f --- /dev/null +++ b/document/tutorial/project/light-meter/03_sensor_contract.md @@ -0,0 +1,216 @@ +--- +title: Sensor 抽象契约 +--- + +# Sensor 抽象契约:为什么先把接口钉死,再写任何 UI + +::: info 本节你将学到 +- 为什么 light-meter 第一份动笔的代码不是 UI,而是一个什么都不干、只定义接口的抽象类 +- C++ 的虚函数、纯虚函数、抽象类到底解决了什么,以及为什么多态基类的析构函数必须是虚的 +- `enum class` 比 C 的 `enum` 安全在哪 +- 逐行读懂 `sensor/sensor.h`,包括那个被全代码消费、却拼错了的字段名 `luxury` +- 拉模型(应用主动 query)为什么比推模型(回调)更适合 Qt 的事件循环 +::: + +::: tip 前置知识 +- 第 02 章的 `std::expected`,这一章的 `sensor.h` 返回类型全是它 +- 听说过"面向对象"和"继承"这两个词就行,虚函数我们从零讲 +::: + +## 为什么先写一个什么都不干的抽象类 + +你可能以为,做一个照度摆件,第一份代码应该是那个 lux 大数字或者那条折线。light-meter 不是。它第一份动笔的代码是 `sensor/sensor.h`,一个**不能被实例化、不带任何实现、只声明"一个传感器该长什么样"**的抽象类。这顺序听着反直觉,但它会在第 07 章"翻一个 CMake 开关就换后端"那一招里给你兑现回报。我们慢慢说为什么。 + +先想个反例。假设你不写这个抽象基类,直接在 `MainWindow` 里 `#include "ap3216c_sensor.h"`,直接 `Ap3216cSensor sensor;` 然后 `sensor.query_once()`。看起来省事,可你立刻背上两个负担。一是 UI 代码跟真机驱动后端焊死了,想在桌面上跑(没有 `/dev/ap3216c`)调试 UI 根本不可能,你每次都得插板子;二是哪天想换一颗传感器、或者想喂假数据做测试,得把 UI 里所有调 `sensor` 的地方挨个翻一遍。 + +抽象基类 `Sensor` 就是来解这件事的。它定义一个**契约**:任何一个想被 UI 当作数据源用的东西,都得提供 `init` 和 `query_once` 这两个操作,签名就是这样。UI 只持有一个 `Sensor*` 指针,它不关心底下到底是 MockedSensor 还是 Ap3216cSensor。这么一来,UI 代码只跟契约打交道,后端是可插拔的,桌面调试时插 Mock、上板时插真机。"先定契约再写实现"这个判断,说实话,比任何花哨的设计模式都顶用。 + +要把这个契约在 C++ 里落地,得先把虚函数这个 C++ 基础从零讲清楚。 + +## 从 C 到 C++:虚函数到底解决了什么 + +假设你在 C 里想做"同一类东西有不同实现"这件事。比如你有几种传感器,都想叫 `sensor_read`,但每种读法不一样。C 的做法是函数指针:定义一个结构体,里面塞一个 `int (*read)(void*, int*)` 函数指针,不同的传感器给这个指针赋不同的函数。能用,但啰嗦,而且调用的时候你得手动传那个 `void*` 上下文。 + +C++ 给了一个更顺滑的机制,虚函数。你在基类里把一个函数标成 `virtual`,派生类可以重写它,然后你通过基类指针调用时,**实际跑的是派生类那个版本**,不是基类的。这就是多态。它的底层实现是一张函数指针表,叫 vtable,每个有多态对象的类一张,每个对象头部藏一个指针指向它。你通过基类指针调虚函数时,编译器生成的代码是"读对象的 vptr、查表、调对应的函数指针",所以运行时能找到正确的派生实现。这些细节你不用记,有个心智模型就够:虚函数是"通过基类指针调用、运行时决定跑哪个版本"的函数,这就是它能模拟"同一接口、不同实现"的根。 + +把虚函数推到极致,就是**纯虚函数**。在声明后面加 `= 0`,这个函数在基类里就没有实现,派生类必须提供实现。一个有纯虚函数的类叫**抽象类**,它自己**不能被实例化**,你写 `Sensor s;` 直接编译报错。抽象类的意义就是当契约用:我规定了接口长这样,实现交给派生类。light-meter 的 `Sensor` 就是抽象类,它的 `init` 和 `query_once` 都是纯虚的。 + +### 虚析构:多态基类不可省的一行 + +这里有一个 C++ 经典坑,所有讲虚函数的教程都会强调,我们也强调。一个类如果打算被当作多态基类用,也就是你会通过基类指针 `delete` 一个派生类对象,那它的**析构函数必须是虚的**。看个反例: + +```cpp +class Bad { +public: + ~Bad() {} // 不是虚的 + virtual void f() = 0; +}; + +class Derived : public Bad { + int* data; +public: + Derived() : data(new int[100]) {} + ~Derived() { delete[] data; } // 有资源要释放 +}; + +Bad* p = new Derived; +delete p; // 未定义行为:只调用了 Bad::~Bad(),Derived 的析构没跑,data 泄漏 +``` + +这里 `delete p` 因为 `p` 的静态类型是 `Bad*`,而 `Bad` 的析构不是虚的,编译器就只生成"调用 `Bad::~Bad()`"的代码,`Derived` 的析构函数被跳过,那 100 个 int 泄漏了。更糟的情况是基类没析构、派生类有需要释放的资源时,直接是未定义行为,程序可能崩。 + +解法就一行,把析构标成虚的: + +```cpp +class Good { +public: + virtual ~Good() = default; // 虚析构,=default 让编译器生成默认实现 + virtual void f() = 0; +}; +``` + +`virtual ~Sensor() = default;` 这一行,`virtual` 保证 `delete` 基类指针时析构链能正确走到派生类,`= default` 表示"我不要自定义析构逻辑,你给我生成默认的就行"。light-meter 的 `sensor.h` 就有这一行,任何一个想被多态使用的基类都不能省它,这一步不改一定炸。 + +### enum class:比 C 的 enum 安全在哪 + +`sensor.h` 里还有两个枚举,`InitError` 和 `QueryError`,它们用的是 `enum class` 而不是 C 里那种 `enum`。区别值得花两句讲。C 的 `enum`(`enum Color { RED, GREEN, BLUE };`)的值会污染外层命名空间,你能直接写 `RED`,而且它会隐式转成 `int`,你拿一个 `Color` 去当整数用、拿一个整数去当 `Color` 用,编译器都不拦你,bug 就藏在里面。 + +`enum class`(`enum class Color { Red, Green, Blue };`)是 C++11 引入的作用域枚举。它的值必须带前缀用,`Color::Red`,不会跟别的 `Red` 撞;而且它不会隐式转成 int,你想转得显式 `static_cast(Color::Red)`,编译器能在编译期挡掉一堆类型混用的错误。light-meter 的错误类型用 `enum class`,所以 `InitError::DeviceUnavailable` 这种写法既清楚又不会跟别的枚举撞名。 + +## 逐行读 sensor.h + +概念讲够了,来读真东西。下面是 `examples/light-meter/sensor/sensor.h` 的全部内容,我们一段段拆: + +```cpp +#ifndef SENSOR_H +#define SENSOR_H + +#include + +struct SensorData { + double luxury; // light luxury + int ps; // how hand close to the sensor +}; +``` + +先是 `SensorData`,一个普通的结构体,装一次采样的结果。它有两个字段,一个是照度,一个是接近度。这里有个事得诚实告诉你,字段名是 `luxury`,不是 `lux`。lux 照度,被写成了 luxury 奢华,上面那行注释 `// light luxury` 大概是想写 `light lux` 又写岔了。这事问就是手滑写错了,本来想改,结果一翻代码,UI、折线图、CSV 导出全代码都在用 `res->luxury`、`.luxury = ...`,这字段名已经是 public 契约的一部分了,动一下得改整个项目。索性不改,逃。后面章节你看到 `luxury`,知道它就是 lux 就行,别被这个 luxury 带偏。接口一旦发布出去,拼写错了也得维持稳定,这是契约的代价,跟现实里给变量起错名一样,改不动就只能认。 + +```cpp +class Sensor { + public: + enum class InitError { + Ok, GeneralFailed, DeviceUnavailable + }; + + enum class QueryError { + Ok, NotInited, DeviceUnavailable + }; +``` + +接着是 `Sensor` 类本体,开头两个 `enum class` 嵌在类里面,这意味着它们的完整名字是 `Sensor::InitError`、`Sensor::QueryError`,在类外面用得带前缀,在类的成员函数里可以直接写 `InitError`。`InitError` 装的是初始化可能的三种结果,成功、一般失败、设备不可用;`QueryError` 装的是查询可能的三种结果,成功、还没初始化、设备不可用。注意这两个枚举里都有 `Ok`,但它们是不同的类型,`InitError::Ok` 和 `QueryError::Ok` 不会混,这就是 `enum class` 作用域的好处。 + +```cpp + Sensor() = default; + virtual ~Sensor() = default; // 多态基类: 经基类指针 delete 需 virtual 析构 +``` + +构造函数 `= default` 表示让编译器生成默认的,我们不需要自定义。析构函数上一节讲过,`virtual ... = default` 是多态基类的标配,不可省。 + +```cpp + virtual std::expected init(bool force_reinit = false) = 0; + + virtual std::expected query_once() = 0; +``` + +这两行就是契约的核心。`init` 是纯虚函数,返回 `std::expected`,第 02 章讲过,值类型是 `void` 表示"成功不带值,失败带一个 InitError",它带一个 `bool force_reinit` 参数,默认 false,意思是"如果已经初始化过,要不要强制重新来一遍"。`query_once` 也是纯虚,返回 `std::expected`,成功带一个采样数据,失败带一个 QueryError。这两个 `= 0` 让 `Sensor` 成了抽象类,谁想当数据源,就得实现这两个操作。 + +```cpp + /// 测试数据注入(Mock 用);真机后端忽略(no-op)。 + /// 留在基类以便 UI 层无差别调用 —— 切换后端时不需改 MainWindow 的调用点。 + virtual void set_phase(double /*phase*/) {} + virtual void set_held(bool /*is_held*/) {} +}; +``` + +最后这两个 `set_phase` 和 `set_held` 值得停下来想一想。它们是虚函数,但**不是纯虚**,带一个默认的空实现 `{}`。用途是给 Mock 后端"注入测试数据",`set_phase` 推进假数据的相位,`set_held` 模拟"手靠近"。问题来了,真机后端根本不需要这两个操作,它的数据来自真硬件。 + +那为什么不把它们放在 MockedSensor 里当私有方法,而要放进基类?注释里那两行说得明白,放在基类、给个空实现,是为了让 UI 层可以**无差别地调用**。`MainWindow` 里 `m_sensor->set_phase(...)`、`m_sensor->set_held(...)` 这些调用,在 Mock 后端时真的推进假数据,在真机后端时走基类的空实现、什么也不发生。这样切换后端的时候,UI 的调用点一行都不用改。换个说法,如果这俩方法是 Mock 私有的,UI 切真机时就得把所有 `set_phase` 调用删掉或者加判断,那"翻一个开关就换后端"的干净就破坏了。把测试注入口放基类、用空实现兜底,代价是基类多俩没用方法,换回来的是切换后端零改动,这笔账怎么算都值。 + +## 拉模型 vs 推模型:为什么是 init 加 query_once + +你可能注意到,`Sensor` 的接口是"应用主动去 query 一次",而不是"传感器有数据了回调通知应用"。前者叫拉模型,pull,后者叫推模型,push,带回调或者信号。两种都能用,light-meter 选拉模型是有理由的,这个理由跟 Qt 的事件循环有关。 + +Qt 的 GUI 程序跑在一个事件循环里,主线程不停地从队列取事件、处理、再取下一个。light-meter 的采样节奏是由一个 200ms 的 `QTimer` 控制的(第 05 章细讲),timer 到点了,主循环就去 `query_once()` 拉一次数据、刷新 UI。这种"应用主动按自己的节奏拉"的方式,跟事件循环天然咬合,采样频率完全由应用说了算,不依赖传感器那边什么时候主动推。推模型在异步、事件驱动的传感器上有它的价值,但 light-meter 想要的是"5Hz 的等间隔采样",拉模型简单得多,也顺手避开了回调线程跟 UI 线程之间那堆同步麻烦。这件事第 05 章接上 QTimer 之后你会体会得更深,这里先记住"拉模型是 light-meter 主动选的"就行。 + +## 上手:写一个 FakeSensor 验证契约 + +光读不练,契约到底能不能被满足、满足起来别不别扭,你心里是没底的。我们来写一个最小的 `FakeSensor`,只 override 那两个纯虚函数,返回固定数据,然后通过 `unique_ptr` 持有它、调用它。这一段不需要 Qt,纯 C++,你 `g++ -std=c++23` 就能编。 + +新建一个目录,放一个 `sensor.h`(把上面那份完整内容贴进去,或者直接从 `examples/light-meter/sensor/sensor.h` 拷一份),再写一个 `test.cpp`: + +```cpp +#include "sensor.h" + +#include +#include + +// 一个假的传感器,只为验证 Sensor 契约能被满足 +class FakeSensor : public Sensor { +public: + std::expected init(bool /*force_reinit*/) override { + return {}; // 成功,什么都不干 + } + + std::expected query_once() override { + return SensorData{ .luxury = 300.0, .ps = 0 }; // 永远返回 300 lux、无人靠近 + } +}; + +int main() { + std::unique_ptr sensor = std::make_unique(); + auto init_res = sensor->init(false); + if (!init_res) { + std::cout << "init 失败\n"; + return 1; + } + + auto data = sensor->query_once(); + if (data) { + std::cout << "luxury=" << data.value().luxury << " ps=" << data.value().ps << '\n'; + } +} +``` + +编译运行: + +```bash +g++ -std=c++23 test.cpp -o test && ./test +# 期望输出: luxury=300 ps=0 +``` + +这里有几个值得停一下的点。`std::unique_ptr` 持有一个基类指针,实际指向一个 `FakeSensor` 对象,这正是上一节讲的多态用法,而因为有虚析构,`unique_ptr` 析构时能正确调到 `FakeSensor` 的析构(虽然这里它没资源要释放)。`override` 这个关键字是 C++11 的好东西,它告诉编译器"我这个函数是想重写基类的虚函数",你签名要是写错了,比如参数类型对不上,编译器当场就报错。我的习惯是永远写 `override`,抓"以为重写了其实没重写"这种 bug,靠它最稳。`SensorData{ .luxury = 300.0, .ps = 0 }` 这种写法叫指定初始化,C++20 起的语法,按字段名赋值,可读性比按位置好。 + +跑出来 `luxury=300 ps=0`,契约就被一个具体类满足了,而且满足起来不别扭。这意味着,后面不管 MockedSensor 还是 Ap3216cSensor,只要实现了 `init` 和 `query_once`,UI 就能无差别地用它们。 + +## 这一章的坑 + +第一个坑,基类忘了写虚析构。症状通常是"程序大多数时候正常,偶尔崩在退出的时候",因为 UB 不保证每次都炸,这种偶发崩溃最折磨人。任何要被多态使用的基类,`virtual ~ClassName() = default;` 这一行就是肌肉记忆,写之前先写它。 + +第二个坑,override 不继承基类的默认参数。这是 C++ 一个出了名的反直觉点。`Sensor::init` 的声明是 `init(bool force_reinit = false)`,有默认参数。但派生类 override 它时,默认参数**不会**继承,而且默认参数是按**静态类型**决定的,跟虚函数的动态派发是两套机制。light-meter 的 `mainwindow.cpp` 里 `m_sensor->init(false)` 是显式把 false 传进去的,旁边注释也特意写了"override 不继承基类默认参数, 显式传 false"。你自己调的时候,要么显式传,要么确认基类那份默认参数就是你想要的,别指望它在派生类里也生效。 + +第三个坑,在构造函数或析构函数里调虚函数。这时候虚函数不会表现出多态行为,它只会调当前类(构造/析构正在进行的那一层)的版本,因为派生部分还没构造好或已经销毁了。这个坑 light-meter 没踩到,但写多态代码大概率会撞上,先记着。 + +第四个坑,对象切片。如果你把一个派生类对象**按值**赋给一个基类对象,`Derived d; Base b = d;`,派生类特有的部分会被切掉,`b` 就是个纯基类对象,虚函数也派发不到派生版本。这就是为什么多态必须通过**指针或引用**用,light-meter 用 `unique_ptr` 就是这个道理。 + +## 小结 + +虚函数、纯虚函数、抽象类的心智模型,加上多态基类必须虚析构这条铁律,是这一章第一个落点。`enum class` 比 C 的 enum 安全在哪,是顺手带过的第二个。剩下的篇幅都在拆 `sensor.h` 那份契约,从拼错的 `luxury`,到把测试注入口放基类这步设计,再到为什么选拉模型,你都过了一遍。 + +回头看你手里这份 `sensor.h`,它现在不该再是一段陌生代码了,该是一份你能逐行解释、能照着写出新后端的契约。下一章我们就实现第一个后端,`MockedSensor`,它在桌面上"假装有硬件",让你不插板子也能把数据喂给 UI。顺带会讲 light-meter 用到的另一个 C++ 模式,用 `unique_ptr` 配自定义 deleter 管理一个不完整类型,也就是轻量版 pImpl。 + +## 继续学习 + + + ← 02 C++23 + 中文源码 + 04 MockedSensor 与 custom-deleter pImpl → + diff --git a/document/tutorial/project/light-meter/04_mocked_backend.md b/document/tutorial/project/light-meter/04_mocked_backend.md new file mode 100644 index 000000000..c55b10e1b --- /dev/null +++ b/document/tutorial/project/light-meter/04_mocked_backend.md @@ -0,0 +1,246 @@ +--- +title: MockedSensor 与 custom-deleter pImpl +--- + +# MockedSensor:把物理世界假装出来 + +::: info 本节你将学到 +- 一个 Mock 怎么"假装"出可信的传感器数据:正弦 lux 模拟昼夜起伏,二值 ps 模拟手靠近 +- C++ 的 ``:为什么用 `std::mt19937` 而不是 C 的 `rand()`,随机引擎怎么复用 +- light-meter 用到的一套可迁移 C++ 模式,用 `unique_ptr` 配自定义 deleter 管理一个不完整类型,也就是轻量版 pImpl,它解决了什么、为什么非这么写不可 +- 逐行读懂 `mockedsensor.h` 和 `mockedsensor.cpp` +::: + +::: tip 前置知识 +- 第 03 章的 Sensor 抽象契约,这一章实现的就是它的第一个派生类 +- 知道 `std::unique_ptr` 是个独占所有权的智能指针就行,自定义 deleter 我们从零讲 +::: + +## 这一章我们造一个假传感器 + +第 03 章我们把 `Sensor` 那份契约钉死了,但它还是个抽象类,没法直接 new 出来用。这一章要做的就是它的第一个派生类 `MockedSensor`,一个在桌面上"假装有硬件"的后端。它不碰 `/dev/ap3216c`,也不依赖任何外设,Windows 和 Linux 都能跑,数据全是它自己算出来的。 + +说实话,这里有个诱惑很容易把人带歪。既然是假的,为什么不直接返回个固定值,比如永远 `lux=300, ps=0`?能,但那样 UI 调起来没意思,折线是一条直线,告警和唤醒这些状态永远触发不了,你等于没法在桌面验证逻辑。一个好的 Mock 得"像"真硬件,数据得有合理的起伏,这样 UI 才能真的跑起来、状态机才能真的流转。所以 `MockedSensor` 花了点心思,让 lux 按正弦曲线模拟一天的昼夜起伏,让 ps 在"手靠近"和"手离开"两个状态下分别给出合理的量级。我们先看它的数据物理,再看支撑这套数据的两个 C++ 技巧。 + +## 先看数据物理:lux 怎么假装,ps 怎么假装 + +照度 lux 这一侧,真实世界里桌面照度一天是会起伏的,白天亮、晚上暗。`MockedSensor` 拿一条正弦曲线来模拟这个起伏,公式是 `400 + 350*sin(phase)`,中心在 400 lux、振幅 350,于是 lux 就在 50 到 750 之间来回摆。再叠一个 -30 到 +30 的随机抖动,模拟环境光的随机波动。最后用 `std::clamp` 把结果钳在 50 到 800 的区间里,防止抖动把它推出 UI 的 Y 轴范围。`phase` 这个相位由 UI 那边的采样定时器每个 tick 推进一小步(`set_phase`),lux 就跟着正弦曲线慢慢起伏,周期大概是 25 秒,这是个适合调试的节奏,不用真等一天。 + +接近度 ps 这一侧,真实硬件里手越靠近传感器 ps 越大。但桌面调试时没人伸手,light-meter 的办法是让 `MockedSensor` 暴露一个 `set_held` 注入口,UI 上按住空格就调 `set_held(true)` 表示"手靠近",松开调 `set_held(false)`。"手靠近"时 ps 给 800 到 840 这个量级,"手离开"时给 0 到 40。为什么挑这两个量级?因为 UI 里有个阈值 `kPsWakeThreshold=500`,`ps > 500` 就认为有接近,所以"靠近"得明显高于 500,"离开"得明显低于 500,这两个状态才能可靠区分开。 + +这套数据物理看起来平平无奇,但它是一个"能用的 Mock"能不能成立的命门:数据得落在合理的物理量级上,UI 状态机的所有分支都得能被它打到。Mock 要像真硬件,这是个该养成习惯的判断。 + +## C++ 基础:`` 比 `rand()` 强在哪 + +抖动那一项需要一个随机数。C 程序员本能会想 `rand() % 61 - 30`,但 C++ 有更好的工具,light-meter 用的是 `` 这套。先把这套讲清楚,因为它和 C 的 `rand()` 不在一个档次。 + +C 的 `rand()` 有几个老问题。它的随机性差,在很多平台上低位明显有规律,`rand() % N` 这种取模写法还会放大这个毛病,分布不均匀。它的状态是全局的,多线程下你得自己加锁。它的种子是 `srand(time(nullptr))`,分辨率到秒,同一秒启动的两个程序拿到一样的序列,debug 起来想骂人。 + +C++11 的 `` 把这件事重做了,拆成两个独立的概念。一个是**随机引擎**,负责生成原始的随机比特,`std::mt19937` 是最常用的一个,基于梅森旋转算法,周期长、分布好。另一个是**分布**,负责把引擎的原始比特映射成你要的分布,`std::uniform_int_distribution(min, max)` 给你指定区间里的均匀整数。两者组合用,先拿一个引擎、拿一个分布,然后 `dist(engine)` 就出一个你想要的随机数。 + +light-meter 把这套封装进一个 `Random` 小类: + +```cpp +class Random { +public: + Random() : m_rng(std::random_device{}()) {} + int int_range(int min, int max) { + std::uniform_int_distribution dist(min, max); + return dist(m_rng); + } +private: + std::mt19937 m_rng; +}; +``` + +这里有几个点值得停一下。`std::random_device{}()` 是用系统的真随机源(比如 Linux 的 `/dev/urandom`)给 `mt19937` 提供种子,比 `time(nullptr)` 靠谱得多。`m_rng` 作为成员一直留着,每次 `int_range` 复用同一个引擎,这很重要,因为引擎的初始化不便宜,而且一个引擎跑起来后状态才是"热"的,你每次要随机数都新建一个引擎既慢又没意义。`uniform_int_distribution` 倒是每次调用现建,它是个轻量的无状态映射,这没问题。引擎留着复用、分布现建现用,记住这个分工,这就是 `` 的标准用法。 + +## 重头戏:unique_ptr 配自定义 deleter 管不完整类型 + +接下来这一段是本章的硬核,也是 light-meter 里我个人觉得最值钱的一个 C++ 模式。你看 `mockedsensor.h` 会发现一件怪事,它在头文件里前向声明了 `LuxSource` 和 `PsSource` 两个结构体,却不给出完整定义,完整定义全藏在 `.cpp` 里。然后它用一种看起来有点怪的 `unique_ptr` 持有它们,带了一个自定义的 deleter。我们在搞清楚这到底在干什么、为什么要这么干。 + +先说一个直接的问题。假设你不懂这套,想当然地在头文件里写: + +```cpp +// mockedsensor.h(错误示范) +class MockedSensor : public Sensor { + std::unique_ptr lux_source; // LuxSource 在头文件里只前向声明,不完整 + // ... +}; +``` + +这段在很多编译器上会编译失败,或者至少在你析构 `MockedSensor` 的地方失败。原因是 `std::unique_ptr` 的默认 deleter 在销毁对象时要 `delete` 那个指针,而 `delete` 一个指向不完整类型的指针是未定义行为,编译器需要在 `delete` 的位置看到 `LuxSource` 的完整定义。问题是 `MockedSensor` 的析构函数(编译器默认生成的)就在头文件里,而头文件里 `LuxSource` 只有前向声明、不完整,于是析构函数没法正确生成 `delete`。 + +"头文件里不想暴露类的完整定义"这个需求其实很常见。你想把实现细节藏在 `.cpp` 里,头文件只暴露一个最小的接口,这样拿到头文件的人不用看见 `LuxSource` 内部长什么样,编译依赖也小(改 `LuxSource` 的成员不用重编所有 include 了这个头文件的地方)。这个套路有个名字,pImpl,pointer to implementation,指针指向实现。 + +要让 `unique_ptr` 持有一个不完整类型,有两条路。第一条,在头文件里声明析构函数,在 `.cpp` 里定义它,这样析构的位置(`.cpp` 里)`LuxSource` 已经完整了,默认 deleter 就能正常工作。第二条是 light-meter 用的,给 `unique_ptr` 配一个自定义 deleter,这个 deleter 的实现也放在 `.cpp` 里,同样把 `delete` 推迟到类型完整的地方。两条路都成立,light-meter 选了第二条,我们就跟着它讲。 + +它的写法是这样,头文件: + +```cpp +// mockedsensor.h +struct LuxSource; // 只前向声明,不完整 + +struct LuxSourceDeleter { + void operator()(LuxSource* p) const; // 只声明,实现放 .cpp +}; + +class MockedSensor : public Sensor { + std::unique_ptr lux_source; // 带自定义 deleter + // ... +}; +``` + +`.cpp` 里: + +```cpp +// mockedsensor.cpp +struct LuxSource { + // 完整定义,只在 .cpp 里可见 + double phase {0.0}; + std::unique_ptr random_source; + double fetch_lux() const { /* ... */ } +}; + +void LuxSourceDeleter::operator()(LuxSource* p) const { + delete p; // 这里 LuxSource 已完整,delete 安全 +} +``` + +关键在 `LuxSourceDeleter::operator()`,它是在 `.cpp` 里定义的,而 `.cpp` 里 `LuxSource` 的完整定义就在上面,所以这里的 `delete p` 是安全的。`unique_ptr` 在析构时调用的就是这个 deleter,而不是默认的 `delete`,于是"销毁时类型要完整"这个要求被推迟到了 `.cpp` 里满足,头文件那侧只靠前向声明就能过编译。 + +这套写法的好处,头文件 `mockedsensor.h` 里 grep 不到 `LuxSource` 的任何成员细节,它对一个 include 它的文件来说就是一个不透明的名字,改 `LuxSource` 的实现只重编 `mockedsensor.cpp`,不动其他地方。这就是轻量版 pImpl,用 `unique_ptr` + 自定义 deleter 实现。以后写库、写需要隐藏实现的类,这招直接抄走。 + +## 逐行读 mockedsensor.h 和 mockedsensor.cpp + +原理讲透了,来读真东西。`mockedsensor.h` 的核心是这几行: + +```cpp +struct LuxSource; +struct PsSource; +struct LuxSourceDeleter { + void operator()(LuxSource* p) const; +}; +struct PsSourceDeleter { + void operator()(PsSource *p) const; +}; + +class MockedSensor : public Sensor { +public: + MockedSensor(); + std::expected init(bool force_reinit) override; + std::expected query_once() override; + void set_held(bool is_held) override; + void set_phase(double phase) override; +private: + std::unique_ptr lux_source; + std::unique_ptr ps_source; +}; +``` + +它 `public Sensor` 继承契约类,override 了四个虚函数,两个纯虚的 `init`/`query_once` 必须实现,两个测试注入口 `set_phase`/`set_held` 它也 override 了(因为 Mock 真要用它们推进假数据)。两个成员是带自定义 deleter 的 `unique_ptr`,上面刚讲过。 + +`.cpp` 里先给出 `LuxSource` 和 `PsSource` 的完整定义: + +```cpp +struct LuxSource { + LuxSource() : random_source(std::make_unique()){} + void setPhase(double phase_) { phase = phase_; } + double fetch_lux() const { + return std::clamp(400 + 350*std::sin(phase) + + random_source->int_range(-30, 30), 50.0, 800.0); + } +private: + double phase {0.0}; + std::unique_ptr random_source; +}; +``` + +这就是前面那套数据物理的代码形态。`fetch_lux` 里 `400 + 350*sin(phase)` 是正弦基线,`random_source->int_range(-30, 30)` 是抖动,`std::clamp(..., 50.0, 800.0)` 是钳位。`std::clamp` 是 C++17 加进 `` 的,三参数版本 `clamp(v, lo, hi)`,比手写 `std::max(lo, std::min(v, hi))` 清楚得多。`PsSource` 同理,held 给 800 到 840,不 held 给 0 到 40。 + +接着是两个 deleter 的定义,就是上一段贴的那两行 `delete p`。然后是 `MockedSensor` 的实现: + +```cpp +std::expected MockedSensor::init(bool force_reinit) { + if(!lux_source || force_reinit) { + lux_source = std::unique_ptr(new LuxSource); + } + if(!ps_source || force_reinit) { + ps_source = std::unique_ptr(new PsSource); + } + return {}; +} +``` + +`init` 干的事就是按需 new 出两个数据源。注意它怎么用 `unique_ptr(new LuxSource)` 这种带 deleter 的构造,这里 `new LuxSource` 是安全的,因为是在 `.cpp` 里,`LuxSource` 完整。`if(!lux_source || force_reinit)` 表示只有"还没建过"或者"强制重建"时才 new,避免重复。成功返回空的 `expected`。 + +```cpp +std::expected MockedSensor::query_once() { + if(!lux_source || !ps_source){ + return std::unexpected {QueryError::NotInited}; + } + return { SensorData { .luxury = lux_source->fetch_lux(), .ps = ps_source->fetch_ps() } }; +} +``` + +`query_once` 先检查两个数据源是否就绪,没就绪就返回 `NotInited` 错误,这是第 02 章讲的 `std::unexpected` 的用法。就绪了就把两路 fetch 的结果填进 `SensorData` 返回。`set_phase` 和 `set_held` 就是把 UI 传进来的相位和"靠近"状态转发给两个数据源,简单一行。 + +`MockedSensor` 到这里就全读完了。它不长,但每一行都有讲究:正弦数据物理、`` 复用、自定义 deleter 的轻量 pImpl,这套东西凑在一起,撑起了一个能在桌面上假模假样跑起来的传感器。 + +## 上手:把两条数据通路分别验证 + +光读没手感,我们把两条数据通路单独验证一下,确认 lux 和 ps 是独立可注入的。还是在第 03 章那个 scratch 目录里干,你已经有 `sensor.h` 了,现在再把 `mockedsensor.h` 和 `mockedsensor.cpp` 拷过来(或者直接在 `examples/light-meter/sensor/mocked/` 里干活),写一个小 main: + +```cpp +#include "mockedsensor.h" +#include + +int main() { + MockedSensor s; + s.init(false); + + // 推进相位,看 lux 起伏 + for (double phase = 0.0; phase < 6.28; phase += 0.5) { + s.set_phase(phase); + auto d = s.query_once(); + if (d) std::cout << "phase=" << phase << " luxury=" << d.value().luxury << '\n'; + } + + // 看 ps 在 held/不 held 两个状态的切换 + s.set_held(false); + std::cout << "松手 ps=" << s.query_once().value().ps << '\n'; + s.set_held(true); + std::cout << "按住 ps=" << s.query_once().value().ps << '\n'; +} +``` + +编译跑一下(记得带上 `mockedsensor.cpp` 一起编,因为 `LuxSource` 的完整定义在它里面): + +```bash +g++ -std=c++23 test.cpp mockedsensor.cpp sensor.cpp -o test && ./test +``` + +正常的话,你应该看到 lux 跟着 phase 从低到高再到低地起伏(正弦),ps 在"松手"时是个位数、在"按住"时跳到 800 多。两组数对得上,就说明这两条数据通路确实各走各的,都能被外部注入控制。 + +## 这一章的坑 + +第一个坑,自定义 deleter 写在了头文件里、而且那里类型还不完整。这样 `delete p` 的位置看不到完整类型,等于又绕回了最初的问题。deleter 的实现必须在 `.cpp` 里,跟类型的完整定义放一起。 + +第二个坑,每次要随机数都新建一个 `mt19937`。这既慢,又让序列失去意义(每次都从同一个种子重新跑,如果种子还是固定的,你每次拿到一样的"随机"数)。引擎作为成员留着复用,这是 `` 的基本规矩。 + +第三个坑,把"手靠近"和"手离开"的 ps 量级设得太接近阈值。比如你 held 给 520、不 held 给 480,中间就差 40,而抖动可能有 ±40,于是状态会在阈值附近来回抖,UI 跟着反复唤醒息屏。light-meter 给的是 800+ 对 0-40,两边离 500 这个阈值都远远的,留足裕量。做任何带阈值的系统,裕量这个意识都得绷着。 + +第四个坑,`std::clamp` 第一个参数得是能转成那两个边界的类型,`400 + 350*std::sin(phase) + int_range(-30,30)` 这里 `int_range` 返回 int、`sin` 返回 double,混在一起算会隐式转,记得 `int_range` 的返回参与的是浮点运算,不会有截断问题,但你要是写反了把一个 int 表达式整体 clamp 进 double 区间,类型不匹配会报错。light-meter 这里的写法是对的,你照着抄不会错。 + +## 小结 + +`MockedSensor` 这个"假传感器"的核心就两件事:让数据落在合理的物理量级上,让 UI 的状态机所有分支都能被它打到。围绕这两件事,顺手把 `` 的引擎复用、分布现建这套标准用法捡了起来,再把 `unique_ptr` 配自定义 deleter 管不完整类型的轻量 pImpl 走了一遍。后面那个模式,我个人觉得是 light-meter 里最值得单独拎出来记一笔的东西。 + +到这里,light-meter 的桌面数据源就齐了,`Sensor` 契约有了第一个能跑的实现。下一章是这套教程体量最大的一章,我们把 `MockedSensor` 接到 Qt 的 UI 上,造出 light-meter 的三态界面,运行、告警、息屏,顺便把 Qt 的信号槽、事件循环、QTimer 这些从零讲一遍。 + +## 继续学习 + + + ← 03 Sensor 抽象契约 + 05 三态 UI + 状态机 + 定时器编排 → + diff --git a/document/tutorial/project/light-meter/05_three_state_ui.md b/document/tutorial/project/light-meter/05_three_state_ui.md new file mode 100644 index 000000000..efcb97f8f --- /dev/null +++ b/document/tutorial/project/light-meter/05_three_state_ui.md @@ -0,0 +1,308 @@ +--- +title: 三态 UI + 状态机 + 定时器编排 +--- + +# 三态 UI:运行、告警、息屏怎么调度 + +::: info 本节你将学到 +- Qt 程序和顺序 C 程序的根本区别在哪,事件循环是个什么东西 +- QObject 这套父子所有权,Qt 怎么管内存,你为什么基本不用手 delete +- 信号和槽是怎么回事,`Q_OBJECT` 和 moc 在背后替你干了什么 +- QTimer 的周期、单次、动画三种用法,light-meter 的三个定时器怎么分工 +- 一个三态 UI 怎么用状态机的思路写清楚,而不是写成一锅 if +- 顺手把 CSV 导出和常驻应用的 OOM 防线讲了 +::: + +::: tip 前置知识 +- 第 04 章的 MockedSensor,数据源已经就绪 +- 第 01 章你已经弹出过一个 Qt 窗口,但没讲过它为什么能弹出来,这一章从事件循环讲起 +::: + +## 先打个预防针,这章最长 + +先把话说在前面,这一章是整套教程里最长的一章。原因没什么神秘的,Qt 的几样基础设施我们还没碰过,得从零讲一遍。到上一章为止,我们手里的零件只有 Sensor 契约和 MockedSensor,UI 这一摊是一张白纸。这次我们要把 MockedSensor 接到一个 Qt 窗口上,造出 light-meter 的三态界面:运行态正常采样,lux 跌破阈值进告警态,无接近 10 秒进息屏态。 + +要让这三态有条不紊地流转,得先搞清楚一件事:Qt 程序到底是怎么跑起来的,它和你写过的顺序 C 程序差在哪。 + +## Qt 程序和顺序 C 程序的根本区别:事件循环 + +你写过的 C 程序,大概都是从 main 进来,一行一行往下执行,执行完就退了。GUI 程序不能这么跑。一个窗口弹出来,它得一直待在那儿,等你点按钮、等你按键、等系统通知它"该重绘了"。你来一个事件它处理一个,没事件就等着,直到你关掉窗口程序才退。这种"反复取事件、处理事件"的循环就叫事件循环,Qt 里就是 `QApplication::exec()`。 + +回头看第 01 章那个 hello-qt 的 main: + +```cpp +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + QWidget window; + window.show(); + return app.exec(); // ← 就是这一行,进入事件循环 +} +``` + +`app.exec()` 就是事件循环本身,它不会马上返回,而是反复从 Qt 的事件队列里取事件、分发给对应的对象处理。点鼠标产生一个鼠标事件,分发给鼠标落点的那个控件;按键盘产生键盘事件,分发给有焦点的控件;窗口被遮挡再露出来,产生一个绘制事件,通知该控件重绘自己。直到你关掉窗口,`exec()` 才返回,main 才走到 return。 + +这件事会反过来改变你写代码的思路。顺序程序里你是"主动去拿"数据,比如 `scanf` 等用户输入。GUI 程序里你反过来,是被事件驱动的:你的代码是被事件调用的,你要做的事情变成"事件来了我该怎么响应"。light-meter 的采样就是被一个定时器事件驱动的,定时器到点了通知 UI 去 query 一次数据。这就是上一章说的"拉模型"和事件循环咬合的地方。 + +## QObject:Qt 的地基和它的内存管理 + +Qt 里绝大多数类都继承自 QObject,`QWidget`、`QTimer`、`QMainWindow` 都是。QObject 给它的子类提供了一整套能力,信号槽、事件处理、还有一套非常省心的内存管理,父子所有权。 + +Qt 的内存管理是这样。每个 QObject 都可以有一个 parent,你 new 一个 QObject 的时候把它的 parent 传进去,这个对象就挂到了 parent 的子列表里。当 parent 被销毁时,它会自动销毁自己所有的 children,children 再销毁各自的 children,整棵树递归销毁。所以你在 Qt 里写 `new QLabel("hi", parentWidget)`,基本不用操心这个 QLabel 什么时候 delete,parent 销毁时它跟着走。 + +light-meter 里 `MainWindow` 是个 QWidget,它的构造函数里 new 了一堆 QTimer、QPushButton、QLabel,都把 `this` 当 parent 传进去: + +```cpp +m_sampleTimer = new QTimer(this); // parent 是 MainWindow +m_idleTimer = new QTimer(this); +m_pauseBtn = new QPushButton(QStringLiteral("⏸ 暂停"), leftPanel); +``` + +所以这些控件和定时器,你都不用手动 delete,`MainWindow` 析构时它们全跟着销毁。这是 Qt 比"裸 new/delete"省心的地方。说穿了就是一条肌肉记忆:在 Qt 里 new 一个 QObject,第一反应永远是问一句"它的 parent 是谁"。 + +::: details 不传 parent 会怎样 +你不传 parent,这个对象就成了没有归属的"孤儿",没人替它 delete,你就得自己管理它的生命周期,自己 new 自己 delete,跟普通 C++ 对象一样。light-meter 几乎所有 QObject 都挂了 parent,唯一例外是那些被智能指针管理的非 QObject 对象(比如 `unique_ptr`),因为 Sensor 不是 QObject,挂不进 Qt 的父子树。 +::: + +## 信号和槽:Qt 对象怎么对话 + +GUI 程序里到处都是"一件事发生了,通知另一件事去做反应"。用户点了按钮,通知业务逻辑去处理;定时器到点了,通知 UI 去刷新;数据变了,通知显示它的控件更新。Qt 给这套通知机制起了一对专门的名字,信号和槽。 + +信号是一个对象"发出"的通知,`emit mySignal();`,意思是"这件事发生了"。槽是一个对象里可以被调用来响应的成员函数。你用 `connect` 把一个对象的信号连到另一个对象的槽上,信号一发出,连上的槽就被调用。比如把按钮的 `clicked` 信号连到 `MainWindow` 的某个处理函数上,点按钮就触发处理。 + +light-meter 里这种连接到处都是: + +```cpp +connect(m_sampleTimer, &QTimer::timeout, this, &MainWindow::onSampleTick); +connect(m_pauseBtn, &QPushButton::toggled, this, &MainWindow::onTogglePause); +``` + +第一行的意思是,`m_sampleTimer` 每次发出 `timeout` 信号,就调 `this`(也就是 MainWindow)的 `onSampleTick` 槽。第二行是按钮的 `toggled`(按钮按下/弹起)信号连到 `onTogglePause`。这种用函数指针的 `connect` 写法是现代 Qt5 起推荐的,编译期能检查信号和槽的签名,比老的字符串写法 `SIGNAL(timeout())` 安全得多。 + +要让一个类能用信号槽,它得满足两个条件:得继承 QObject,类定义里还得写一个 `Q_OBJECT` 宏。`Q_OBJECT` 这个宏展开后是一堆元对象声明,Qt 有个叫 moc(Meta-Object Compiler)的工具会扫描带 `Q_OBJECT` 的类,替你生成一段额外的 C++ 代码来实现信号槽的元对象机制。你在第 01 章见过的 AUTOMOC 就是让 CMake 自动跑这个 moc。light-meter 里 `MainWindow` 有 `Q_OBJECT`,`ChartView` 也有(它用了信号槽),而 `BreathingOverlay` 没有(它纯 paintEvent 不需要信号槽),这也是看一个类有没有 `Q_OBJECT` 的实际判据。 + +## QTimer:周期、单次、动画三件套 + +`QTimer` 是 Qt 里做定时和周期性任务的工具,它的工作方式是,你设好间隔、连好 `timeout` 信号、`start`,之后它每隔那个间隔就发一次 `timeout`,你连的槽就被调一次。light-meter 里用了三个 QTimer,正好对应三种典型用法,我们一个个看。 + +第一个是采样定时器 `m_sampleTimer`,周期性,200 毫秒一次,用来驱动数据采样: + +```cpp +m_sampleTimer = new QTimer(this); +m_sampleTimer->setTimerType(Qt::PreciseTimer); +connect(m_sampleTimer, &QTimer::timeout, this, &MainWindow::onSampleTick); +m_sampleTimer->start(kSampleMs); // 200ms +``` + +`setTimerType(Qt::PreciseTimer)` 是要求高精度,默认的粗精度定时器为了省电会有一点抖动,但 light-meter 要 5Hz 等间隔采样,所以开 Precise。这是周期定时器的标准用法,`start(间隔)` 之后就会一直每间隔发一次 timeout。 + +第二个是息屏倒计时 `m_idleTimer`,**单次**。它的特点是"启动后只响一次",用来数"无接近 10 秒就息屏": + +```cpp +m_idleTimer = new QTimer(this); +m_idleTimer->setSingleShot(true); +connect(m_idleTimer, &QTimer::timeout, this, &MainWindow::enterScreenOff); +``` + +`setSingleShot(true)` 把它设成单次模式,`start(10000)` 之后 10 秒发一次 timeout 然后自己停。这跟周期定时器的区别很关键,周期的是"每隔",单次的是"延迟多久之后就一次"。 + +第三个是呼吸动画 `m_breathTimer`,又是周期性,但间隔很短(50ms),用来驱动息屏态那个慢呼吸点的动画。息屏时启动,唤醒时停掉。 + +这三个定时器合起来,就是 light-meter 在时间维度上的全部调度。Qt 里做定时任务,翻来覆去基本也就是这三种套路。 + +## 逐段读 mainwindow.cpp + +概念讲够了,来读真东西。`mainwindow.cpp` 是 light-meter 最长的一个文件,我们按它的几个职责分段读。 + +### UI 搭建:纯代码,不用 .ui 文件 + +`buildUi()` 这个函数负责把界面搭出来。light-meter 选择的是**纯代码**搭 UI,不用 Qt Designer 那个 `.ui` 文件。这不是唯一选择,`.ui` 文件适合快速拖拽出复杂表单,但对 light-meter 这种控件不多、布局简单、还要精细控制样式的摆件,纯代码反而更直接,代码即界面,改起来不用在编辑器和设计器之间切。这是个权衡,不是教条。 + +布局用 `QSplitter` 把窗口分成左右两栏,左栏是 lux 大数字、进度条、暂停按钮、导出按钮、阈值滑杆、息屏控制这些控件,右栏是那条折线图。Qt 的布局系统,`QVBoxLayout`、`QHBoxLayout` 是垂直、水平排列,你往里 `addWidget` 控件就自动排好,窗口缩放时布局自动调整。`QSplitter` 比 `QLayout` 多了"中间一根可拖动的分隔条"。每个控件 new 出来时把父容器当 parent 传进去,挂进父子树,再 `addWidget` 进布局。 + +样式用 Qt 的样式表 QSS,语法接近网页的 CSS。light-meter 用了一套 Catppuccin 深色调色板: + +```cpp +constexpr auto kCssWindow = + "QMainWindow, QSplitter { background:#1e1e2e; }" + "QSplitter::handle { background:#11111b; }"; +``` + +`setStyleSheet` 一设,匹配的控件就变了样子。这套深色配色让摆件在床头不刺眼,也是它"产品感"的一部分。 + +### 三个定时器怎么编排 + +构造函数的后半段把三个定时器建好、连好、启动: + +```cpp +m_sampleTimer = new QTimer(this); +m_sampleTimer->setTimerType(Qt::PreciseTimer); +connect(m_sampleTimer, &QTimer::timeout, this, &MainWindow::onSampleTick); +m_sampleTimer->start(kSampleMs); + +m_idleTimer = new QTimer(this); +m_idleTimer->setSingleShot(true); +connect(m_idleTimer, &QTimer::timeout, this, &MainWindow::enterScreenOff); + +m_breathTimer = new QTimer(this); +connect(m_breathTimer, &QTimer::timeout, this, &MainWindow::onBreathTick); +``` + +注意一个设计点,只有 `m_sampleTimer` 一启动就 `start`,`m_idleTimer` 和 `m_breathTimer` 都是按需启动的。`m_idleTimer` 要等"开启自动息屏且无接近"才启动数那 10 秒,`m_breathTimer` 要等进了息屏态才启动画呼吸点。这种"常驻定时器和按需定时器分开"的意识,对一个 7x24 跑的常驻应用是有意义的,没必要一开始就把所有定时器都开着。 + +### processSample:数据怎么流进 UI + +每 200ms,`onSampleTick` 被调用一次,它推进 Mock 的相位、`query_once()` 拉一次数据,然后把数据交给 `processSample`: + +```cpp +void MainWindow::onSampleTick() { + if (m_paused) return; + m_phase += kPhaseStep; + m_sensor->set_phase(m_phase); + auto res = m_sensor->query_once(); + if (!res) { + m_sensor->init(true); + res = m_sensor->query_once(); + if (!res) return; + } + processSample(res->luxury, res->ps); +} +``` + +这里有个细节值得学一下。`query_once` 返回 `std::expected`,失败时(比如还没初始化)这里不是直接放弃,而是先 `init(true)` 强制重新初始化,再 query 一次,二次还失败才 return。这是"失败自动重试一次"的容错写法,对一个常驻应用很合理,偶发的瞬态失败不该让整屏数据停掉。 + +`processSample` 把这一帧数据分发到各处,折线图追加一个点、历史记录存一份(给 CSV 用)、大数字更新、进度条更新、按 lux 判断要不要进告警态、按 ps 重置息屏倒计时: + +```cpp +void MainWindow::processSample(double lux, int ps) { + m_lastLux = lux; + m_chart->pushSample(float(lux)); + m_history.append({QDateTime::currentMSecsSinceEpoch(), float(lux)}); + if (m_history.size() > kMaxHistory) + m_history.remove(0, m_history.size() - kMaxHistory); + m_clock->setText(QTime::currentTime().toString("HH:mm")); + m_bigLux->setText(QString::number(qRound(lux)) + " lux"); + m_bar->setValue(qBound(0, int(lux / kLuxYMax * 100.0), 100)); + setAlarmMode(lux < m_threshold); + resetIdleCountdown(ps); +} +``` + +### 三态状态机:运行、告警、息屏 + +light-meter 的三个状态,运行、告警、息屏,代码里没有用 Qt 的 `QStateMachine`(那是另一套更重的状态机框架),而是手搓的几个布尔加一段转移逻辑。这没问题,状态少的时候手搓更清楚。关键是你要把它当状态机来想,而不是一堆散乱的 if。 + +告警态其实是运行态的一个"着色变体",不是独立状态,代码里就是 `setAlarmMode(bool)`,lux 跌破阈值时把左侧数字卡的样式从绿底翻成红底、状态文字从"运行"变"告警": + +```cpp +void MainWindow::setAlarmMode(bool alarm) { + if (alarm) { + m_numberCard->setStyleSheet("QFrame#numberCard { background:#c0392b; ... }"); + m_subText->setText("光线不足,建议开灯"); + m_statusLine->setText("● 告警"); + } else { + m_numberCard->setStyleSheet("QFrame#numberCard { background:#181825; ... }"); + m_subText->setText("明亮 ✓"); + m_statusLine->setText("● 运行"); + } +} +``` + +息屏态是真正的独立状态,由 `m_screenOff` 这个布尔标记,进了息屏态就盖一个全屏黑遮罩、启动呼吸动画,出来就撤掉。驱动息屏状态转移的核心是 `resetIdleCountdown`: + +```cpp +void MainWindow::resetIdleCountdown(int ps) { + const bool near = ps > kPsWakeThreshold; + if (near) { + if (m_screenOff) exitScreenOff(); + if (m_idleTimer->isActive()) m_idleTimer->stop(); + } else { + if (m_autoSleep && !m_idleTimer->isActive()) + m_idleTimer->start(kScreenOffMs); + } +} +``` + +这一段是整个状态机的精髓,有一个反直觉的点一定要讲。看那个 `if (m_autoSleep && !m_idleTimer->isActive())`,意思是"开启了自动息屏、而且息屏倒计时当前没在跑,才启动它"。为什么要有 `!isActive()` 这个判断?因为 `resetIdleCountdown` 是每个采样 tick(200ms)都调一次的,如果手离开之后每个 tick 都无条件 `m_idleTimer->start(10000)`,那这个 10 秒倒计时就每个 tick 都被重置回 10 秒,永远到不了 0,息屏永远触发不了。加了 `!isActive()` 判断,只有第一次"手离开"时启动倒计时,之后只要它还在跑就不重启,这样 10 秒才能真的数到、触发息屏。 + +这是一个典型的"定时器被反复重启导致永远到不了"的坑,我自己第一次写空闲超时就是栽在这儿,盯着看半天不知道为什么死活不触发。所以但凡你看到"空闲 N 秒之后做某事"的逻辑,先确认一下:触发定时器的那段代码,会不会在等待期间被反复调用、反复 start。 + +反过来,手靠近时(`near` 分支),如果在息屏就 `exitScreenOff` 唤醒,如果在数息屏倒计时就 `stop` 取消它,因为人来了就不该再息屏。 + +### 焦点策略:让空格专属于"手靠近" + +light-meter 用空格键模拟"手靠近传感器",按下空格就是手靠近、松开就是手离开。这件事在 `keyPressEvent`/`keyReleaseEvent` 里处理。但这里有个 Qt 焦点的坑,如果不管,空格根本到不了 `MainWindow` 的 keyPressEvent。 + +原因是,Qt 里键盘事件默认送给"有焦点的控件",而按钮、滑杆、复选框这些控件拿到焦点后,空格键会被它们自己吃掉用(按钮把空格当"点击"触发)。所以如果你点了"暂停"按钮,焦点跑到按钮上,之后按空格触发的是按钮点击,不是你的"手靠近"。 + +light-meter 的解法是,把所有不需要接收键盘的控件的焦点策略设成 `Qt::NoFocus`: + +```cpp +m_pauseBtn->setFocusPolicy(Qt::NoFocus); +m_thresholdSlider->setFocusPolicy(Qt::NoFocus); +m_exportBtn->setFocusPolicy(Qt::NoFocus); +// ... 所有按钮、滑杆、复选框都设 +``` + +设成 NoFocus 的控件不接收焦点,键盘事件就不会被它们吞掉,空格就能稳定地被 `MainWindow` 接到。这是一个 Qt 焦点路由的经典坑,light-meter 的注释也特意写了"不抢焦点:空格留给手靠近"。 + +### CSV 导出和 OOM 防线 + +最后两块小功能。CSV 导出是把这一段会话的历史数据存成文件: + +```cpp +QTextStream s(&f); +s << "timestamp,lux\n"; +for (const auto &row : m_history) { + const QString ts = QDateTime::fromMSecsSinceEpoch(row.first).toString(Qt::ISODateWithMs); + s << ts << ',' << QString::number(row.second, 'f', 1) << '\n'; +} +``` + +`QFileDialog::getSaveFileName` 弹个保存对话框让用户选位置,`QTextStream` 把 `m_history` 里的时间戳和 lux 一行行写出去。`QStandardPaths::DocumentsLocation` 拿到系统的"我的文档"目录作为默认保存位置,这是跨平台拿用户目录的正确姿势,别硬写 `/home/user`。 + +OOM 防线是 `m_history` 那个 `if (m_history.size() > kMaxHistory) m_history.remove(0, m_history.size() - kMaxHistory)`。一个常驻摆件 7x24 跑,如果 `m_history` 无限 append,内存迟早撑爆,所以给它设了个上限 `kMaxHistory=18000`,超了就把最老的丢掉。这里要诚实说一个事,这个 `QVector::remove(0, n)` 是从头删 n 个元素,是个 O(n) 的搬移操作,**不是真正的环形缓冲**。真正的环形缓冲在下一章的 `ChartView` 里(`m_head`/`m_count` 那套),这里 `m_history` 只是为了 CSV 存全量、用截断的方式限内存,18000 条对 O(n) 搬移来说也不频繁(只在超上限时触发),所以这个取舍是合理的。但你别把它当环形缓冲抄到高频场景。 + +## 上手:三态全验 + +把第 04 章的 MockedSensor 接进 light-meter(或者直接编 `examples/light-meter` 的 Mock 配置,默认就是 Mock),跑起来。然后按这三步把三个状态都触发一遍。 + +三态长这样,对照着验。 + +启动进来就是运行态,折线每 200ms 滚动,lux 按 ~25 秒周期起伏,左侧大数字跟着变。 + +![运行态:绿色折线起伏,左侧 lux 大数字](/light-meter/mock-light.png) + +把阈值滑杆拖到 700,等 lux 跌到 700 以下(或者拖高点让它跌破),左侧数字卡立刻翻红、状态点变"告警"、副提示变"光线不足,建议开灯"。这是告警态。拖回低值,lux 升回阈值以上,恢复绿色。 + +![告警态:左侧数字卡翻红、状态点变告警](/light-meter/mock-dark.png) + +勾上"自动息屏"复选框,松开空格大概 10 秒,全屏变黑、中央出现一个慢慢呼吸的点,这就是息屏态。按住空格瞬间唤醒回运行态,息屏时在屏幕上点一下鼠标也能唤醒。 + +![息屏态:全屏黑加中央慢呼吸点](/light-meter/mock-sleep.png) + +三个状态都触发一遍,Mock 数据源就被你接成了一个真会自己流转状态的桌面应用。这一步如果三态切换都顺,说明事件循环、定时器、状态机这条链路是通的,后面就可以放心画图了。 + +## 这一章的坑 + +第一个坑,类的 `Q_OBJECT` 宏忘了写,或者写了但 AUTOMOC 没开。症状是一堆 `undefined reference to vtable` 或者信号槽连不上。`Q_OBJECT` 是信号槽的前提,AUTOMOC 是自动跑 moc 的开关,第 01 章那份 CMakeLists 里 `qt_standard_project_setup()` 已经默认开了 AUTOMOC,所以你跟着走不会踩,但以后脱离这套模板自己建项目要知道。 + +第二个坑,跨线程的信号槽连接类型搞错。Qt 的信号槽默认是 `AutoConnection`,发送方和接收方在同一线程就直接调用,跨线程就排队。如果你手动指定了 `DirectConnection` 又用在跨线程场景,槽会在发送方线程执行,可能数据竞争。light-meter 全在主线程,没这个问题,但哪天你写多线程 Qt,这块的连接类型得心里有数。 + +第三个坑就是上面讲的,定时器被反复 `start` 导致倒计时永远到不了。任何"空闲 N 秒触发"的逻辑,都要判一下定时器是不是已经在跑,在跑就别重启。 + +第四个坑,QTimer 的精度。默认的 `CoarseTimer` 为了省电会允许几毫秒到十几毫秒的提前或延后,对 UI 动画无感,但如果你像 light-meter 采样这样要等间隔,得显式 `setTimerType(Qt::PreciseTimer)`。不过 PreciseTimer 在 Linux 下也只有毫秒级,要更准得上别的机制,这里够用。 + +第五个坑,样式表的 selector 写错导致不生效。QSS 的 selector 是按控件类名和 objectName 匹配的,`QFrame#numberCard` 表示"objectName 是 numberCard 的 QFrame"。你 `setObjectName` 设错了名,或者忘设,样式就匹配不上,控件保持默认外观,排查起来很费劲。 + +## 小结 + +说实话,这章信息量是真大,一气读完肯定记不住。但核心就一条主线:Qt 程序是被事件循环驱动的,你写的一切都是在回应事件。顺着这条线往下,事件循环催生了信号槽这套对话机制,信号槽又最常被 QTimer 这种周期事件触发,QObject 的父子所有权则让你不用操心这一堆对象谁先死谁后死。把这几样拼起来,light-meter 那个运行/告警/息屏的三态流转,手搓几个布尔加一段转移逻辑就写清楚了,顺手还把焦点路由、CSV 导出、内存上限这些常驻应用该有的细节都铺了一遍。 + +到这里 light-meter 的桌面 Mock 阶段基本齐了,就差那条折线图和息屏遮罩还没细讲。下一章我们把这两个自绘控件拆开,顺带说清楚为什么 i.MX6ULL 这种没有 GPU 的板子上,light-meter 宁可自己用 QPainter 画折线,也不用 Qt Charts。 + +## 继续学习 + + + ← 04 MockedSensor + 06 自绘 ChartView + 息屏遮罩 → + diff --git a/document/tutorial/project/light-meter/06_self_painted_chart.md b/document/tutorial/project/light-meter/06_self_painted_chart.md new file mode 100644 index 000000000..fd04852b4 --- /dev/null +++ b/document/tutorial/project/light-meter/06_self_painted_chart.md @@ -0,0 +1,195 @@ +--- +title: 自绘 ChartView + 息屏遮罩 +--- + +# 自绘 ChartView:为什么这条折线要自己画 + +::: info 本节你将学到 +- 为什么 light-meter 不用 Qt Charts,宁可自己用 QPainter 画一条折线,以及"库就在 rootfs 里,但不一定非得用"这种判断 +- QPainter 自绘的基本套路:`paintEvent` 在哪触发、`QPainter` 怎么用、屏幕坐标系为什么 Y 轴朝下 +- 一个**真正的**环形缓冲怎么写,`m_head`/`m_count` 那套,和上一章 `m_history` 那个 O(n) 截断的区别 +- `update()` 的真相:它刷新的是整个控件,不是某个矩形,以及为什么 light-meter 这样写也不卡 +- 息屏遮罩 `BreathingOverlay`:纯 `paintEvent` 怎么写,以及那个 `M_PI` 的跨平台坑 +::: + +::: tip 前置知识 +- 第 05 章,Qt 的事件循环、QWidget、信号槽,这一章的 ChartView 是个 QWidget 子类 +::: + +## 为什么这条折线要自己画 + +你大概知道 Qt 有个 Qt Charts 模块,专门画各种图表,折线、饼图、柱状,开箱即用。而且我们的 rootfs 里其实编了它(第 10 章上板时你会看到 buildroot 的 qt6 fragment 开了 `QT6CHARTS`)。那 light-meter 为什么不用它,反而自己用 `QPainter` 画一条折线,多写一百多行代码。 + +这事得从 i.MX6ULL 没有 GPU 说起,但不是说 Qt Charts 在没 GPU 的板子上完全跑不了。Qt Charts 的 widgets 版本理论上是能用软件光栅渲染跑起来的,它不强制要 OpenGL。真正因为没 GPU 而被否决的是 EGLFS 和 Qt Quick 那套走 OpenGL 的渲染路径,那是 buildroot/11 那篇讲的事,Qt Charts 不在这个硬否决的范围里。 + +所以 light-meter 还是自己画,是个主动的工程取舍,不是被逼的。原因说穿了也朴素。这个折线的需求其实就那么大点,一个 150 点的滚动窗口、一条线、一个阈值虚线、一个当前点,Qt Charts 那套带坐标轴、图例、动画、主题的完整框架对它来说是杀鸡用牛刀,引入它你就得跟一整套它定义的对象模型打交道。更现实的是样式控制,Catppuccin 配色、lux 跌破阈值时折线和当前点翻红、当前点的呼吸感,这些细节自己画一目了然,改 Qt Charts 反而别扭。再加上自绘只依赖 `QPainter`,走的是软件光栅引擎,直接 blit 到 framebuffer,在 no-GPU 的 linuxfb 上稳得一批,没有"某个平台插件加载失败"那种玄学隐患。顺带,应用层不 link Qt Charts,二进制更小、启动也更快。 + +把上面这段念叨念叨,其实就一句话:库存在不等于你得用。一个简单需求,自己写一百行可控、轻量、稳;引入一个大库省下那一百行,代价是跟它的整套抽象绑死,而且往往为了用它还得按它的方式组织代码。哪条划算,看需求复杂度,light-meter 这种简单折线,自己画划算。 + +## QPainter 自绘的基本套路 + +Qt 里所有自绘都遵循一个套路,控件收到绘制事件时,`paintEvent` 被调,你在里面创建一个 `QPainter`、用它的 API 画,函数返回画面就上屏。`QPainter` 是那支"画笔",它有 `drawLine`、`drawRect`、`drawEllipse`、`drawText`、`fillRect` 这些方法,你设好画笔 `QPen`(描边颜色、线宽、线型)和画刷 `QBrush`(填充颜色),它就按你说的画。 + +有一个坐标系的事必须先讲,不然你画出来的东西是反的。QWidget 的坐标系,原点在**左上角**,X 轴向右增长,Y 轴**向下**增长。这跟数学里 Y 轴向上相反。所以画折线图时,lux 越大你希望点越靠上,但屏幕上"靠上"是 Y 值小,这就需要一个映射把 lux 翻一下。`ChartView` 里 `mappedY` 就是干这个的,等会儿逐行读。 + +一个最小的 paintEvent 长这样,画一条对角线: + +```cpp +void MyWidget::paintEvent(QPaintEvent*) { + QPainter p(this); // 在这个控件上作画 + p.setPen(QPen(Qt::green, 2)); // 绿色、2 像素宽的画笔 + p.drawLine(0, 0, width(), height()); +} +``` + +`QPainter p(this)` 这一句把 painter 绑定到当前控件,它的绘制坐标都是相对于这个控件的左上角。至于什么时候 `paintEvent` 被调,无外乎两种。一种是 Qt 觉得这个控件需要重绘了,比如窗口刚从被遮挡中露出来。另一种是你主动调了 `update()`,这是告诉 Qt"我这个控件内容变了,麻烦安排一次重绘"。`update()` 不是立刻重绘,它是把一个重绘请求排进事件队列,Qt 会在下一轮事件循环里合并多个 `update()` 一起重绘,这样你一帧里多次改数据只触发一次 paint。 + +## 真环形缓冲:m_head 加 m_count + +上一章我们说 `m_history` 那个 `QVector` 用 `remove` 截断来限内存,是 O(n) 的,不是真环形。这一章的 `ChartView` 才是真正的环形缓冲,而且它用环形是有道理的,因为折线图每帧都要把整个缓冲从头读到尾画出来,读取必须快,不能像 `QVector::remove` 那样每次写入都搬移。 + +环形缓冲的核心是两个游标加一个定长数组。数组 `m_buf` 固定大小 `kCapacity`(150,正好 30 秒的窗口、200ms 一个点),`m_head` 是"下一个该写入的位置",`m_count` 是"目前存了多少个有效点"(不超过 kCapacity)。写入一个新值: + +```cpp +void ChartView::pushSample(float lux) { + m_buf[m_head] = lux; + m_head = (m_head + 1) % kCapacity; + if (m_count < kCapacity) ++m_count; + update(); +} +``` + +`m_buf[m_head] = lux` 写到当前位置,`m_head = (m_head+1) % kCapacity` 把写指针往前推一格、到尾了绕回头,这就是"环"的来源,`% kCapacity` 让指针在 0 到 149 之间循环。前 150 次写入,`m_count` 一路加到 150,之后数组满了,新的写入会覆盖最老的点,`m_count` 就不再加,永远停在 150。没有搬移,写入是 O(1),这是它比 `QVector::remove` 强的地方。 + +读取时,因为数据是绕着环写的,"最老的点"不在下标 0 而在 `m_head` 当前位置(满了的话),最新的点是 `m_head-1`。paintEvent 里要按时间从老到新遍历,下标算术是这样: + +```cpp +const int idx = (m_head - n + i + kCapacity) % kCapacity; +``` + +`i` 从 0 到 n-1,算出来的 `idx` 就是从最老到最新地走遍有效点。这个 `(m_head - n + i + kCapacity) % kCapacity` 的写法是环形缓冲读取的标准技巧,加一个 `kCapacity` 是为了防止 `m_head - n` 变负数(取模在 C++ 里对负数的行为不直观),先把正数加回来再模。这套下标算术你写两遍就会熟,是环形缓冲的肌肉记忆。 + +## mappedY:把 lux 映射到像素 + +数据是 lux 浮点数,屏幕是像素坐标,两者之间要有一个映射。`mappedY` 干这件事: + +```cpp +float ChartView::mappedY(float lux, int top, int height) const { + const double v = (lux < 0.0) ? 0.0 : (lux > m_yMax ? double(m_yMax) : double(lux)); + return float(top + height - int(v / m_yMax * height)); +} +``` + +先 `clamp` 一下,把 lux 限制在 0 到 `m_yMax`(800)之间,防止异常值画出绘图区。然后 `v / m_yMax * height` 算出这个值占绘图区高度的比例,转成像素偏移。关键是最后那个 `top + height - ...`,因为屏幕 Y 向下、我们想要 lux 越大越靠上,所以用绘图区底部的 Y(`top + height`)减去偏移,把方向翻过来。lux=0 映射到 `top+height`(底部),lux=yMax 映射到 `top`(顶部)。这个"减一下翻 Y 轴"是所有自己画图表的代码都会有的套路。 + +## update() 的真相 + +代码里那个注释差点误导人,这一步值得多花点笔墨讲清楚。你看 `pushSample` 最后一行 `update();`,旁边注释写着"只刷自身矩形"。这句话描述的是**效果**,但它描述得容易让人以为机制是"只重绘某个矩形"。实际上 `QWidget::update()` 不带参数时,调度的是**整个控件**的重绘,不是某个矩形。那个"只刷自身矩形"的效果,真正的来源是 `ChartView` 是一个**独立的小 QWidget**,它的"整个控件"本来就只有右栏那块折线区域那么大,所以重绘它的整个控件,等价于只刷那一小块矩形。 + +事情为什么要讲这么细。因为如果你把这个 `update()` 抄到一个**大控件**里,比如一个铺满整个窗口的 widget,那你每次 `pushSample` 都会触发整个大控件重绘,性能就崩了。light-meter 这么写没事,是因为 ChartView 被设计成一个独立的小 widget,它的"全部"就是那条折线,重绘整个它成本很低。真正能做矩形级局部刷新的 API 是 `update(QRect)`,但 light-meter 没用,因为它不需要,小控件全量重绘已经够快。 + +说白了,自绘控件的性能,一半取决于你画得简不简,另一半取决于你**把控件切得够小**。把高频刷新的区域单独做成一个小 QWidget,它的全量重绘就天然只影响那一小块,这比在一个大 widget 里费劲算 dirty rect 简单也可靠。light-meter 把折线单独抽成 ChartView,就是这个道理。 + +## 逐行读 paintEvent + +`paintEvent` 是 ChartView 最长的方法,但逻辑是线性的,我们顺着读。开头: + +```cpp +QPainter p(this); +p.setRenderHint(QPainter::Antialiasing, true); +p.fillRect(rect(), QColor(kBg)); +``` + +建 painter,开抗锯齿(折线会平滑很多,代价是一点 CPU,对这个刷新量完全负担得起),填背景色。 + +接着算绘图区,`rect().adjusted(40, 12, -12, -28)`,左边让出 40 像素给 Y 轴刻度文字,上下右各留点边,得到真正画折线的矩形 `plot`。 + +然后画 Y 轴网格和刻度,0、200、400、600、800 各一条横向虚线加一个数字标签: + +```cpp +for (int v = 0; v <= 800; v += 200) { + const int y = int(mappedY(float(v), plot.top(), plot.height())); + p.setPen(QPen(QColor(kGridLine), 1, Qt::DotLine)); + p.drawLine(plot.left(), y, plot.right(), y); + p.setPen(QColor(kAxisText)); + p.drawText(QRect(plot.left() - 38, y - 8, 34, 16), + Qt::AlignRight | Qt::AlignVCenter, QString::number(v)); +} +``` + +阈值虚线同理,`m_threshold` 那个位置画一条 `Qt::DashLine` 长虚线,标个"阈值 N"。 + +折线本体用 `QPainterPath` 拼,从最老到最新逐个 `lineTo`,同时拼一个"曲线下面积"的 path 用于半透明填充。x 坐标的算法让最新点恒在最右、老数据从右往左滚: + +```cpp +const double x = plot.left() + + double(i + (kCapacity - n)) / (kCapacity - 1) * plot.width(); +``` + +`(kCapacity - n)` 那一项是关键,数据还没填满 150 个点时(n` 里。问题是 `M_PI` 压根不是 C/C++ 标准的一部分,它是 POSIX 的扩展,GCC 和 Clang 默认提供,但 MSVC 默认不定义它,你得在 include 前定义 `_USE_MATH_DEFINES` 才有。所以同一份用了 `M_PI` 的代码,在 Linux 上编得好好的,拿到 Windows MSVC 上就报"`M_PI` 未定义"。light-meter 的办法是干脆自己写一个 `constexpr float kTwoPi = 6.2831...`,把圆周率的两倍直接写死成字面量,不依赖任何平台宏,哪个编译器都认。这事儿踩一次就长记性,跨平台 C++ 别图省事用非标准扩展。 + +还有几个小点。`BreathingOverlay` **没有 `Q_OBJECT` 宏**,因为它不发出也不接收信号槽,纯 paintEvent 就够了,省掉 moc 那套。它的构造函数里 `setAttribute(Qt::WA_NoSystemBackground, true)` 让它不画系统背景、避免闪一下白。它自己不 `raise`、不设几何,这些是 `MainWindow` 在进息屏态时干的,`m_overlay->setGeometry(rect())` 把它铺满整个窗口、`raise()` 顶到最上层、`show()` 显示,出息屏态时 `hide()`。 + +## 上手:看折线滚 60 秒,再踩一下 M_PI 的坑 + +跑起 light-meter 的 Mock 配置,盯着折线看 60 秒。它应该平滑地滚动,30 秒后填满整个绘图区,早期数据从右边一路滚到左边消失,永不越界。lux 跌破阈值时(拖阈值滑杆到 700 强制触发),整条线和当前点翻红。这一步过了,环形缓冲和颜色翻转就算都对上了。 + +第二个小实验,把 `breathing_overlay.cpp` 里那个 `kTwoPi` 临时换成 `M_PI * 2`,在 Linux 上能编,但拿到 Windows MSVC 上(如果你有环境)就会报 `M_PI` 未定义。改回 `kTwoPi` 就好。没 Windows 环境也没关系,记住这个坑就行。 + +## 这一章的坑 + +先说最大的那个,就是上面讲过的,误以为 `update()` 是矩形级局部刷新。它是整个控件重绘,light-meter 不卡是因为 ChartView 小。你把高频自绘放在一个大 widget 里,就得自己用 `update(QRect)` 或更精细的脏区管理。 + +然后是 `QPainter` 的画笔画刷状态会"渗透"。你 `setPen` 画了一个东西,忘了复位,下一段绘制就接着用上一段的 pen。`ChartView` 里每画一种东西都显式 `setPen`/`setBrush`,这是个好习惯,别依赖默认状态。 + +抗锯齿在大面积填充上很贵。`setRenderHint(Antialiasing)` 开着画折线和圆点没事,但你要是拿它 `fillRect` 整个背景就大可不必,大面积填色抗锯齿帮不上忙还费 CPU。ChartView 是先 `fillRect` 背景(此时还没开抗锯齿的事,fillRect 本身不走抗锯齿路径),再开抗锯齿画线。 + +环形缓冲的下标算术差一也容易翻车。`(m_head - n + i + kCapacity) % kCapacity` 那个 `+ kCapacity` 漏了,或者 `n` 用错了(比如直接用 `kCapacity` 而不是 `qMin(m_count, kCapacity)`),你画出来的折线就会错位、或者画出没初始化的点。写环形缓冲一定拿笔画一遍下标,别凭感觉。 + +跨平台用 `M_PI` 那个前面已经讲过,不再重复,自己写 constexpr 字面量最稳。 + +## 小结 + +回头看,这一章其实就是把"自己画折线"这件事从动机一路拆到实现:Qt Charts 在 rootfs 里但 light-meter 没用它,是算过账的取舍;QPainter + paintEvent 的自绘套路,屏幕坐标系 Y 轴朝下逼出来的那套映射翻转;真正的环形缓冲长什么样、为什么写入能是 O(1);还有 `update()` 的真相和那个差点骗人的注释。`M_PI` 的坑算个赠品。 + +light-meter 的桌面 Mock 阶段到这里就讲完了。运行、告警、息屏三态,数据从 MockedSensor 流到 ChartView 自绘的折线,整条软件链路在桌面上跑得明明白白。接下来问题来了:下一章是整个系列的转折点,我们要翻那个 CMake 开关,把 Mock 后端换成真机后端,看看"契约先行"这件事到底扛不扛得住迁移。 + +## 继续学习 + + + ← 05 三态 UI + 状态机 + 07 THE 接缝:一行 CMake 切后端 → + diff --git a/document/tutorial/project/light-meter/07_cmake_seam.md b/document/tutorial/project/light-meter/07_cmake_seam.md new file mode 100644 index 000000000..540ca824d --- /dev/null +++ b/document/tutorial/project/light-meter/07_cmake_seam.md @@ -0,0 +1,168 @@ +--- +title: THE 接缝:一行 CMake 切后端 +--- + +# THE 接缝:一行 CMake 开关,后端就换了 + +::: info 本节你将学到 +- light-meter 怎么靠一个 CMake `option` 在编译期切换 Mock 和真机两个后端 +- CMake 的 `option` / `target_sources` / `target_compile_definitions` 三件套,以及 C++ 里的 `#ifdef` 条件编译 +- 为什么这个开关放在编译期而不是运行期,host 和 target 隔离是什么意思 +- 为什么这套机制能砍这么深,第 03 章那个"把注入端口放基类"的决定在这里兑现回报 +- 那个 `override` 不继承默认参数的 C++ gotcha +::: + +::: tip 前置知识 +- 第 03 章的 Sensor 抽象契约、第 04 章的 MockedSensor,这是被切换的两个后端 +- 第 01 章的 CMake 基础 +::: + +## 这一刀是整个桌面阶段的收口 + +前面六章我们做的事,可以一句话总结:在桌面上用 MockedSensor 把 light-meter 整条软件链路调到完美。UI、状态机、自绘折线、CSV 导出,全跑在一个假数据后端上,一行真硬件代码都没碰。说实话,你自己心里大概一直有个疑问:既然桌面跑的是假数据,那真机呢,翻成真机是不是要把这一堆代码重写一遍。 + +这一章就是回答这个问题的。答案说出来也很朴素,翻成真机,不改 UI 一行代码、不改状态机一行代码、不改折线绘制一行代码,只翻一个 CMake 开关。这个"翻开关就行"听着像魔法,但它不是凭空来的,它是第 03 章我们花一整章钉死的 Sensor 抽象契约、以及把 `set_phase`/`set_held` 放在基类当空实现那个决定,在这一刻结出来的果子。我们这一章就把这个接缝的机制拆开,看它到底怎么工作,以及为什么它能砍得这么深。 + +## 三件套:option / target_sources / target_compile_definitions + +接缝的全部机制,在 light-meter 的 `CMakeLists.txt` 里就是这么一段: + +```cmake +# 真机后端(可选): /dev/ap3216c 桥接, 仅 Linux/板子(POSIX)。默认 OFF = Mock 后端。 +option(USE_REAL_SENSOR "真机后端 /dev/ap3216c(仅 Linux/板子)" OFF) +if(USE_REAL_SENSOR) + if(WIN32 OR NOT UNIX) + message(FATAL_ERROR "USE_REAL_SENSOR 仅 Linux/板子可用(POSIX open/read/close)") + endif() + target_sources(light-meter PRIVATE + sensor/ap3216c/ap3216c_sensor.h sensor/ap3216c/ap3216c_sensor.cpp) + target_compile_definitions(light-meter PRIVATE USE_REAL_SENSOR=1) +endif() +``` + +我们逐行拆。`option(USE_REAL_SENSOR "说明文字" OFF)` 定义一个 CMake 的开关变量,名字 `USE_REAL_SENSOR`,默认值 `OFF`,它会在 `cmake -B build` 配置阶段被读到。用户可以在配置时用 `-DUSE_REAL_SENSOR=ON` 把它翻成 ON。`option` 本质就是带默认值的布尔变量,但它语义上表示"这是一个用户可调的开关",比普通 `set` 更能表达意图,而且 CMake 的 GUI(cmake-gui、ccmake)会把它列进可调选项里。 + +`if(USE_REAL_SENSOR)` 判断这个开关有没有开。开了就进里面三行。 + +第一行是个平台守卫,`if(WIN32 OR NOT UNIX)` 表示"如果在 Windows 上,或者根本不是 Unix",就 `message(FATAL_ERROR ...)`。`FATAL_ERROR` 是 CMake 的一个消息级别,它会让配置过程**立刻报错中止**。为什么要这么狠,因为真机后端 `Ap3216cSensor` 用的是 POSIX 的 `open`/`read`/`close` 这套系统调用,Windows 没有这些。你硬要在 Windows 上编译它,会撞一墙的"未定义标识符 open"之类的错,而且这些错报得晚、报得散,新手根本不知道怎么回事。所以干脆在 CMake 配置阶段就拦住,直接告诉你"这个开关只能在 Linux 或板子上用",把一个会发生在编译中途的、让人困惑的失败,提前成一个清晰的配置期错误。早失败、说人话,比晚失败、说鬼话强太多了。 + +第二行 `target_sources` 把真机后端的两个源文件加进 light-meter 这个目标的编译列表里。注意默认情况下(开关 OFF)这两个文件**根本不参与编译**,这就是为什么你在 Windows 上能编 light-meter,因为 Windows 编译器压根没看见 `ap3216c_sensor.cpp` 里那些 POSIX 调用。开关一开,它们才进编译列表。 + +第三行 `target_compile_definitions(light-meter PRIVATE USE_REAL_SENSOR=1)` 给目标加一个编译期宏定义 `USE_REAL_SENSOR=1`,等价于在编译命令里加 `-DUSE_REAL_SENSOR=1`。这个宏定义是给 C++ 源码看的,源码里用 `#ifdef USE_REAL_SENSOR` 来判断当前编的是哪个后端,这正是接下来要讲的事。 + +这三行合起来,做的就是"开关一开,把真机源文件加进编译,再往源码里塞一个 `USE_REAL_SENSOR` 宏"。`target_sources` 管编译哪些文件,`target_compile_definitions` 管源码里能看到什么宏,CMake 控制编译产物就靠这两板斧。 + +## 条件编译:在 C++ 源码里读那个开关 + +CMake 塞进去的 `USE_REAL_SENSOR` 宏,C++ 源码怎么用。靠的是预处理器的 `#ifdef` 条件编译。看 light-meter 真正切换后端的地方,注意,它在 `mainwindow.cpp` 里,**不是** `main.cpp`。这是个容易搞错的点,很多人以为后端切换会写在程序入口 main.cpp 里,但 light-meter 把它放在了 `MainWindow` 的构造函数,因为后端是被 `MainWindow` 持有和使用的。 + +先看头文件包含,`mainwindow.cpp` 开头: + +```cpp +#ifdef USE_REAL_SENSOR +#include "ap3216c/ap3216c_sensor.h" +#else +#include "mocked/mockedsensor.h" +#endif +``` + +`#ifdef USE_REAL_SENSOR` 表示"如果编译时定义了这个宏",就只包含真机后端的头文件,否则只包含 Mock 的。预处理器的 `#ifdef`/`#else`/`#endif` 是在**编译之前**就处理掉的,它 literally 地从源码里删掉不满足条件的那个分支。所以编真机版本时,`mockedsensor.h` 这一行根本不存在;编 Mock 版本时,`ap3216c_sensor.h` 这一行根本不存在。这就是为什么 Mock 版本能编译过、不报"找不到 open",因为 open 那段代码在预处理阶段就被删了。 + +真正的工厂分支在构造函数里: + +```cpp +#ifdef USE_REAL_SENSOR + m_sensor = std::make_unique(); +#else + m_sensor = std::make_unique(); +#endif + m_sensor->init(false); // 注: override 不继承基类默认参数, 显式传 false +``` + +`m_sensor` 是 `std::unique_ptr`,基类指针,这在第 03 章讲过。这两行 `#ifdef` 决定它实际 new 的是哪个派生类。编真机版本,它 new 一个 `Ap3216cSensor`;编 Mock 版本,new 一个 `MockedSensor`。但往下看,`m_sensor->init(false)` 之后的所有代码,`processSample`、状态机、定时器、CSV 导出,全都只通过 `Sensor*` 这个基类指针跟后端打交道,它们对"底下到底是谁"一无所知,也不关心。 + +这就是接缝的全部,两处 `#ifdef`,一处管 include,一处管 new 哪个对象,干净得很。`main.cpp` 全文十行,只是 `QApplication` 加 `MainWindow::show()`,它不碰后端切换,所以你以后看 light-meter 别去 main.cpp 里找开关,白找。 + +## 为什么是编译期开关,不是运行期 + +你可能想,为什么不做成运行期切换,比如读个配置文件或者命令行参数,程序启动时决定用 Mock 还是真机,这样一份二进制就能两个地方都跑。这是个合理的想法,但 light-meter 选编译期,是有理由的。 + +根本原因是,Mock 后端和真机后端跑在**两台不同的机器**上。Mock 在你的 Windows 或 Linux 开发机上,真机在 i.MX6ULL 板子上,这两台机器指令集都不一样(板子是 ARM,开发机是 x86),你不可能编出一份在两边都跑的二进制。所以"运行期切换"在这场景下本来就不成立,你为板子编的 ARM 二进制,在开发机上根本启动不了;反过来也一样。既然必然要为不同目标编不同的二进制,那把后端选择放在编译期是最自然的,每个目标编出来就是为那个目标定制的,真机二进制里根本没有 Mock 的代码,Mock 二进制里根本没有 POSIX 调用,各自的二进制都最精简,也没有运行期判断的开销。这就是 host(开发机)和 target(目标板)隔离的工程哲学,你给谁编,编出来就是给谁的,不掺混。 + +如果哪天你的需求变了,比如要在同一台机器上根据运行时条件选后端(板子上既接了真传感器又想 fallback 到 mock 测试),那时候才考虑运行期策略,比如把后端做成动态加载的插件,或者读配置。但 light-meter 不需要,编译期开关就是最简单够用的方案。**别过度设计**,够用就好,这一条对嵌入式这种"目标固定、需求收敛"的场景尤其贴。 + +## 为什么能砍这么深:契约先行的回报 + +现在你能看到第 03 章那个决定的全部回报了。回顾一下,我们在 `Sensor` 基类里做了一件当时看起来多余的事:把 `set_phase` 和 `set_held` 这两个只有 Mock 才用得上的"测试注入口",放在基类里给了空实现 `{}`,而不是放进 MockedSensor 当私有方法。 + +回报在哪。看 `MainWindow` 的代码,它到处调 `m_sensor->set_phase(...)`、`m_sensor->set_held(...)` 来推进 Mock 的假数据、模拟手靠近。如果这两个方法是 Mock 私有的,那 `MainWindow` 的这些调用就耦合死了 Mock,你切到真机后端时,这些调用全得删掉或者加 `if` 判断"如果现在是 Mock 就 set_phase,真机就算了",那接缝就不止两处 `#ifdef` 了,得散落得到处都是。 + +但因为它们在基类给了空实现,`MainWindow` 调 `m_sensor->set_phase(...)` 时,Mock 后端真的推进假数据,真机后端走基类的空实现、什么也不发生,代码一行不改。这就是第 03 章说的"留在基类以便 UI 层无差别调用,切换后端时不需改 MainWindow 的调用点"。这件事之所以只翻两处 `#ifdef` 就能换后端,根就在这里:契约先行把所有"后端差异"都吸收进了基类接口,UI 那一侧永远是干净的。 + +"契约先行"听着像 PPT 里的口号,但它不是。它在你切换实现、扩展功能、加测试的时候,实打实地省下重构的成本。light-meter 是个小例子,你想象一个有十个后端、上百个 UI 调用点的大项目,契约先行省下的修改量是数量级的。这种"多实现、要切换"的系统,谁先在契约上把差异吸收干净,谁后面就少掉头发。 + +## override 不继承默认参数:一个 C++ gotcha + +`m_sensor->init(false)` 那行旁边有个注释"override 不继承基类默认参数, 显式传 false",这里顺手讲掉这个 C++ 经典坑。 + +第 03 章我们定义基类时,`init` 的签名是 `virtual std::expected init(bool force_reinit = false) = 0;`,带一个默认参数 `false`。派生类 override 时,`MockedSensor` 和 `Ap3216cSensor` 都写成 `init(bool force_reinit)` **不带默认参数**。 + +C++ 的规则是,默认参数**不参与虚函数的派发**,它是按**静态类型**在编译期决定的。也就是说,你通过 `Sensor*` 指针调 `init()`,编译器看到静态类型是 `Sensor`,就用 `Sensor` 那份默认参数(false);运行时实际派发到哪个派生类的 init,是另一回事。这有两个坑:一是,如果基类没给默认参数、派生类给了,你通过基类指针调 `init()` 编译都过不了,因为基类那份没默认值;二是,默认参数在派生类里不会"继承"基类的,你得在每个 override 里显式再写一遍(如果你想要的话)。 + +light-meter 的做法是干脆谁都别依赖默认参数,调用处 `init(false)` 显式把值传进去,清清楚楚,不依赖静态类型是哪个。虚函数 + 默认参数是个危险组合,要么别用默认参数,要么调用处全显式传,这条记一下,"明明基类有默认参数、为什么我 override 里改了不生效"这种 bug 调起来是真的费劲。 + +## 上手:三路径验证 + +这一章的上手验证我们走三条编译路径,每条的预期行为都不同。把它当三道关卡,过一遍比看十遍文字都清楚。 + +第一条,默认 OFF,在你开发机上编 Mock: + +```bash +cmake -B build +cmake --build build +./build/light-meter +``` + +跑起来就是前面几章那个桌面摆件,折线起伏、能告警、能息屏。这是基线。 + +第二条,在 Linux 开发机上开真机开关编: + +```bash +cmake -B build-real -DUSE_REAL_SENSOR=ON +cmake --build build-real +``` + +它能编译通过(因为是 Linux,过得了那个 `WIN32 OR NOT UNIX` 守卫),真机后端 `ap3216c_sensor.cpp` 参与编译,二进制里编进去的是 `Ap3216cSensor`。但你在开发机上跑 `./build-real/light-meter` 会发现它启动后读不到数据、状态栏报错,因为开发机上没有 `/dev/ap3216c` 这个设备节点,`Ap3216cSensor::init` 会返回 `DeviceUnavailable`。**这是预期行为**,真机二进制本来就该在板子上跑,不是在开发机上。这条路径验证的是"编译能过、后端确实换成了真机",你看到 DeviceUnavailable 别慌,那是它在正确地报"这台上不了网"。 + +第三条,在 Windows 上开真机开关: + +```bash +cmake -B build -DUSE_REAL_SENSOR=ON +``` + +它会立刻撞上那条 `message(FATAL_ERROR ...)`,CMake 配置中止,告诉你这个开关只能在 Linux 或板子上用。这条验证的是那个平台守卫在干活。 + +三条路径你跑下来,接缝的机制就算真在自己手上过了一遍。至于真机二进制真正在板子上跑出真实数据,那是后面第 09、10 章的事,我们这一章只管"开关本身怎么工作"。 + +## 这一章的坑 + +第一个坑,改了 `USE_REAL_SENSOR` 开关之后忘了重新跑 `cmake -B`。`option` 的值是在配置阶段读进 `CMakeCache.txt` 的,你直接 `cmake --build` 它还用着上次的旧值。改开关就重新配置一次,或者干脆 `rm -rf build` 重来,这是第 01 章讲过的"CMake 缓存会咬人"的又一例,别问我怎么记住这条的。 + +第二个坑,期待翻个开关、不重新编译就生效。编译期开关意味着你必须重新编译,二进制里编进去的后端才是新的。你以为翻完 `cmake -DUSE_REAL_SENSOR=ON` 就行了,忘了 `cmake --build`,跑的还是旧的 Mock 二进制,然后纳闷"怎么还是假数据"。这种事我至少干过两回。 + +第三个坑,Windows 上看到 `FATAL_ERROR` 以为出了什么大问题。那就是个守卫,告诉你这个开关在你的平台上不该开,关掉它继续用 Mock 就行,不是 bug。 + +第四个坑就是上一节的默认参数 gotcha,虚函数带默认参数时,调用处显式传值,别依赖继承来的默认。 + +## 小结 + +桌面阶段到这里收口。接缝本身其实没什么花活,就是 CMake 三板斧管编译产物、C++ 预处理器读那个 `USE_REAL_SENSOR` 宏、host 和 target 因为指令集不一样所以老老实实走编译期。真正撑起"只翻两处 `#ifdef` 就能换后端"的,是第 03 章把差异吸收进基类契约那个决定,以及那个 `override` 不继承默认参数的小坑提醒我们:虚函数 + 默认参数,老老实实显式传。 + +翻完开关你会看到,真机二进制在板子上的行为和桌面 Mock 并不完全一样,折线量级、告警触发、唤醒阈值,可能都得按你自己的板子重新调。这正是下一章的事,怎么把这些默认占位值,标定到位。 + +## 继续学习 + + + ← 06 自绘 ChartView + 息屏遮罩 + 08 把阈值调成你的:按环境标定 → + diff --git a/document/tutorial/project/light-meter/08_calibrate_to_your_env.md b/document/tutorial/project/light-meter/08_calibrate_to_your_env.md new file mode 100644 index 000000000..ae9adef9d --- /dev/null +++ b/document/tutorial/project/light-meter/08_calibrate_to_your_env.md @@ -0,0 +1,145 @@ +--- +title: 把阈值调成你的:按环境标定 +--- + +# 把阈值调成你的:把默认占位值标定到你的环境 + +::: info 本节你将学到 +- 为什么真机翻完开关后,默认常量得按你自己的板子调一调,这不是修坑,是任何涉及物理量换算的产品都要做的常规最后一步 +- als raw 计数和物理量 lux 是两件事,`lux_coeff` 这个换算系数怎么用手机粗标 +- ps 的唤醒阈值在真机上怎么定,怎么参考 driver/08 的实测数据 +- 每个可调常量分别住在哪个文件哪一行,运行时能调的和要重编的分别怎么改 +- 一个小增强,把 `lux_coeff` 做成环境变量可调,改参数不用每次重编 +::: + +::: tip 前置知识 +- 第 07 章的接缝机制,你已经能编出真机二进制 +- 第 09 章的 `Ap3216cSensor` 在下一章细讲,但这一章会引用它的 `lux_coeff` 参数;标定这件事本身在桌面就能理解,真机验证等下一章接上 +::: + +## 翻完开关,默认值是按我们的板子调的 + +第 07 章你翻完 `USE_REAL_SENSOR=ON`,真机二进制能在板子上跑出真实数据了,真机行为我们这边验过、跑通过,这件事可以放心。但有件事得先跟你交个底:light-meter 源码里那些默认常量,`lux_coeff`、告警阈值、唤醒阈值,是按我们这块板子、我们这个测试环境调好的。你的板子镜头透光率未必一样、AP3216C 焊的位置也未必一样、你房间的照度更不可能跟我的一样,这些都会让"同一份代码、换一个物理环境"读出来的数不一样。 + +所以下面要讲的不是修坑,是任何涉及物理量换算的产品都要做的最后一步:标定。把那些写着 1.0 的占位值、写着默认数的阈值,按你自己的环境调到位。light-meter 故意把这一步留给你,占位值只是"能跑",标定过的值才是"在你这边准"。温湿度、气压、距离,凡是带物理量换算的传感器,这步都躲不掉。 + +## als raw 不是 lux:lux_coeff 标定 + +标定里头最要紧的一件事,是先把 als raw 和 lux 这两个概念分清楚。AP3216C 的环境光通道给你的不是一个物理量 lux,而是一个 raw 计数,叫 als raw,它大致正比于光强,但比例系数受镜头透光率、安装位置、传感器个体差异影响。driver/08 那篇实测数据里,正常室内光下 als raw 大概 80 上下,注意这个 80 不是 80 lux,它只是个计数。 + +物理量 lux 是有国标定义的照度单位,GB 50034 规定书桌阅读要 300 lux 以上。light-meter 的告警逻辑、"明亮/不足"的判断,都是冲着物理量 lux 去的,所以必须把 als raw 换算成 lux,这个换算系数就是 `lux_coeff`。看 `Ap3216cSensor` 的查询逻辑,`db[1] * m_lux_coeff` 就是这一步: + +```cpp +return SensorData{ + .luxury = db[1] * m_lux_coeff, // als → luxury(lux) + .ps = static_cast(db[2]) +}; +``` + +`lux_coeff` 在构造函数里默认是 1.0,这是个诚实的占位,意思就是"还没标定",这时候显示的"lux"其实就是 als raw,室内读个 80。标定,说白了,就是把这个 1.0 换成你环境的真值。 + +朴素的办法是拿一个已知 lux 的参考。你手机上装一个照度计 App(它用手机的前置光感估算 lux,精度一般,但够粗标),把手机和 AP3216C 摆在同一束光下,同时读,App 显示 X lux、板子读到 als raw = Y,那 `lux_coeff = X / Y`。举例,App 读 300 lux、板子 als raw 是 80,那 `lux_coeff = 300 / 80 = 3.75`。把这个值填进去,以后 `luxury = als_raw * 3.75`,light-meter 显示的 lux 就和手机 App 在 ±15% 内对得上。 + +±15% 是个诚实的容差,手机照度计 App 本身误差就不小,这套是"粗标定",比 1.0 强得多、让告警逻辑真的有意义,但它不是计量级的。要实验室精度,得上标准光源和照度计,那是另一回事,light-meter 这个摆件用不着。 + +系数填哪。在 `mainwindow.cpp` 那个真机工厂分支,默认是 `std::make_unique()`,用的是构造函数的默认 `lux_coeff=1.0`。你可以显式传: + +```cpp +#ifdef USE_REAL_SENSOR + m_sensor = std::make_unique("/dev/ap3216c", 3.75); // 你标定出的系数 +#else + m_sensor = std::make_unique(); +#endif +``` + +## ps 唤醒阈值:参考 driver/08 的实测 + +ps 这一路比 lux 简单,不用换算物理量,但有个阈值要定。`kPsWakeThreshold` 是"ps 多大算有接近",默认 500。这个 500 是按 Mock 后端调的,Mock 在"手靠近"时给 800 到 840,远远超过 500。但真机呢? + +答案在 driver/08 的实测数据里。板子上,手离开、正常室内,ps 大概在 430 到 450 这个量级;手指慢慢靠近、贴近传感器,ps 上升到峰值大概 502。所以真机 ps 的有效范围跟 Mock 完全是两回事:Mock 是 0-40 对 800-840,真机是 ~440 对 ~502。`kPsWakeThreshold=500` 意味着只有手指**几乎贴上**传感器(ps 到 502)才会触发唤醒,"手在前面晃晃"是到不了 500 的。 + +你要的体验是哪种,阈值就定在哪。想"手指贴近才唤醒"(省电、防误触),500 合适。想"手伸到前面就唤醒"(灵敏、像床头感应),那 500 太高,得降到 470 左右,给离 idle 的 440 留点裕量。这个值没有标准答案,按你想要的体感试。改的位置是 `mainwindow.h` 里 `static constexpr int kPsWakeThreshold = 500;` 这一行,改完重编。 + +说一句,阈值离 idle 噪声太近会抖。你要是把阈值定在 445,而 idle 本身在 430 到 450 之间漂,那没人靠近的时候 ps 也会偶尔过 445,屏幕反复唤醒息屏,体验很糟。idle 和阈值之间留够裕量,这是上一章讲 Mock 时提过的"裕量意识",搬到真机标定一样成立。 + +## 阈值滑杆:运行时就能调告警线 + +`lux_coeff` 和 `kPsWakeThreshold` 是要重编才能改的(下面那个增强会改掉 lux_coeff 这一条)。但告警阈值 `m_threshold` 不用,light-meter 给它配了个运行时滑杆,左下角那个"阈值"滑条,拖一下就实时改告警线,折线图上的虚线跟着移动,告警着色也立刻重评估。 + +```cpp +void MainWindow::onThresholdChanged(int value) { + m_threshold = double(value); + m_thresholdValue->setText(QString::number(value)); + m_chart->setThreshold(float(m_threshold)); + setAlarmMode(m_lastLux < m_threshold); // 即时重评估告警 +} +``` + +滑杆范围 100 到 700,默认 300,正好是国标 GB 50034 的书桌阅读下限。接上真机后,不用重编,直接拖滑杆就能找到你环境下"明亮/不足"的分界点,这是体验调参最快的方式。等你用滑杆摸到合适的阈值,再回头把它写进 `mainwindow.h` 里 `m_threshold` 的默认值,下次启动就是新值。 + +先用界面找到值、再固化进配置,这是个挺顺手的工程习惯。 + +## 一个小增强:lux_coeff 走环境变量 + +每次标定完都要改源码、重编,有点烦,尤其标定本身就是要反复试的。这里我带你做一个小增强,把 `lux_coeff` 做成环境变量可调,改系数只要重启程序、不用重编。改动很小,但能让标定过程顺很多,也顺带把"配置外置"这点小思路过一遍。 + +改动在 `mainwindow.cpp` 的真机工厂分支,读一个环境变量 `LIGHTMETER_LUX_COEFF`,解析成 double,传给构造函数,没设就退回默认 1.0: + +```cpp +#ifdef USE_REAL_SENSOR +{ + double coeff = 1.0; // 默认占位 + if (const char* env = std::getenv("LIGHTMETER_LUX_COEFF")) { + bool ok = false; + double parsed = QString::fromLocal8Bit(env).toDouble(&ok); + if (ok) coeff = parsed; + } + m_sensor = std::make_unique("/dev/ap3216c", coeff); +} +#else + m_sensor = std::make_unique(); +#endif +``` + +记得在文件顶部 include `` 拿 `std::getenv`。改完之后,标定流程就变成:板子上跑 `LIGHTMETER_LUX_COEFF=3.75 ./light-meter`,看 lux 对不对得上手机,不对就改数重跑,全程不编译。定下来之后,你可以把这个值固化进启动脚本、或者 systemd service 的 Environment,也可以回头写进源码默认值,看你怎么舒服。 + +`std::getenv` 返回 `const char*`,可能为空(变量没设),`QString::fromLocal8Bit(env).toDouble(&ok)` 把字符串转 double,`ok` 表示转换成不成功,这两层保护让你在环境变量写错(比如写了个字母)时不崩、退回默认。处理外部输入就该这么写:校验、失败就退回安全默认,别图省事直接 `atof(env)` 不管成败。 + +这个小增强你不一定要做,light-meter 主线用源码默认值也跑得好。但如果你打算顺滑地标定,或者哪天想把 light-meter 部署到几块不同的板子上、每块板系数都不一样,这套外置配置就方便了。 + +## 上手:用手机粗标 lux_coeff + +这步要等第 09 章你能在板子上读到真实 als raw 之后才能完整做,这里先把流程过一遍,等你接上 `Ap3216cSensor` 照着走就行。 + +手机装个照度计 App,把手机和板子的 AP3216C 摆在同一束室内光下,手机读一个参考 lux,记下来,比如 300。 + +接着在板子上读 AP3216C 的 als raw,这个用第 09 章那个 5 行独立 main 最方便,跑一下打印 als,比如读到 80。 + +然后算 `lux_coeff = 300 / 80 = 3.75`,用上面那个环境变量增强跑 `LIGHTMETER_LUX_COEFF=3.75 ./light-meter`,看界面上显示的 lux 和手机 App 读数差多少。理想是 ±15% 以内。 + +差得多的话,换个光强(比如开个台灯)再来一次,两点标定取平均更稳。定下来之后,把系数写进启动脚本或源码默认值。 + +顺手把 ps 唤醒阈值也定一下,按你想要的"贴近唤醒"还是"伸手唤醒",改 `kPsWakeThreshold`,重编、试。 + +## 这一章的坑 + +第一个坑,拿手机 App 当照度标准却期待计量级精度。手机前置光感本身误差就不小,不同手机读数能差 20%。这套就是个"粗标定",±15% 容差,够摆件用,要计量级请上标准光源。 + +第二个坑,在不具代表性的光环境下标定。比如你标的时候拉了窗帘、桌面上很暗,als raw 读 30,你算出 coeff 把这个值焊死,结果白天一开窗全偏了。标定选你实际使用的典型环境,或者多点标定取平均。 + +第三个坑,ps 阈值定得太贴 idle 噪声,导致反复唤醒息屏。idle 和阈值之间留够裕量,真机 idle ~440 有漂动,阈值别定在 445 这种贴边的位置。 + +第四个坑,环境变量增强里图省事 `atof(env)` 不判失败。env 没设时 `getenv` 返回 nullptr,`atof(nullptr)` 是未定义行为;env 写了个非数字,`atof` 默默返回 0,你的 lux 全变 0。用 `toDouble(&ok)` 判一下,失败退回默认,稳。 + +## 小结 + +als raw 不是 lux,这一件最容易想错的事讲完了,`lux_coeff` 就是那个把计数换成物理量的系数,手机粗标、±15% 容差收着用。ps 唤醒阈值参考 driver/08 实测的 ~440 idle / ~502 touch,贴近唤醒还是伸手唤醒,看你想要的灵敏度。运行时滑杆先把合适的告警线摸出来,再写回编译期默认值。再就是那个把系数外置到环境变量的小增强,顺带把"外部输入要校验、失败退回安全默认"这件事也提了。 + +讲到这里,light-meter 的"翻开关、调参数"这一段就齐了。下一章我们正式读那个真机后端 `Ap3216cSensor`,看它怎么用 POSIX 的 `open`/`read` 把 `/dev/ap3216c` 的数据读出来,以及 `{ir, als, ps}` 这个三路数据的顺序,为什么是驱动和应用两端必须共享的同一份契约。 + +## 继续学习 + + + ← 07 THE 接缝:一行 CMake 切后端 + 09 POSIX 字符设备客户端 → + diff --git a/document/tutorial/project/light-meter/09_ap3216c_client.md b/document/tutorial/project/light-meter/09_ap3216c_client.md new file mode 100644 index 000000000..078219864 --- /dev/null +++ b/document/tutorial/project/light-meter/09_ap3216c_client.md @@ -0,0 +1,191 @@ +--- +title: POSIX 字符设备客户端 +--- + +# POSIX 字符设备客户端:把驱动契约翻译成用户态 + +::: info 本节你将学到 +- 用户态怎么通过 `/dev/ap3216c` 这个字符设备节点,读到内核驱动提供的数据 +- POSIX 的 `open`/`read`/`close` 三件套,文件描述符 fd 是个什么东西 +- 短读(short read)是什么,为什么 light-meter 一定要检查 `read` 的返回字节数 +- `{ir, als, ps}` 这个三路数据的顺序,为什么是驱动和应用两端必须共享的同一份契约 +- `std::expected` 怎么把 POSIX 的错误(open 失败、短读)翻译成 Sensor 那套 `InitError`/`QueryError` +::: + +::: tip 前置知识 · 硬前置 +- 第 03 章的 Sensor 契约,`Ap3216cSensor` 就是它的派生实现 +- **driver/08 AP3216C I2C 驱动是硬前置**,你的板子上必须有已经跑通、能读 `{ir,als,ps}` 的 `/dev/ap3216c`。这一章不重讲驱动怎么写,只讲用户空间怎么消费它产出的设备节点 +- 听说过 C 的文件 IO(`open`/`read`)就行,fd 这个概念我们从零讲 +::: + +## 用户态怎么读到内核驱动的数据 + +driver/08 那篇里我们写了个内核驱动,它把 AP3216C 这颗 I2C 传感器的数据,通过一个字符设备节点 `/dev/ap3216c` 暴露给用户空间。这一章讲的就是另一端:用户空间的 light-meter 怎么把数据从那个节点读出来。 + +Linux 有个一以贯之的设计哲学,"一切皆文件"。普通文件、串口、网卡、还有这种自己写的字符设备,在用户态看来都是"打开、读、写、关闭"那一套接口,内核在背后把它们对应到各自的实现。对 `/dev/ap3216c` 来说,我们 `open` 它拿到一个文件描述符,`read` 它就触发驱动的 `ap3216c_read` 那个函数,驱动把 `{ir, als, ps}` 三路数据 `copy_to_user` 到我们给的缓冲区,于是用户态就拿到了传感器的实时读数。`Ap3216cSensor` 这个类,说白了就是把这套 POSIX 文件 IO 包成了 Sensor 契约的样子,让 UI 那一侧能无差别地用它。 + +## POSIX 文件 IO:open / read / close 和 fd + +POSIX 文件 IO 的三件套我们从零过一遍,因为这是 light-meter 真机后端的根基。`open`、`read`、`close` 是 POSIX 定义的一组系统调用,用来操作"文件描述符"。 + +文件描述符,fd,是个**小整数**,是内核给你的进程发的一张"IO 句柄"票据。你 `open` 一个设备节点成功,内核给你一个 fd(通常从 3 开始,因为 0/1/2 已经被标准输入/输出/错误占了),之后你对这个 fd 做 `read`/`write`,内核就知道你想操作的是哪个打开的文件或设备。用完了 `close` 把 fd 还给内核。fd 不是指针,是个整数句柄,这是 Unix IO 模型的核心抽象,记住这一点下面都好理解。 + +`Ap3216cSensor::init` 里那行: + +```cpp +m_fd = ::open(m_dev.c_str(), O_RDWR); +``` + +`::open` 是带全局命名空间限定符的 `open`,前面那两个冒号告诉编译器"我要的是全局那个 POSIX 的 `open`,不是某个类里的同名方法"。这在 Qt 项目里是个好习惯,因为 Qt 的一些类(比如 QFile)有自己的 `open`/`read` 成员函数,不加 `::` 偶尔会被解析错,踩过一次就长记性了。`O_RDWR` 是个标志位,表示"读写方式打开"。`open` 成功返回非负的 fd,失败返回 -1 并设置 errno。light-meter 的处理是判断 `m_fd < 0` 就返回错误。 + +`read` 是这一套里最需要小心的,短读那块单独拎出来下一节讲。`close` 释放 fd,light-meter 在 `force_reinit` 重新打开之前会先 `::close(m_fd)`。 + +## 短读:为什么 read 的返回值必须检查 + +`read` 的签名大致是 `ssize_t read(int fd, void* buf, size_t count)`,它尝试从 fd 读 `count` 个字节到 `buf`,**返回实际读到的字节数**,返回类型 `ssize_t` 是有符号的(可能返回 -1 表示出错)。坑就坑在那个"实际"上,`read` 不保证一定读满你要的字节数:对普通文件可能因为读到文件尾而少读,对设备节点可能因为驱动的实现而返回不定长。读不满,就叫短读。 + +light-meter 要的是固定 6 字节(三个 `unsigned short`),所以它严格检查: + +```cpp +unsigned short db[3] = {0, 0, 0}; // {ir, als, ps}, 与驱动 copy_to_user 顺序一致 +const ssize_t n = ::read(m_fd, db, sizeof(db)); +if (n != static_cast(sizeof(db))) + return std::unexpected{QueryError::DeviceUnavailable}; +``` + +`sizeof(db)` 是 6(三个 unsigned short,每个 2 字节)。`read` 返回的 `n` 必须**正好是 6**,少一个字节都不行,直接当错误返回。这是处理结构化设备数据的正确姿势,因为接下来我们要按 `db[0]`/`db[1]`/`db[2]` 去解释这 6 个字节,如果只读到了 4 个,后两个就是初始的 0,你会读出错误的 ps。不检查短读、直接信任缓冲区,是一类很常见的 bug,轻则数据错,重则在别的场景下读越界。 + +说实话,检查 `read` 返回的实际字节数这件事,写 `read`/`recv`/`fread` 的时候都该带着,别假设它一定读满,血泪教训。 + +## {ir, als, ps} 的顺序:驱动和应用共享的契约 + +这一章最该记住的工程教训就在这里。看上面那行注释"`{ir, als, ps}, 与驱动 copy_to_user 顺序一致`"。`db[0]` 是 ir、`db[1]` 是 als、`db[2]` 是 ps,这个顺序不是随便定的,它必须和驱动那边 `ap3216c_read` 函数里 `copy_to_user` 出去的 `data[3] = {ir, als, ps}` **一模一样**。 + +为什么这么强调。因为驱动在内核、应用在用户态,它们之间唯一的"语言"就是这 6 个字节的二进制布局。驱动按 `{ir, als, ps}` 顺序写,应用就得按同样顺序读。如果哪天驱动改成了 `{ps, als, ir}` 顺序写出去,而应用没跟着改,那应用的 `db[1]` 读到的就不是 als 而是 als(碰巧位置没变),但 `db[0]` 读到的会是 ps、`db[2]` 读到的是 ir,于是 `luxury = db[1] * coeff` 还对,但如果有用到 ir 的逻辑就全错位了,更明显的例子是把 `db[0]` 当 ir 用、结果拿到的是 ps 的值。 + +这种"两端顺序不一致导致数据静默错位"的 bug 极其难查,程序不报错、数据也"在动",只是数值是错的,你盯着屏幕能盯到怀疑人生。 + +driver/08 的 `04_driver_layer.md` 里明确写了驱动 `copy_to_user` 的顺序就是 `{ir, als, ps}`,而且 `06_build_and_test.md` 里那个测试程序 `ap3216c_app.c` 也是按这个顺序读的。light-meter 的 `Ap3216cSensor` 同样按这个顺序。三处(驱动、测试程序、应用)共享同一份二进制契约,这就是跨内核/用户态边界的接口约定。凡是写"驱动给应用提供数据"的接口,这份字节布局契约都得想清楚,而且要写进文档,因为编译器帮不了你检查跨进程的二进制布局,这事儿只能靠人盯。 + +## 逐行读 Ap3216cSensor + +原理讲透了,来读代码。`Ap3216cSensor` 在 `sensor/ap3216c/ap3216c_sensor.{h,cpp}`,实现很紧凑。先看 `init`: + +```cpp +std::expected +Ap3216cSensor::init(bool force_reinit) { + if (m_fd >= 0) { + if (!force_reinit) return {}; // 已 init, 不重复打开 + ::close(m_fd); + m_fd = -1; + } + m_fd = ::open(m_dev.c_str(), O_RDWR); + if (m_fd < 0) return std::unexpected{InitError::DeviceUnavailable}; + return {}; +} +``` + +`m_fd` 的状态机是这里的核心,`m_fd >= 0` 表示已经打开过、`m_fd == -1` 表示没打开(成员初始化就是 -1)。如果已经打开过且不是强制重 init,直接成功返回,避免重复打开同一个设备。如果 `force_reinit` 为真,先 `close` 旧的、把 `m_fd` 复位成 -1,再重新 open。这种"用 fd 的值表示状态、用哨兵值 -1 表示未打开"是 C 风格 IO 的常见写法,别嫌弃它土,好用就行。 + +`query_once` 干的是真正的读数据: + +```cpp +std::expected +Ap3216cSensor::query_once() { + if (m_fd < 0) return std::unexpected{QueryError::NotInited}; + + unsigned short db[3] = {0, 0, 0}; + const ssize_t n = ::read(m_fd, db, sizeof(db)); + if (n != static_cast(sizeof(db))) + return std::unexpected{QueryError::DeviceUnavailable}; + + return SensorData{ + .luxury = db[1] * m_lux_coeff, + .ps = static_cast(db[2]) + }; +} +``` + +开头先检查 `m_fd < 0`,没 init 就 `query` 直接返回 `NotInited` 错误,这是防御性编程,别假设调用者一定先 init 了。中间那段 open/read/短读检查上面两节讲过。最后把 `{ir, als, ps}` 里的 `db[1]`(als)乘 `m_lux_coeff` 换算成 lux(第 08 章标定的那个系数)、`db[2]`(ps)直接转 int 填进 `SensorData`。 + +顺带一提,它没用 `db[0]`(ir),因为 light-meter 这款摆件不需要红外通道,驱动给了但应用忽略它,这是合理的,契约里不要求的字段可以不消费。 + +(字段名 `luxury` 是个拼写 wart,问就是手滑写错了懒得改,逃。真正想写的是 lux,但现在全代码库都叫 `luxury` 了,改起来要 grep 一圈,就这样吧。) + +## std::expected 翻译 POSIX 错误 + +这里能看到第 02 章的 `std::expected` 在真实代码里怎么用。POSIX 的 `open`/`read` 用的是返回 -1 加 errno 那套老 C 风格的错误表达,但 Sensor 契约要求用 `std::expected<..., InitError/QueryError>`。`Ap3216cSensor` 就是夹在中间的翻译层,把 POSIX 的低级错误映射成契约的错误枚举。 + +`open` 失败(`m_fd < 0`)映射成 `InitError::DeviceUnavailable`,意思是"设备打不开",常见原因是驱动没加载(没有 `/dev/ap3216c` 这个节点)或者权限不够。没 init 就 query 映射成 `QueryError::NotInited`,这是程序逻辑错误的提示。`read` 短读或出错映射成 `QueryError::DeviceUnavailable`,意思是"读不到完整数据",可能是设备被拔了、驱动出了问题。 + +这种把底层 API 的错误风格翻译成项目统一错误类型的做法,工程上很值。UI 那一侧拿到的是干净的 `InitError`/`QueryError`,不用关心底下是 POSIX errno 还是别的什么。封装底层库的活儿,都该有这么一层翻译,让上层面对统一的错误模型,不然错误风格一杂,UI 那边得写一堆 if/else 区分"这是哪种来源的错"。 + +## 先单独验证:5 行 main 隔离后端和 UI + +接下来这个调试策略挺关键,直接关系到上板时能不能快速定位问题。把真机后端接进 light-meter、跑到板子上,发现"屏幕上 lux 数字不动、或者全 0",这时候问题出在哪儿?是驱动没数据、是 read 短读、是 lux_coeff 不对、还是 UI 没刷新。一上来就跑整个 light-meter,这几个层面混在一起,很难分辨。 + +正确的做法是先写一个 5 行的独立 main,**只**调 `Ap3216cSensor` 的 `init` 和 `query_once`,把原始返回值打印出来。这样就把"后端能不能读到数据"和"UI 有没有正确消费"这两件事彻底隔离开了: + +```cpp +#include "ap3216c/ap3216c_sensor.h" +#include + +int main() { + Ap3216cSensor sensor; // 默认 /dev/ap3216c, lux_coeff=1.0 + if (!sensor.init(false)) { + std::cerr << "init 失败: 设备不可用\n"; + return 1; + } + for (int i = 0; i < 5; ++i) { + auto d = sensor.query_once(); + if (d) std::cout << "als_raw=" << d.value().luxury + << " ps=" << d.value().ps << '\n'; + else std::cerr << "query 失败\n"; + } +} +``` + +这个 main 不依赖 Qt、不依赖 UI,交叉编译后扔到板子上,直接看终端输出。如果它能稳定打印出 als_raw ~80、ps ~440 这种合理值(对照 driver/08 的实测数据),那后端就是通的,light-meter 跑不出数据就是 UI 那侧的问题。如果它就打印失败或全 0,那问题在驱动或后端这一层,根本不用去看 UI。 + +这种"先把可疑模块单独跑通,再往上集成"的隔离调试法,是嵌入式开发的命脉。我之前就吃过亏,UI 和后端一起跑、数据是 0,折腾半天发现是 coeff 没标对,跟 UI 一毛钱关系都没有。先隔离、再集成,能省下大量对着整个系统瞎猜的时间。 + +注意,这个独立 main 必须在板子上跑(因为它要 `open("/dev/ap3216c")`),在开发机上编译它,会因为 `USE_REAL_SENSOR` 那个 CMake 守卫报错。可以把它编进真机二进制、scp 到板子上跑。 + +## 上手:在板子上读真实 ir/als/ps + +这一章的上手必须有板子、且板子上已经加载了 `ap3216c.ko` 驱动(那是 driver/08 的产物)。先确认设备节点在: + +```bash +ls -l /dev/ap3216c +``` + +看到这个节点存在,权限允许当前用户读(不行就 root 跑,或者加 udev 规则)。然后跑上面那个独立 main,应该看到连续几行 als_raw 和 ps 的打印,als 在正常室内光下大概 80 上下,ps 在没人靠近时大概 440。 + +然后做两个物理验证。用手遮住 AP3216C,als_raw 应该明显下降,甚至掉到 0;把手指慢慢靠近、贴近传感器,ps 应该一路上升到 500 出头。这套 als↓/ps↑ 的耦合变化,driver/08 的 `06_build_and_test.md` 里专门讲过,它是物理量被正确翻译成数字的活证据。在板子上亲手摸到这套规律,就说明从驱动到 `/dev/ap3216c` 到 `Ap3216cSensor` 这条链路全程通了。 + +接着可以试第 08 章的 lux_coeff 标定,用手机照度计 App 读个参考 lux、对比这里的 als_raw,算出系数,跑 light-meter 本体看显示对不对得上。给板子拍张照不过分,这一步是整个项目第一次见到真实环境光数据。 + +## 这一章的坑 + +先说最常见的。驱动没加载,`/dev/ap3216c` 不存在,`init` 直接 `DeviceUnavailable`。先 `lsmod | grep ap3216c` 确认驱动模块在,再 `ls /dev/ap3216c` 确认节点在,这两条命令能挡掉一半的上板翻车。 + +节点存在但当前用户没权限读,这个也很经典。默认设备节点往往只 root 可读,普通用户跑就 open 失败。要么 `sudo` 跑,要么写条 udev 规则给你的用户组权限,后者是产品化时的正经做法。 + +把短读当"设备坏了"也是个坑。其实短读在这个驱动上不太会发生(驱动 `read` 一次性 `copy_to_user` 6 字节),但代码必须处理它,而且要记得"短读返回的字节可能少于请求的"是 `read` 的正常语义,不是 bug,是得防御的情况。 + +改了驱动的 `copy_to_user` 顺序、忘了同步改应用,这个上面那节讲过的契约错位,数值静默错乱、不报错,血压拉满的那种。驱动和应用任何一端改了字节布局,另一端必须同步,最好在共享头文件或文档里写死这份契约。 + +最后是 fd 泄漏。`init` 里 `force_reinit` 重开之前要先 `close` 旧的 fd,忘了就是每次 reinit 泄漏一个 fd,长期跑 fd 表撑爆。light-meter 这里写了 `::close(m_fd)`,没问题,自己写类似逻辑的时候记得带上这一步。 + +## 小结 + +走完这一章,真机后端的用户态这边就齐了:POSIX 的 `open`/`read`/`close` 加 fd 这个 IO 抽象,短读为什么必须检查,`{ir,als,ps}` 这份跨内核/用户态的二进制契约,还有 `std::expected` 把 POSIX 错误翻译成统一错误模型。这套东西拼起来,就是 Linux 字符设备用户态客户端的样子。 + +light-meter 的真机后端到这里全亮了,从 AP3216C 芯片到 I2C 到内核驱动到 `/dev/ap3216c` 到 `Ap3216cSensor`,数据这条路全程打通。下一章我们把这个真机二进制部署到板子上,配 linuxfb 和 tslib 让它在屏幕上跑起来,顺便用 NFS 搭一个"改一行代码、10 秒后板上见效"的开发循环。 + +## 继续学习 + + + ← 08 把阈值调成你的:按环境标定 + 10 上板部署 → + diff --git a/document/tutorial/project/light-meter/10_board_deploy.md b/document/tutorial/project/light-meter/10_board_deploy.md new file mode 100644 index 000000000..d7aa22416 --- /dev/null +++ b/document/tutorial/project/light-meter/10_board_deploy.md @@ -0,0 +1,168 @@ +--- +title: 上板部署 +--- + +# 上板部署:linuxfb 运行环境与 NFS 开发循环 + +::: info 本节你将学到 +- 怎么把 light-meter 交叉编译成能在板子上跑的 ARM 二进制 +- linuxfb 这套运行环境是怎么回事,`QT_QPA_PLATFORM` 那一组环境变量每个在干什么 +- 怎么用 evtest 动态发现触摸屏设备,别照抄 `event0` +- 一个 NFS 开发循环,桌面改代码、交叉编译、板子上 10 秒后见效,告别每次改一行就重烧镜像 +- 中文字体为什么会在板子上变方块,以及怎么解决 +::: + +::: tip 前置知识 · 硬前置 +- 第 07 到 09 章,真机二进制能编出来、能在板子上读到真实数据 +- **driver/08 和 buildroot/11 是硬前置**:板子上必须已经有可读的 `/dev/ap3216c`,而且 rootfs 必须是带 linuxfb 加 tslib 的 Qt6 rootfs(就是 buildroot/11 那篇 `--with-qt6` 编出来的) +::: + +## 这一章干的两件事 + +到上一章为止,light-meter 的真机二进制你已经能编出来、能在板子上读到真实 `{ir,als,ps}` 了。但说实话,那还只是一个 5 行 main、在串口终端里打印数字的状态,离"做完了"差得远。这一章要把那个完整的 light-meter GUI 推到板子的屏幕上,而且要跑得不憋屈。 + +具体两摊事要一起办。一摊是 Qt 在板子上的运行环境,linuxfb 把界面画到 LCD、tslib 读到正确的触摸坐标,这套不配好,程序能跑但屏上什么都没有。另一摊是 NFS 开发循环,搭好之后改一行代码、交叉编译完,板子上 10 秒就能看到新效果,不用再为了一行改动重烧整个镜像。两摊事的优先级我自己排下来,NFS 循环其实更想早一点装上,因为没它的话这一章后面每改一处 UI 都得烧镜像,人会废掉。 + +## 交叉编译 light-meter + +第 01 章我们在桌面编 light-meter 用的是主机编译器,编出来是 x86 二进制,板子跑不了。板子是 ARM,得用交叉编译。交叉编译的核心就是告诉 CMake 两件事:用哪个编译器(`arm-linux-gnueabihf-g++` 这类),还有 Qt6 库在哪个 sysroot 里。 + +CMake 管这件事的标准机制是工具链文件,一个 `.cmake` 文件,配置时用 `CMAKE_TOOLCHAIN_FILE` 指给它: + +```bash +cmake -B build-board \ + -DCMAKE_TOOLCHAIN_FILE=<你的 toolchain.cmake 路径> \ + -DCMAKE_PREFIX_PATH=<板子 sysroot 里的 Qt6 路径> \ + -DUSE_REAL_SENSOR=ON +cmake --build build-board +``` + +工具链文件里大致是这几行,指定目标系统和交叉编译器: + +```cmake +set(CMAKE_SYSTEM_NAME Linux) +set(CMAKE_SYSTEM_PROCESSOR arm) +set(CMAKE_C_COMPILER arm-linux-gnueabihf-gcc) +set(CMAKE_CXX_COMPILER arm-linux-gnueabihf-g++) +set(CMAKE_FIND_ROOT_PATH <板子 sysroot>) +``` + +那个 sysroot 和里面的 Qt6,来自 buildroot 的产物。buildroot/11 那篇讲了怎么用 `--with-qt6` 编出一个带 Qt6 6.9.1 的 rootfs,这个 rootfs 对应的 sysroot 里就有交叉编译版的 Qt6 库和头文件,`CMAKE_PREFIX_PATH` 指到那里,`find_package(Qt6)` 才能找到**板子那一版** Qt6 而不是你桌面的。 + +这里有个版本协调的点要单独拎出来,我自己在这卡过。桌面开发用的 Qt6 可能是 6.8,板子 rootfs 里 buildroot 编的是 6.9.1,交叉编译 light-meter 时,一定要让它链板子那版 sysroot 里的 Qt6,别误链桌面版的。否则编出来在板子上很可能起不来,Qt 的某些插件、ABI 在小版本间不一定兼容。养成个习惯:交叉编译永远显式指 sysroot 的 Qt6 路径,别让它顺手找到桌面的。 + +## linuxfb 运行环境:那一组环境变量 + +二进制有了,直接在板子上 `./light-meter` 大概率跑不起来,因为它不知道往哪个屏幕画、从哪读触摸。Qt 在板子上靠一组环境变量来定这些,就是 buildroot/11 那篇讲的 linuxfb 加 tslib 那套。rootfs 怎么编出 Qt6 不重述,链到 buildroot/11,这里只看应用侧要配什么。 + +板子上跑 light-meter 之前,export 这一组: + +```bash +export QT_QPA_PLATFORM=linuxfb:fb=/dev/fb0 +export QT_QPA_PLATFORM_PLUGIN_PATH=/usr/lib/qt6/plugins +export QT_QPA_FB_TSLIB=1 +export TSLIB_TSDEVICE=/dev/input/eventN # N 要你自己发现,见下一节 +export TSLIB_FBDEVICE=/dev/fb0 +export TSLIB_PLUGINDIR=/usr/lib/ts +export TSLIB_CALIBFILE=/etc/pointercal +``` + +逐个说。`QT_QPA_PLATFORM=linuxfb:fb=/dev/fb0` 告诉 Qt 用 linuxfb 这个平台插件,直接画到 framebuffer,不需要 X 或 Wayland,这正合适没有 GPU 的 i.MX6ULL,并且 framebuffer 设备是 `/dev/fb0`。`QT_QPA_PLATFORM_PLUGIN_PATH` 指向 Qt 平台插件目录,板子上通常是 `/usr/lib/qt6/plugins`,不对的话以你 rootfs 实际为准。`QT_QPA_FB_TSLIB=1` 是告诉 linuxfb 插件"触摸输入从 tslib 走,别自己去读 event 设备"。 + +下面那一组 `TSLIB_*` 是 tslib 触摸校准的标配。tslib 是一层触摸事件过滤和校准,Qt 不直接读原始触摸 event,而是从校准后的事件流读坐标。`TSLIB_TSDEVICE` 指向触摸屏的 input 事件设备,这个特别关键,下一节专门讲怎么发现它,千万别照抄 `event0`。`TSLIB_FBDEVICE` 是 framebuffer,和上面的 fb0 一致。`TSLIB_PLUGINDIR` 是 tslib 滤镜插件目录,`TSLIB_CALIBFILE` 是校准结果存放位置。 + +### 别照抄 event0:动态发现触摸设备 + +`TSLIB_TSDEVICE` 那个 `eventN`,N 是几,取决于你的触摸控制器在内核里注册的顺序,不同板子、不同外设组合下都不一样。正点原子这块屏的 Goodix 触摸挂在 i2c-1 上,上电之后它具体落在哪个 eventN 得自己查,buildroot/11 那篇也特意警告过"别照抄"。 + +查的办法是用 evtest,板子上跑: + +```bash +evtest +``` + +它会列出所有 `/dev/input/event*` 设备让你选,你逐个看,触摸屏那个会在你按屏时疯狂刷事件、名称里通常带 "Goodix" 或 "Touch"。或者更轻量的办法,`cat /proc/bus/input/devices`,看每个设备的 Name 和 Handlers,Handlers 里写着它对应 `eventN`。找到触摸屏对应的 N,填进 `TSLIB_TSDEVICE=/dev/input/eventN`。input 设备编号在嵌入式上是真不固定,所以这一步别想着抄一个数一劳永逸,得养成上来先查的习惯。 + +定好了设备,先校准一次再跑 light-meter: + +```bash +ts_calibrate # 屏上点五个点,结果写到 /etc/pointercal +ts_test # 可选,拖一个图标满屏跑,验证校准准不准 +``` + +校准完,`./light-meter` 应该就能在屏上画出来、触摸也能响应了。 + +## 中文字体方块:链到 buildroot/11 + +跑起来你可能会撞上第二个坑,界面上的中文全是方块,英文正常。这是因为板子的 rootfs 里没有中文字体。Qt 默认带的 DejaVu 字体只覆盖西文和基础符号,中文那些字形它没有,就画成方块。 + +这个问题的完整解法在 buildroot/11 那篇的"字体这一摊"那节,post-build 脚本会嗅探 rootfs 里有没有 `libQt6Core.so`,有就自动下 Noto CJK 中文字体进去。所以正常情况下,你用 buildroot/11 那套 `--with-qt6` 编出的 rootfs,中文字体是齐的,light-meter 的中文应该能正常显示。如果你用的是别的方式做的 rootfs、字体没齐,要么回去补 buildroot/11 那套字体逻辑,要么手工往 rootfs 的 `/usr/share/fonts/` 塞一个 Noto Sans CJK,fontconfig 启动时会自动扫到。这部分细节不重述,链到 buildroot/11。 + +## NFS 开发循环:改一行,10 秒见效 + +这是这一章真正要给自己装上的"武器"。如果每次改 light-meter 一行代码,都要重新 build rootfs、重新烧镜像到板子,一个迭代周期十几分钟,折腾两轮人就废了。NFS 开发循环能把这周期压到 10 秒。 + +思路是这样。开发机和板子在同一局域网里,开发机用 NFS 导出一个目录,板子把它挂载到本地,这个目录里放 light-meter 的交叉编译产物。我们在开发机上改代码、交叉编译,产物直接落进这个导出目录,板子上重新 `./light-meter` 就跑的是新版,整个过程不碰 rootfs、不烧镜像。 + +开发机上配导出,在 `/etc/exports` 加一行(路径按你实际改): + +``` +/srv/lightmeter-build <板子IP>(rw,sync,no_root_squash,no_subtree_check) +``` + +`exportfs -ra` 让它生效。把 light-meter 交叉编译的输出指到这个目录,`cmake --build` 之后二进制就在 `/srv/lightmeter-build/light-meter`。 + +板子上挂载(板子 IP 能 ping 通开发机之后): + +```bash +mount -t nfs <开发机IP>:/srv/lightmeter-build /mnt/dev +ls /mnt/dev/light-meter # 看到二进制 +``` + +之后开发循环就三步走:开发机上改代码、`cmake --build build-board`,板子上 `./mnt/dev/light-meter` 重跑。第二步如果是增量编译、只改了一个 `.cpp`,几秒就完事,板子上重跑也是秒级,合起来 10 秒级见效。这套循环是嵌入式 Linux 应用开发的标配,buildroot/practical 那边的 NFS 讲的是另一种用法(网络挂载整个 rootfs 切版本),这里用的是只共享应用产物目录的轻量版,两者不冲突,看你迭代的是 rootfs 还是应用,选合适的就行。 + +::: tip 顺带把环境变量固定下来 +上面那一大坨 `QT_QPA_PLATFORM`、`TSLIB_*` 环境变量,每次手 export 太累。写个小脚本 `run-lm.sh` 放板子上,里面 export 完所有变量再 `./light-meter`,以后跑一行 `./run-lm.sh` 就行。NFS 挂载也可以写进 `/etc/fstab` 或开机脚本,板子一启动就挂上。 +::: + +## 上手:改一处 UI 文本,板上 10 秒见 + +整套环境搭起来,做个最直观的验证。开发机上把 light-meter 里某处中文改一下,比如把告警态的"光线不足,建议开灯"改成"该开灯啦"。`mainwindow.cpp` 里改这一行,然后: + +```bash +# 开发机上 +cmake --build build-board # 增量编译,几秒 +``` + +板子上重新跑: + +```bash +./run-lm.sh # 重新启动 light-meter +``` + +10 秒内,板子屏幕上那个新文本"该开灯啦"就出来了,全程没动 rootfs、没烧镜像。然后用这套三态 UI 在板子上完整验一遍:折线随环境光实时动(真传感器数据)、lux 跌破阈值翻红告警、手靠近触发唤醒、无接近 10 秒息屏、触摸阈值滑杆能拖、中文不乱码。这一套都过了,light-meter 才算真在板子上活了,而不是只在桌面上活着。 + +## 这一章的坑 + +第一个坑,`TSLIB_TSDEVICE` 照抄 `event0`,结果触摸没反应。用 evtest 动态发现真实的 eventN,不同板子不一样。 + +第二个坑,交叉编译误链了桌面版 Qt6 而不是板子 sysroot 那版。`CMAKE_PREFIX_PATH` 一定指向板子 sysroot 的 Qt6,版本要对上板子 rootfs 的 6.9.1。编出来跑不起、报插件加载失败,十有八九是这个。 + +第三个坑,没跑 `ts_calibrate` 直接上,触摸点击位置是偏的。tslib 的校准数据没生成,坐标没校准,先 `ts_calibrate` 点五点。 + +第四个坑,NFS 挂载偶发 "stale NFS file handle"。一般是开发机那边导出目录有变动或 NFS 服务重启过,板子上 `umount` 再 `mount` 一次通常就好。长期跑可以把 mount 选项调稳。 + +第五个坑,中文方块,以为程序坏了。其实是字体,不是逻辑 bug,回 buildroot/11 那篇补 CJK 字体。 + +## 小结 + +交叉编译用工具链文件指 sysroot 的 Qt6,linuxfb 加 tslib 那一组环境变量让 Qt 画到 framebuffer 并读校准后的触摸,evtest 动态发现触摸设备别照抄编号。这几样凑齐,Qt 应用就能在 i.MX6ULL 板子上跑起来。但说真的,这一章最值钱的是那个 NFS 开发循环,改代码到板上见效压到 10 秒级,以后做任何板端应用,只要还在改应用而不是 rootfs,这套都直接复用。 + +light-meter 从桌面 Mock 到板子真机的完整闭环,到这里就闭环了。最后一章我们退一步,把这条全栈数据链路画成一张图,回顾这一路学到的造工程方法论,然后说说 light-meter 这套思路怎么衔接到更大的项目去。 + +## 继续学习 + + + ← 09 POSIX 字符设备客户端 + 11 收束:全栈数据流图与方法论回顾 → + diff --git a/document/tutorial/project/light-meter/11_wrap_up.md b/document/tutorial/project/light-meter/11_wrap_up.md new file mode 100644 index 000000000..1886c37d6 --- /dev/null +++ b/document/tutorial/project/light-meter/11_wrap_up.md @@ -0,0 +1,134 @@ +--- +title: 收束:全栈数据流图与方法论回顾 +--- + +# 收束:把全栈画成一张图,把方法论带走 + +::: info 本节你将学到 +- 把 light-meter 这条从光子到屏幕像素的完整数据链路画成一张图,每个环节你能说出对应哪一章 +- 回顾这一路踩到的那些工程判断,哪些是真值得记住的 +- light-meter 这套 Sensor 契约 + Mock + 真机的架构,怎么扩成多传感器,衔接到更大的项目 +::: + +::: tip 前置知识 +- 全系列第 01 到 10 章,这是收束章,不引入新代码 +::: + +## 画一张图:从光子到屏幕像素 + +走完前面十章,你现在手里有了一整个能在板子上跑的产品。这一章我们退一步,把它的数据链路完整画出来,每个环节标上对应哪一章。说实话,这种"退一步看全貌"的事我前面一直在忍,十章都在低头赶路,这会儿才有底气把链路端到端摊开。这张图你能在一张纸上徒手画出来,就说明 light-meter 这套东西真吃透了。反过来,某个箭头你讲不出来,那就回去把对应那章再看一眼。 + +``` + 物理世界 + │ 环境光光子 / 手指接近 + ▼ + ┌───────────────┐ + │ AP3216C 芯片 │ (硬件, Lite-On 三合一) + └───────────────┘ + │ I2C1 总线 + ▼ + ┌───────────────┐ + │ 内核驱动 │ driver/08 AP3216C I2C 驱动 + │ ap3216c.ko │ (copy_to_user {ir,als,ps}) + └───────────────┘ + │ 暴露为字符设备节点 + ▼ + /dev/ap3216c + │ POSIX open/read ← Ch.09 + ▼ + ┌───────────────┐ + │ Ap3216cSensor │ Ch.09 真机后端 + │ (用户态客户端) │ (als*lux_coeff → lux) + └───────────────┘ + │ 实现 Sensor 契约 ← Ch.03 + ┌───────────────┴───────────────┐ + │ unique_ptr │ + │ (Ch.07 一行 CMake 在此切换) │ + │ ▼ │ + │ ┌─────────────────┐ │ + │ │ MockedSensor │ Ch.04 桌面后端 + │ │ (桌面,无硬件) │ (正弦 lux + 二值 ps) + │ └─────────────────┘ │ + └───────────────┬─────────────────┘ + │ query_once() 拉数据 ← Ch.05 onSampleTick + ▼ + ┌───────────────┐ + │ MainWindow │ Ch.05 三态状态机 + 定时器 + │ (运行/告警/息屏) │ (processSample 分发数据) + └───────────────┘ + ┌───────┴───────┐ + ▼ ▼ + ┌──────────┐ ┌──────────────┐ + │ ChartView │ │ 数字卡/CSV/ │ + │ Ch.06 自绘 │ │ 息屏遮罩 │ + │ (真环形) │ │ │ + └──────────┘ └──────────────┘ + │ QPainter + ▼ + ┌──────────────┐ + │ linuxfb QPA │ Ch.10 上板部署 + │ /dev/fb0 │ (QT_QPA_PLATFORM) + └──────────────┘ + │ blit 像素到显存 + ▼ + LCD 屏幕 +``` + +从最上面的环境光光子打到 AP3216C 芯片,一路经过内核驱动、字符设备、用户态客户端、Sensor 抽象、状态机、自绘控件、linuxfb,最后变成屏幕上变化的像素,这条链路每一个环节你都能指认它对应哪一章、哪份代码。中间那个 `unique_ptr` 的分叉,就是第 07 章那"一行 CMake 开关"切换的地方,Mock 走左边、真机走右边,UI 那一侧完全一样。我们回头看,这整张图里真正难画的不是哪个节点,是节点之间的那根线。光知道"这里有个字符设备"没用,得知道它怎么跟用户态、跟 Sensor 抽象咬合上。 + +## 回头看这一路学到的 + +把图摊开后,顺着这条链路回顾一下,这一路哪些东西是值得记住的。下面这些不是 light-meter 这个产品特有的细节,换个项目它们还是成立,所以才值得专门拎出来念叨。 + +契约先行。第 03 章我们花一整章钉死 Sensor 抽象,UI 只跟基类指针打交道。这事当初写的时候觉得啰嗦,回报在第 07 章才显出来:翻一个 CMake 开关就能换后端。再往后想加新传感器,UI 一行不用改。先把接口钉死、再写任何实现,这套做法在有多后端、要测试的系统里基本是底层常识,只是很多人偷懒不这么做,真到要换实现的时候才补,代价大得多。 + +Mock-first。第 04 章那个假数据后端,让你在桌面把整条 UI 调到完美,不卡硬件、不烧镜像。说实话在硬件不到位、硬件贵、或者烧一次镜像要等十分钟的场景里,这种"先用假数据把上层调通"的做法就是救命稻草,嵌入式尤其。我们这趟没怎么被硬件阻塞,一半功劳在第 04 章那张 Mock 牌上。 + +编译期后端切换。第 07 章那个 `option` 加 `#ifdef` 的接缝,把"桌面开发态"和"板子部署态"用编译期开关隔开,host 和 target 各自最精简,运行时不背多余的依赖。同一套代码、多种部署形态(比如带模拟器的桌面版加嵌入式版)基本都可以套这个模式,看你怎么把接缝藏在编译期。 + +显式状态机。第 05 章那三个布尔加转移逻辑,看着土,但把运行、告警、息屏三态的转移条件写在了明面上,特别是 `resetIdleCountdown` 那个"定时器在跑就别重启"的细节,这种东西藏在隐式逻辑里调试时血压拉满。状态少的时候手搓清楚,状态多了再上框架。说实话这个取舍意识比死记 `QStateMachine` 的 API 有用得多,后者你忘了查文档就行,前者忘了你会写出一坨连自己都读不懂的 if-else。 + +无 GPU 自绘。第 06 章自己用 QPainter 画折线,不是因为 Qt Charts 跑不了,是因为对这个需求自绘更轻、更可控、更稳。库存在不等于该用,以及把高频刷新区域切成小控件,这两条在资源受限设备上反复会出现,记住省事。 + +驱动 ↔ 应用协议对齐。第 09 章那个 `{ir,als,ps}` 的顺序,是跨内核/用户态边界的二进制契约。两端必须共享同一份字节布局,这种意识你写对接自研驱动的应用、或者任何跨进程二进制接口时,都得刻在脑子里,字节顺序错一个就是 segfault 伺候。 + +按环境标定。第 08 章那个 `lux_coeff`,把传感器 raw 计数换成物理量,默认值只是占位,真值得按你那块板子所在的环境标。涉及物理量换算的产品,温湿度、气压、距离、电流,标定这一步谁也逃不掉。 + +最后还有两个横跨全程的 C++ 技巧,`std::expected` 的错误处理(第 02 章)、`unique_ptr` 配自定义 deleter 的轻量 pImpl(第 04 章)。这俩是现代 C++ 工程的高频件,你在 light-meter 里见过它们在真实代码里怎么落地,以后自己写 C++ 项目能直接抄走。 + +## 加第二颗传感器:契约层的可扩展性 + +light-meter 是一颗 AP3216C 单传感器,但你这套架构不是只能撑一颗。接下来问题来了,要加第二颗传感器(比如一颗温湿度 AHT10)得动哪些地方?我们顺手走一遍。你会发现,得益于契约先行,改动是局部的,不会牵一发动全身。 + +数据契约这一层有两个选择。要么给 `SensorData` 加温湿度两个字段,简单粗暴,但所有后端都得跟着填;要么把"传感器"这个概念再抽象一层,做成一个传感器注册表,每颗传感器独立一个契约,更灵活但要重构。light-meter 现在的规模,前者够用,等真的塞到四五颗再考虑后者不迟。 + +再往下是两个新后端。新增一个 Mock 后端,照着第 04 章的 `MockedSensor` 写个 `MockedAht10`,产假温湿度数据,桌面调试用。新增一个真机后端,照着第 09 章的 `Ap3216cSensor` 写个 `Aht10Sensor`,通过对应的字符设备节点(这里先验证一下,你得先有 AHT10 的驱动,那是 driver 系列另一个故事)读温湿度。它同样实现 Sensor 契约,接进来 UI 不用改。 + +UI 那一侧,ChartView 加一条温湿度折线(或者干脆新开一个显示区),状态机基本不动,因为它管的是 light-meter 自己的三态,温湿度只是多一路显示数据。 + +加一颗传感器的改动,集中在"加一个新后端 + UI 加一路显示"这两处。UI 的状态机骨架、Sensor 契约本身、自绘控件、部署这一整套基础设施,全都纹丝不动。真正的坑在后面:如果你想得太美,把这事儿当乘法做(每加一颗传感器就动一遍 UI 状态机),那架构就白搭了。契约先行的全部价值,就是让这件事变成加法而不是乘法。 + +## 下一站:PROJ-001 环境监测站 + +light-meter 这一套走到头,你已经掌握了 imx-forge 那个旗舰项目 PROJ-001 便携式环境监测站的大约八成技术底座。PROJ-001 在 `document/todo/projects/proj-001-env-monitor.md` 里有详细蓝图,它要做的事,本质就是 light-meter 的多路扩展再加上一层网络。 + +传感器层,PROJ-001 要把 AP3216C 这一课扩展到温湿度、气压、陀螺仪多颗传感器,每颗都走"驱动 + 字符设备 + Sensor 用户态客户端"这条你已经走通的路。这一路你刚在 light-meter 里练过一遍,PROJ-001 就是多练几遍,换个 I2C 地址、换份寄存器表而已。 + +应用层,Qt 的界面从单页折线扩展到多页面、多通道图表,Qt 的骨架还是你第 05、06 章学的那些:信号槽、QPainter、布局,只是控件更多、布局更密。 + +网络层是 PROJ-001 比 light-meter 多出来的部分,MQTT 把数据推到云端、Web 看板实时同步显示。这是 light-meter 整个系列没碰过的东西,你到了 PROJ-001 会新学一轮。 + +所以从 light-meter 到 PROJ-001,不是推倒重来,是"加几颗传感器后端、加几页 UI、加一层网络"。light-meter 这套 Sensor 契约加 Mock 加真机加自绘加部署的底座,PROJ-001 直接复用。这也是为什么我们花这么大力气把 light-meter 的方法论讲透——它不是个孤立的小摆件,它是通往旗舰项目的脚手架。 + +## 收尾 + +到这里,light-meter 这套教程就收工了。你从一个空的 `CMakeLists.txt` 开始,一路走到一个在 i.MX6ULL 板子上常驻运行、读真实环境光、会告警、会息屏、能导出 CSV 的产品,中间把 CMake、现代 C++、Qt、Mock 思想、驱动协议、部署标定都过了一遍。代码量不大,但每一处都是有讲究的,讲究的就是前面回顾的那套方法论。 + +完结撒花。给板子拍张照不过分,屏幕上亮着你自己造出来的 light-meter 界面。然后带着这一路踩出来的判断,去造下一个。 + +## 继续学习 + + + ← 10 上板部署 + PROJ-001 环境监测站 → + diff --git a/document/tutorial/project/light-meter/index.md b/document/tutorial/project/light-meter/index.md new file mode 100644 index 000000000..2bb8e9fdc --- /dev/null +++ b/document/tutorial/project/light-meter/index.md @@ -0,0 +1,129 @@ +--- +title: 照度护眼摆件 light-meter +--- + + + +## 前言:这一套教你造工程,不是讲产品 + +说实话,网上大多数嵌入式项目教程要么是一个干巴巴的功能清单,今天我们点亮一颗 LED,要么是一个跑不起来的成品堆砌,略去一万字、代码自己看。light-meter 这个系列不打算这么干。我们要做的,是把你从一份空的 `CMakeLists.txt` 一步步带到一个在 i.MX6ULL 板子上常驻运行、会按环境光告警、会按接近度唤醒和息屏、能导出 CSV 的真实产品,全程不跳步。 + +这一刀砍下去,你带走的不是"我又抄了一个照度计",而是一套能用到下一个项目、下 N 个项目的造工程方法论,这才是含金量所在,下一节专门讲。 + +关于硬件依赖只有一处要事先说明。AP3216C 的内核驱动不在本系列里重讲,它指给你本仓库的 [driver/08 AP3216C I2C 驱动](../../driver/08_i2c_ap3216c_driver/),那里手把手教你把 `/dev/ap3216c` 写出来,本系列从"用户空间怎么消费这个 `/dev/ap3216c`"接上。除此之外,C++23、Qt6、CMake、Mock、部署、标定,全在这套教程里讲够,不假设你先读过别的卷。 + +## 先看成品 + +废话不多说,这就是这套教程带你造出来的东西,完整跑起来是这样: + + + +桌面 Mock 阶段 Windows 和 Linux 双平台能跑,翻一个 CMake 开关接上 AP3216C,板子上就是视频里这样:折线随环境光实时起伏、跌破阈值翻红告警、手靠近唤醒、无接近 10 秒息屏。 + +## 这个产品是什么 + +一个常驻桌面或床头的照度护眼摆件。它有三种状态:运行态每 200ms 采样一次环境光,左侧大数字显示当前 lux,右侧一条 30 秒滚动的折线;告警态在 lux 跌破阈值时触发,国标 GB 50034 规定书桌阅读要 300 lux 以上,低于这个值左侧数字卡就翻红,提示"光线不足,建议开灯";息屏态是无接近 10 秒后全屏变黑、只留中央一个慢呼吸点,手靠近、按空格或者点屏幕就能瞬间唤醒。 + +它的两个 git commit 就是本系列的脊柱。你现在 `git log --oneline` 能看到,`0bb4252` 是桌面 Mock 阶段的全部,Windows 和 Linux 双平台都能跑、不接硬件;`be4fb4d` 是真机后端接入,翻一个 CMake 开关就去读你板子上那颗真实的 AP3216C。本系列的章节顺序,就是沿着这两刀走的。 + +## 目录速览 + +`examples/light-meter/` 的文件树,每个文件对应本系列某一章: + +``` +light-meter/ +├── CMakeLists.txt # C++23/Qt6 配置 + USE_REAL_SENSOR 开关(07 章拆) +├── main.cpp # 10 行 trivial 入口(工厂分支不在这,在 mainwindow.cpp) +├── sensor/ +│ ├── sensor.h # 抽象 Sensor 契约(03 章,字段名 luxury 是个拼写 wart,问就是手滑写错了懒得改,逃) +│ ├── mocked/ # Mock 后端 + custom-deleter pImpl(04 章) +│ └── ap3216c/ # 真机 POSIX 客户端(09 章) +├── mainwindow.{h,cpp} # 三态状态机 + 工厂分支 + 定时器 + CSV/OOM(05 章) +└── ui/ + ├── chart_view.{h,cpp} # 自绘折线 + 真环形缓冲(06 章) + └── breathing_overlay.{h,cpp} # 息屏遮罩 + 跨平台陷阱(06 章) +``` + +有一点要先给你提个醒,README.md 只覆盖 Mock 阶段,它明确写"本阶段为 Mock 不接真硬件",目录树里都省略了 `ap3216c/`。`USE_REAL_SENSOR` 这个开关的存在得从 `CMakeLists.txt` 里发现,这是 README 留给你的一个小坑,第 07 章会把它填上。 + +## 学习路径 + +第一幕是桌面 Mock,第 01 到 06 章,全程不需要板子。你在 Windows 或 Linux 笔记本上用一个假数据后端把整条软件链路调到完美,这一幕结束时你已经有一个会呼吸、会告警、能导出 CSV 的完整桌面应用,一行板子代码都没碰。 + +1. 01 装好 Qt6 与 C++23 工具链,把 `CMAKE_PREFIX_PATH` 钉死、确认编译器够新 +2. 02 C++23 加中文源码,亲手删 `/utf-8` 造一次乱码再修好,顺便学 `std::expected` +3. 03 Sensor 抽象契约,为什么先把接口钉死再写 UI +4. 04 MockedSensor,正弦 lux 加一套 custom-deleter 的轻量 pImpl +5. 05 三态 UI 加状态机加定时器编排,顺带把 CSV 导出和 OOM 防线讲了 +6. 06 自绘 ChartView 加息屏遮罩,为什么没有 GPU 就必须自己画 + +第二幕是接缝,第 07、08 两章,是整个系列的转折点。 + +7. 07 一行 CMake 开关切后端,以及为什么这一刀能砍这么深 +8. 08 把阈值调成你的,按你自己的板子和测试环境标定 lux 系数和几个阈值 + +第三幕是真机,第 09 到 11 章,从这里开始需要板子上已经跑着 AP3216C 的驱动。 + +9. 09 POSIX 字符设备客户端,`/dev/ap3216c` 加上 `{ir,als,ps}` 的协议对齐 +10. 10 上板部署,linuxfb 加 tslib 加 NFS 开发循环 +11. 11 收束,画一张全栈数据流图,把造工程的方法论回顾一遍 + +章节随写随上,可点击的导航见下面[章节目录](#章节目录)。 + +## 章节目录 + + + 装好 Qt6 与 C++23 工具链 + C++23 + 中文源码 + Sensor 抽象契约 + MockedSensor 与 custom-deleter pImpl + 三态 UI + 状态机 + 定时器编排 + 自绘 ChartView + 息屏遮罩 + THE 接缝:一行 CMake 切后端 + 把阈值调成你的:按环境标定与微调 + POSIX 字符设备客户端 + 上板部署 + 收束:全栈数据流图与方法论回顾 + + +::: tip 学习目标 +把"传感器抽象、mock 先行、编译期后端切换、显式状态机、无 GPU 自绘、驱动协议对齐、按环境标定"这一整套造工程的方法论,在 light-meter 这一个真实产品里走一遍。走完之后,这套招式你能直接搬到下一个嵌入式 Qt 项目。 +::: + +::: info 前置知识,自包含,只有一处外链 +本系列自包含,不要求你先读完 buildroot 或 practical。唯一的硬依赖是硬件驱动,driver/08 AP3216C I2C 驱动是第三幕第 09 到 11 章的前置,你的板子上得有能读 `{ir,als,ps}` 的 `/dev/ap3216c`。如果你只走第一、二幕,也就是桌面 Mock 加接缝机制,暂时不需要它。另外需要一点 C++ 基础,本仓库没有 C++ 教程卷,linux-basics 是纯 C,所以第 01、02 章会把 C++23 工具链和 `std::expected` 从零讲起,不会默认你已经会现代 C++。 +::: + +::: details 延伸阅读 +- [cppreference std::expected](https://en.cppreference.com/w/cpp/utility/expected) +- [PROJ-001 便携式环境监测站](../../../todo/projects/proj-001-env-monitor.md),light-meter 是它的光照单传感器切片,走完本系列你掌握 PROJ-001 的 Sensor 契约加 Qt 骨架约八成。 +- [D3 示例路线图](../../../todo/directions/d3-examples.md) +::: + +## 常见问题 + +### 为什么先做 Mock,不直接上板 + +三个理由。一是 不卡硬件,你可以在笔记本上把 UI 和状态机调到完美,不用每改一行就交叉编译加烧录。二是 双平台验证,Mock 在 Windows 和 Linux 都能跑,提前把跨平台的坑,比如 MSVC 的 `/utf-8`、`M_PI` 未定义,在桌面就踩掉。三是 契约先行,逼你先把 Sensor 抽象钉死,后端就变成可插拔的了。这一刀的回报在第 07 章兑现,翻一个 CMake 开关,UI 的调用点一行不改。 + +### 翻完 USE_REAL_SENSOR=ON,真机行为和 Mock 一样吗 + +真机我们跑通过、行为验过,放心。但要提醒一句,代码里的默认常量,`lux_coeff`、告警阈值、唤醒阈值,是按我们这块板子和测试环境调的,你的板子、你的镜头透光率、你房间的照度都和我们不一样。所以第 08 章是手把手教你怎么按自己的环境把这些值微调到位,这不是修坑,是任何涉及物理量换算的产品都要做的常规最后一步。threshold 滑杆在运行时就能拖,即时调告警线,`lux_coeff` 和几个编译期默认值则放在第 08 章标定。 + +### 我还没做 driver/08 驱动,能跟这套教程吗 + +能,但只能走到第二幕。第一幕第 01 到 06 章是纯桌面 Mock,不需要任何硬件,第二幕第 07、08 章的接缝机制和标定方法也能在桌面理解。只有第三幕第 09 到 11 章真正需要板子上已加载 `ap3216c.ko`、有可读的 `/dev/ap3216c`,那就先去跟 driver/08,回来接着走。 + +### 这套教程和 PROJ-001 环境监测站什么关系 + +light-meter 是 PROJ-001 的光照单传感器切片。PROJ-001 要加温湿度、气压、陀螺仪,加 MQTT 上云,加 Web 看板,但它的 Sensor 抽象层加 Qt UI 骨架加驱动桥接,就是你在这套教程里走一遍的那套。做完 light-meter,你离 PROJ-001 只差多加几个后端加一个网络层。 + +## 继续学习 + + + ← 应用项目 + 01 装好 Qt6 与 C++23 工具链 → + diff --git a/project.config.ts b/project.config.ts index 93af88905..56e7ab200 100644 --- a/project.config.ts +++ b/project.config.ts @@ -88,6 +88,12 @@ export default defineProject({ title: 'U-Boot 点亮 LCD', desc: '上电即见 —— Bootloader 阶段就把 7 寸屏幕点亮', }, + { + src: '/light-meter/mock-light.png', + href: '/tutorial/project/light-meter/', + title: 'light-meter 照度摆件', + desc: '从桌面 Mock 到板子真机 —— 一个嵌入式 Qt 工程的完整造法', + }, { src: '/linux7.png', href: '/tutorial/kernel/', diff --git a/site/.vitepress/config/index.ts b/site/.vitepress/config/index.ts index 6b5e8ca0f..da3345053 100644 --- a/site/.vitepress/config/index.ts +++ b/site/.vitepress/config/index.ts @@ -105,6 +105,12 @@ export default defineConfig({ compilerOptions: { isCustomElement: (tag: string) => tag.includes('-') || tag.includes('.'), }, + //