跳转至

YAML 格式的菜单

菜单是设备的一组设置:目标温度、磁滞、风扇阈值。在 idryer-core 上,菜单用单个文件 menu.yaml 描述,所有其他的 — C++ 结构、保存到非易失性内存(NVS)和发布到门户网站 — 自动生成。

这是核心库的关键块之一。你不编写设置存储代码,也不为门户网站设计格式 — 你只在 YAML 中列出参数。

为什么需要菜单

在前面的步骤之后,设备读取传感器,但所有阈值都"硬编码"在代码中。菜单立即解决三个任务:

  • 存储:值在重启后保持(NVS);
  • 从门户网站管理:每个参数都成为小部件(滑块、开关);
  • 单一信息源:一个文件描述内存和界面。

它如何工作

单个文件 menu.yaml 在构建期间通过生成器:

menu.yaml → (构建 pio run) → src/menu/ 中的 C++ 文件 + NVS + 门户网站 JSON

带有 role: 字段的项对门户网站可见并显示为小部件。没有 role: 的项是私有的,仅用于设备内部逻辑。

不编辑生成的文件

文件 menu_state.*menu_bindings.*menu_ids.h 等是由生成器创建的。只编辑 menu.yaml 并重新构建 — 否则你的更改会被覆盖。

步骤 1. 复制模板

库中有一个菜单模板。将其复制到你的项目:

mkdir -p src/menu
cp path/to/idryer-core/menu/menu.template.yaml src/menu/menu.yaml

步骤 2. 在构建时连接生成

iDryer-Storage 项目复制钩子示例(可以按原样使用,不需要配置):

mkdir -p extra_scripts
cp path/to/iDryer-Storage/extra_scripts/pre_gen_menu.py extra_scripts/pre_gen_menu.py

然后在 platformio.ini 中添加到 [env:cabinet] 部分一行 -Isrc/menu(以便代码看到 #include <menu_state.h>)并通过 extra_scripts 连接钩子:

[env:cabinet]
; ... 第 4 章的 platform / board / lib_deps — 没有变化 ...

build_flags =
    -Isrc/menu                      ; ← 添加了:生成菜单的路径
    -DIDRYER_API_BASE='"https://portal.idryer.org/api"'
    -DMQTT_BROKER='"mqtt.idryer.org"'
    -DMQTT_PORT=8883
    -DMQTT_USE_TLS=1

extra_scripts =                     ; ← 添加了
    pre:extra_scripts/pre_gen_menu.py

钩子将自动通过 lib/idryer-core/menu/menu_gen.py 路径查找生成器,因此库应该通过 lib/(符号链接或副本)连接,如第 4 章所述。

步骤 3. 描述柜的参数

打开 src/menu/menu.yaml。模板已有带 children 数组的根项 root 和参数示例。删除示例(my_parammy_flagmy_mode_group)并在 children 中添加你的。最后两个项 — units_countlanguage — 保留:这是与门户网站的固定合约。

对于基本柜子,几个参数就足够了。

存储目标温度:

- id: target_temp
  type: value
  role: storage.target_temperature   # 在门户网站上创建小部件
  title: { ru: "ТЕМПЕРАТУРА", en: "TARGET TEMP" }
  unit:  { ru: "°C", en: "°C" }
  vtype: uint16
  min: 30
  max: 50
  step: 1
  bind: target_temp            # NVS 密钥(≤ 15 字符)
  persist: true
  scope: global
  default: 45

磁滞(温度可以低于目标多少度,然后加热再次打开):

- id: hysteresis
  type: value
  title: { ru: "ГИСТЕРЕЗИС", en: "HYSTERESIS" }
  unit:  { ru: "°C", en: "°C" }
  vtype: uint8
  min: 1
  max: 5
  step: 1
  bind: hysteresis
  persist: true
  scope: global
  default: 2

role: — 这是一个封闭的列表

role: 的值不能任意发明 — 它必须来自核心合约的 canonical_roles 列表。如果没有合适的角色,构建将停止并显示允许的列表。对于存储柜,适合 storage.* 系列的角色:storage.target_temperaturestorage.target_humiditystorage.startstorage.stop。完整列表在 menu.template.yaml 的顶部。没有 role: 的参数(如上面的磁滞)作为内部设置工作:存储在 NVS 中,但不输出到门户网站。

不能违反的限制:

  • bind — 不超过 15 个字符(NVS 密钥限制);
  • 不在 menu.yaml 中添加 widget: 字段 — 小部件类型由 role: 的合约确定。

检查模板中的 ignore_external_cmd 项

模板有 ignore_external_cmd 项,其 bind 为 19 个字符,超过 15 的限制。如果保留原样,生成将失败:bind 'ignore_external_cmd' ... 具有 19 个字符,限制为 15。要么删除这个项,要么将 bind 缩短到 ign_ext_cmd(如在真实产品中)。对于基本柜子,你可以简单地删除它。

步骤 4. 构建项目并检查生成

pio run -e cabinet

在构建期间,pre-hook 将自动放置依赖项(一次)并生成 C++ 菜单文件。如果 menu.yaml 未改变 — 生成被跳过(up-to-date)。

检查生成是否通过。在构建日志中,出现关于菜单生成的一行,在 src/menu/ 文件夹中 — 生成的文件:

src/menu/
├── menu.yaml          # 你的文件(源)
├── menu_state.h/.cpp  # 带有所有参数的菜单对象
├── menu_bindings.*    # 按 bind 的访问 + NVS 写入
├── menu_ids.h
└── menu_meta.h        # 及其他

如果构建失败,出现有关未知 role: 的消息 — 这意味着角色没有写在 canonical_roles 列表中。纠正它并重新构建。标记为 autogen 的文件不要手动编辑。

步骤 5. 在主文件中连接菜单

要使用菜单代码,在 src/main.cpp 中连接两个东西:

  1. 生成菜单的标题:

    #include <menu_state.h>      # 带有所有参数的菜单对象
    
  2. setup() 中加载默认值 — s_link.begin() 之前

    menu.initDefaults();         #  YAML 设置默认值
    

之后,参数可通过全局 menu 对象访问:

uint16_t target = menu.target_temp;   // 直接访问值

在下一步的加热逻辑中,你使用这些值。当用户在门户网站上更改参数时,核心自动应用新值并将其保存到 NVS。

本章后 src/main.cpp 的完整版本

相对于上一章,只添加了两行(标记为 // ← 第 6 章):菜单连接和 menu.initDefaults()

第 5 章结束后的 src/main.cpp
#include <iDryer.h>
#include <Wire.h>
#include <math.h>
#include "Sht31ClimateSensor.h"

static const iDryer::Config CFG = {
    .deviceType        = iDryer::DeviceType::Dryer,
    .unitsCount        = 1,
    .hasHeater         = true,
    .hasFan            = true,
    .hasAirTemp        = true,
    .hasAirHumidity    = true,
    .hasHeaterTemp     = true,
    .telemetryPeriodMs = 5000,
    .statusPeriodMs    = 10000,
    .hardwareVersion   = "1.0",
    .firmwareVersion   = "0.1.0",
    .model             = "DIY Storage Cabinet",
};
static iDryer::Link s_link(CFG);

static Sht31ClimateSensor s_climate(&Wire);
static bool               s_climateOk = false;

static const int   THERM_PIN  = 2;
static const float SERIES_R   = 4700.0f;
static const float NOMINAL_R  = 100000.0f;
static const float NOMINAL_T  = 25.0f;
static const float BETA       = 3950.0f;

static float readHeaterTempC() {
    int   raw = analogRead(THERM_PIN);
    float v   = (float)raw / 4095.0f;
    float r   = SERIES_R * (1.0f - v) / v;
    float tK  = 1.0f / (1.0f / (NOMINAL_T + 273.15f) + logf(r / NOMINAL_R) / BETA);
    return tK - 273.15f;
}

void setup() {
    Serial.begin(115200);
    Wire.begin(8, 9);
    s_climateOk = s_climate.begin();
    s_link.begin();
}

void loop() {
    s_link.loop();

    if (s_climateOk) {
        s_climate.tick(millis());
        SensorReading r = s_climate.get();
        if (r.ok) {
            s_link.telemetry.airTempC[0]       = r.temperature;
            s_link.telemetry.airHumidityPct[0] = r.humidity;
        }
    }
    s_link.telemetry.heaterTempC[0] = readHeaterTempC();
}
#include <iDryer.h>
#include <Wire.h>
#include <math.h>
#include "Sht31ClimateSensor.h"
#include <menu_state.h>           // ← 第 6 章

static const iDryer::Config CFG = {
    .deviceType        = iDryer::DeviceType::Dryer,
    .unitsCount        = 1,
    .hasHeater         = true,
    .hasFan            = true,
    .hasAirTemp        = true,
    .hasAirHumidity    = true,
    .hasHeaterTemp     = true,
    .telemetryPeriodMs = 5000,
    .statusPeriodMs    = 10000,
    .hardwareVersion   = "1.0",
    .firmwareVersion   = "0.1.0",
    .model             = "DIY Storage Cabinet",
};
static iDryer::Link s_link(CFG);

static Sht31ClimateSensor s_climate(&Wire);
static bool               s_climateOk = false;

static const int   THERM_PIN  = 2;
static const float SERIES_R   = 4700.0f;
static const float NOMINAL_R  = 100000.0f;
static const float NOMINAL_T  = 25.0f;
static const float BETA       = 3950.0f;

static float readHeaterTempC() {
    int   raw = analogRead(THERM_PIN);
    float v   = (float)raw / 4095.0f;
    float r   = SERIES_R * (1.0f - v) / v;
    float tK  = 1.0f / (1.0f / (NOMINAL_T + 273.15f) + logf(r / NOMINAL_R) / BETA);
    return tK - 273.15f;
}

void setup() {
    Serial.begin(115200);
    Wire.begin(8, 9);
    s_climateOk = s_climate.begin();
    menu.initDefaults();           // ← 第 6 章
    s_link.begin();
}

void loop() {
    s_link.loop();

    if (s_climateOk) {
        s_climate.tick(millis());
        SensorReading r = s_climate.get();
        if (r.ok) {
            s_link.telemetry.airTempC[0]       = r.temperature;
            s_link.telemetry.airHumidityPct[0] = r.humidity;
        }
    }
    s_link.telemetry.heaterTempC[0] = readHeaterTempC();
}

检查结果

刷入后:

  • 门户网站上的设备卡中出现目标温度设置;
  • 在门户网站上改变值保存并在重启后保持;
  • 内部参数(磁滞)在代码中通过 menu 可用。

接下来

设置已描述并存储。现在在加热控制中将它们与硬件连接:加热器保持目标温度,风扇按阈值打开。