先选控制器支持的设备类型
本例Generic Switch设备类型0x000F,只启用Momentary+MomentaryRelease,当前位置0/1,提交InitialPress/ShortRelease。不提供长按/多击业务,也不做OnOff Light Switch Binding控制另一灯。
固定检查HA Core2026.10.2的event.py支持对应FeatureMap/CurrentPosition组合,映射为event实体的initial_press/short_release。它不是button.press服务或light实体;先确认实际控制器范围,再谈按钮自动化。没有运行HA或观察实体/事件。
参考:[1] 固定ESP-Matter Generic Switch端点[2] 固定Momentary/Release feature与事件wrapper[8] HA Core2026.10.2 Matter事件映射
把网络角色和供电接线补齐
使用C6-DevKitC-1-N8/WROOM-1 PCB天线,按官方v1.4(PW-2024-03-0595/0921及后续)电路范围核到手板。GPIO10/11各独立10kΩ上拉3V3,各SPST到GND;事件与恢复分开,不用BOOT9/RGB8或USB12/13/UART16/17。
仅USB-to-UART单线供电/日志。C6是原生FTD receiver-on终端,可能参与路由,但不是Border Router、不是电池省电例程。HA OS控制器加专用Thread BR;独立ZBT-2配置Thread/OTBR,不能同一棒同时做Zigbee/Thread,也不重刷家庭现有协调器。
参考:[5] 固定C6 OpenThread平台入口[6] 固定C6官方指南/修订范围[7] C6 v1.4原厂原理图第2页[9] HA Thread:首选网络与手机凭据[10] HA ZBT-2:专用Thread/OTBR角色
复用固定依赖,不下载第二份SDK
IDF5.5.5/ESP-Matter aeefd294…/CHIP9c2a6363…与R09相同。已有最小安装用tools/prepare_dependencies.py --verify;固定GN2255、8个Python隔离包、9公共/ESP32子模块保留许可。未执行全平台bootstrap/hostchip-tool/其他MCUSDK。
本机只审macOS arm64/Python3.12.13。下载README有完整exports;其他系统按固定官方路径另验。私有生成的空pigweed_environment.gni对应固定scripts/configure分支,源码库tracked文件未改。
export LAB_DEPS="/absolute/path/matter-1.5-tools"
python tools/prepare_dependencies.py --prefix "$LAB_DEPS" --verify
export ESP_MATTER_PATH="$LAB_DEPS/esp-matter"
export MATTER_SDK_PATH="$ESP_MATTER_PATH/connectedhomeip/connectedhomeip"
export PATH="$LAB_DEPS/tools/gn:$PATH"
export PYTHONPATH="$LAB_DEPS/python-overlay:$MATTER_SDK_PATH/scripts/py_matter_idl:${PYTHONPATH:-}"参考:[1] 固定ESP-Matter Generic Switch端点[4] 固定Generic Switch应用参考[5] 固定C6 OpenThread平台入口
独立C6配置,默认不广播
默认LAB_ENABLE_MATTER=n立即返回,不初始化NVS/BLE/Thread/Matter;另一个新build明确加入sdkconfig.lab才启用。C6原生无线电、FTD/RX-on、Wi-Fi STA/SoftAP关闭、BR/CLI/PM关闭,不自动生成Dataset;网络由Matter commissioning传入。
8MiB DIO80、无PSRAM,factory0x20000/0x600000、无OTA槽。按E09备份原Flash后再考虑实体初刷;不能复用S3 sdkconfig或把此app当旧OTA包。作者明确启用且修正初始化门禁的最终新目录构建实际Exit0,app1566096B;旧SDK成功镜像被独立故障反例判定存在初始化事件顺序缺陷,已保留并替代。首次PATH失败与第二次示例私有宏误用失败分开保留,最终使用公开结构字段。记录以最终build JSON与同次源/锁/ELF/map/原log为准,无线与恢复还未执行。
export LAB_PROJECT="/absolute/path/esp32-matter-thread-button"
export LAB_BUILD="/absolute/path/r10-enabled"
idf.py -C "$LAB_PROJECT" -B "$LAB_BUILD" -D IDF_TARGET=esp32c6 \
-D SDKCONFIG="$LAB_BUILD/sdkconfig" \
-D "SDKCONFIG_DEFAULTS=$LAB_PROJECT/sdkconfig.defaults;$LAB_PROJECT/sdkconfig.lab" build参考:[4] 固定Generic Switch应用参考[5] 固定C6 OpenThread平台入口[6] 固定C6官方指南/修订范围
完整实现:先位置,再事件
input_task是两个去抖器、四边沿队列和事件配对的唯一owner;每10ms读取64位单调时刻。TryLock忙继续采样;获得CHIP锁后,初始化位置0若失败就解锁/延迟并继续采样,保留四边沿队列但不消费;初始化成功后再更新CurrentPosition,成功才提交对应事件,不从ISR调用SDK。
固定SDK的事件wrapper返回ESP_OK但不是控制器送达ACK;应用不提供掉线历史按键重放。NVS初始化失败停止不自动擦除,任务创建失败可能留下无线栈活跃而无按键采样,均按原log排查。
// ---- main/main.cpp ----
// SPDX-License-Identifier: MIT
#include <inttypes.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_matter.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs_flash.h"
#include <platform/PlatformManager.h>
#include <setup_payload/OnboardingCodesUtil.h>
#include <platform/ESP32/OpenthreadLauncher.h>
#include "input_policy.h"
extern "C" bool lab_enabled(void);
using namespace esp_matter;
using namespace chip::app::Clusters;
static constexpr gpio_num_t EVENT_BUTTON = GPIO_NUM_10;
static constexpr gpio_num_t RESET_BUTTON = GPIO_NUM_11;
static const char *TAG = "r10";
static uint16_t switch_id;
static esp_err_t update_cb(attribute::callback_type_t, uint16_t, uint32_t, uint32_t,
esp_matter_attr_val_t *, void *) { return ESP_OK; }
static esp_err_t identify_cb(identification::callback_type_t, uint16_t, uint8_t, uint8_t, void *)
{
ESP_LOGI(TAG, "Identify requested (no output indicator fitted)");
return ESP_OK;
}
static void event_cb(const chip::DeviceLayer::ChipDeviceEvent *event, intptr_t)
{
ESP_LOGI(TAG, "Matter event=%u", static_cast<unsigned>(event->Type));
}
static esp_err_t set_position(bool down)
{
esp_matter_attr_val_t value = esp_matter_uint8(down ? 1 : 0);
return attribute::update(switch_id, Switch::Id, Switch::Attributes::CurrentPosition::Id, &value);
}
static void input_task(void *)
{
lab::Button event_button, reset_button;
lab::Edges edges;
lab::Dispatch dispatch;
bool initialized_position = false, reset_pending = false;
unsigned observed_overflows = 0;
bool codes_printed = false;
for (;;) {
const uint64_t now = static_cast<uint64_t>(esp_timer_get_time() / 1000);
const auto event_edge = event_button.sample(gpio_get_level(EVENT_BUTTON) == 0, now);
if (event_edge != lab::Edge::None) edges.push(event_edge == lab::Edge::Press);
if (reset_button.sample(gpio_get_level(RESET_BUTTON) == 0, now) == lab::Edge::Release &&
reset_button.last_hold_ms >= 8000) reset_pending = true;
if (edges.overflows != observed_overflows) {
observed_overflows = edges.overflows;
ESP_LOGW(TAG, "Four-edge backlog overflow=%u: discard backlog, resync position without event replay", observed_overflows);
}
auto &manager = chip::DeviceLayer::PlatformMgr();
if (manager.TryLockChipStack()) {
if (!codes_printed) {
PrintOnboardingCodes(chip::RendezvousInformationFlags(chip::RendezvousInformationFlag::kBLE));
codes_printed = true;
}
if (reset_pending) {
const esp_err_t result = esp_matter::factory_reset();
ESP_LOGW(TAG, "Maintenance button released after 8s: reset request=%s", esp_err_to_name(result));
manager.UnlockChipStack();
vTaskDelete(nullptr);
return;
}
if (!initialized_position) {
// Persisted position is not treated as a new physical gesture.
initialized_position = set_position(false) == ESP_OK;
dispatch.resync();
}
if (!initialized_position) {
manager.UnlockChipStack();
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
bool down = false;
if (edges.resync(down)) {
const esp_err_t result = set_position(down);
dispatch.resync();
ESP_LOGW(TAG, "Position-only resync=%u result=%s; no recovered event", down, esp_err_to_name(result));
}
// Bounded four-item drain. No SDK call occurs in a GPIO ISR.
while (edges.pop(down)) {
const esp_err_t result = set_position(down);
if (result != ESP_OK) {
dispatch.resync();
ESP_LOGE(TAG, "Position update failed=%s; suppress matching release event", esp_err_to_name(result));
continue;
}
const auto event = dispatch.next(down, true);
if (event == lab::SwitchEvent::InitialPress) {
cluster::switch_cluster::event::send_initial_press(switch_id, 1);
ESP_LOGI(TAG, "InitialPress submitted to SDK; not a controller delivery acknowledgement");
} else if (event == lab::SwitchEvent::ShortRelease) {
cluster::switch_cluster::event::send_short_release(switch_id, 1);
ESP_LOGI(TAG, "ShortRelease submitted to SDK; not replayed after disconnect");
}
}
manager.UnlockChipStack();
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
extern "C" void app_main(void)
{
if (!lab_enabled()) {
ESP_LOGW(TAG, "LAB_DISABLED: no NVS change, Thread/Matter start or BLE advertisement. Explicit sdkconfig.lab required.");
return;
}
const esp_err_t nvs = nvs_flash_init();
if (nvs != ESP_OK) {
ESP_LOGE(TAG, "NVS init=%s; preserve data, do not auto-erase", esp_err_to_name(nvs));
return;
}
gpio_config_t input = {};
input.pin_bit_mask = (1ULL << EVENT_BUTTON) | (1ULL << RESET_BUTTON);
input.mode = GPIO_MODE_INPUT; // Each SPST has its own external 10k pull-up.
ESP_ERROR_CHECK(gpio_config(&input));
node::config_t node_config;
node_t *node = node::create(&node_config, update_cb, identify_cb);
if (!node) { ESP_LOGE(TAG, "node create failed"); return; }
endpoint::generic_switch::config_t switch_config;
switch_config.switch_cluster.number_of_positions = 2;
switch_config.switch_cluster.current_position = 0;
switch_config.switch_cluster.feature_flags =
cluster::switch_cluster::feature::momentary_switch::get_id() |
cluster::switch_cluster::feature::momentary_switch_release::get_id();
endpoint_t *endpoint = endpoint::generic_switch::create(node, &switch_config, ENDPOINT_FLAG_NONE, nullptr);
if (!endpoint) { ESP_LOGE(TAG, "endpoint create failed"); return; }
switch_id = endpoint::get_id(endpoint);
auto *position = attribute::get(switch_id, Switch::Id, Switch::Attributes::CurrentPosition::Id);
if (!position || attribute::set_deferred_persistence(position) != ESP_OK) {
ESP_LOGE(TAG, "CurrentPosition deferred persistence unavailable"); return;
}
// Native C6 radio, not a UART RCP and not a Border Router.
esp_openthread_platform_config_t thread = {};
thread.radio_config.radio_mode = RADIO_MODE_NATIVE;
thread.host_config.host_connection_mode = HOST_CONNECTION_MODE_NONE;
thread.port_config.storage_partition_name = "nvs";
thread.port_config.netif_queue_size = 10;
thread.port_config.task_queue_size = 10;
const esp_err_t platform = set_openthread_platform_config(&thread);
if (platform != ESP_OK) {
ESP_LOGE(TAG, "Thread platform config=%s", esp_err_to_name(platform)); return;
}
ESP_LOGW(TAG, "Development/test credentials; GenericSwitch endpoint=%u, Thread receiver-on lab", switch_id);
const esp_err_t result = esp_matter::start(event_cb);
if (result != ESP_OK) { ESP_LOGE(TAG, "Matter start=%s", esp_err_to_name(result)); return; }
if (xTaskCreate(input_task, "r10_input", 4096, nullptr, 4, nullptr) != pdPASS)
ESP_LOGE(TAG, "Input task unavailable; Matter stack may remain active without button sampling");
}
参考:[1] 固定ESP-Matter Generic Switch端点[2] 固定Momentary/Release feature与事件wrapper[3] 固定属性更新与延后持久化[5] 固定C6 OpenThread平台入口
四条边沿溢出后只同步最新位置
共享策略30ms稳定门槛、初始按住忽略直到稳定释放,不承诺捕获采样/去抖以下毛刺。队列至多4条边沿;满时清积压,只记最新位置,下一次拿锁做位置同步而不补发旧事件,并清active press标志。
正常顺序只允许已提交press之后对应release;位置更新失败清配对并抑制相关事件,不能声称位置已同步。重同步失败也不补一个孤立release。边沿数量/SDK提交不等于最终无线事件次数,物理输入和控制器需分别验收。
// ---- main/input_policy.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
namespace lab {
enum class Edge { None, Press, Release };
// One owner calls sample() with monotonic milliseconds. Start held is ignored
// until a stable release. No ISR, no allocation, no SDK dependency.
class Button {
bool candidate_ = false, stable_ = false, armed_ = false, started_ = false;
uint64_t candidate_at_ = 0, press_at_ = 0;
public:
uint64_t last_hold_ms = 0;
Edge sample(bool down, uint64_t now) {
if (!started_) { started_ = true; candidate_ = down; candidate_at_ = now; return Edge::None; }
if (down != candidate_) { candidate_ = down; candidate_at_ = now; return Edge::None; }
if (now < candidate_at_ || now - candidate_at_ < 30) return Edge::None;
if (!armed_) {
stable_ = candidate_;
if (!stable_) armed_ = true;
return Edge::None;
}
if (stable_ == candidate_) return Edge::None;
stable_ = candidate_;
if (stable_) { press_at_ = now; return Edge::Press; }
last_hold_ms = now >= press_at_ ? now - press_at_ : 0;
return Edge::Release;
}
bool down() const { return armed_ && stable_; }
};
// At most four ordered position changes. On overflow drop the backlog and
// suppress events until the consumer resynchronizes the latest position.
class Edges {
bool values_[4] = {}; unsigned head_ = 0, count_ = 0;
bool resync_ = false, latest_ = false;
public:
unsigned overflows = 0;
void push(bool down) {
latest_ = down;
if (resync_) return;
if (count_ == 4) { head_ = count_ = 0; resync_ = true; ++overflows; return; }
values_[(head_ + count_) % 4] = down; ++count_;
}
bool resync(bool &position) {
if (!resync_) return false;
position = latest_; resync_ = false; return true;
}
bool pop(bool &position) {
if (!count_) return false;
position = values_[head_]; head_ = (head_ + 1) % 4; --count_; return true;
}
};
enum class SwitchEvent { None, InitialPress, ShortRelease };
class Dispatch {
bool active_ = false;
public:
void resync() { active_ = false; }
SwitchEvent next(bool down, bool position_ok) {
if (!position_ok) { active_ = false; return SwitchEvent::None; }
if (down) { active_ = true; return SwitchEvent::InitialPress; }
if (!active_) return SwitchEvent::None;
active_ = false; return SwitchEvent::ShortRelease;
}
};
}
// ---- main/lab_config.c ----
// SPDX-License-Identifier: MIT
#include <stdbool.h>
#include "sdkconfig.h"
bool lab_enabled(void) {
#ifdef CONFIG_LAB_ENABLE_MATTER
return true;
#else
return false;
#endif
}
参考:[2] 固定Momentary/Release feature与事件wrapper[3] 固定属性更新与延后持久化
先准备首选Thread网络与手机
HA OS的Thread integration应看见专用BR和首选网络,LAN保留IPv6/mDNS路径。按HA当前官方流程在完整Android Companion的Thread设置发送凭据到手机;网络名相近不等于同一个Dataset。Google Play Matter能力/Bluetooth/定位权限需满足官方条件。
手机BLE负责初始commissioning,不是HA主机蓝牙代替。C6既不创建BR也不自动组网;由手机把正确首选网络参数送入。Dataset含密钥,原log/配对码/fabric细节留私有目录,空CSV不要求写出密钥。
参考:[9] HA Thread:首选网络与手机凭据[10] HA ZBT-2:专用Thread/OTBR角色[11] HA Matter控制器与手机条件
配对按层验收,不把BLE成功当终点
明确启用后应用持栈锁打印本次开发onboarding码;打印不等于窗口打开、Thread关联或fabric完成。Companion添加新Matter设备,依本次码完成开发流程;控制器对test attestation的限制如实定位,不把开发身份伪装认证产品。
依次保存BLE发现→Dataset/Thread关联→BR IPv6可达→fabric完成→实际event实体。对GPIO10按下/释放,观察CurrentPosition与initial_press/short_release,再以实际实体按控制器当前UI建立自动化。本文没有捏造entity ID或实测事件次数。
参考:[5] 固定C6 OpenThread平台入口[8] HA Core2026.10.2 Matter事件映射[9] HA Thread:首选网络与手机凭据[11] HA Matter控制器与手机条件
重启、移除与明确恢复各有边界
固定CurrentPosition为NONVOLATILE,本例启用延后持久化,启动位置归零,不把旧持久down当新手势。没有省电承诺,也不保证瞬时掉电存储结果。
GPIO11独立稳定保持≥8秒再释放请求factory_reset;启动按住或事件按钮长按不触发恢复。固定SDK可能在局部擦除错误时仍安排重启,核实际log;不是物理安全擦除。控制器移除fabric、Thread Dataset、HA自动化不是同一份数据,逐层保存并重配。
参考:[1] 固定ESP-Matter Generic Switch端点[3] 固定属性更新与延后持久化[4] 固定Generic Switch应用参考[9] HA Thread:首选网络与手机凭据
适用主机检查与尚空的实板表
tests/host.cpp实际使用固件头,16组ASan/UBSan覆盖去抖/初始按住/64位时间、四队列顺序、满后最新位置、孤立release抑制、位置失败。没有执行CHIP/Thread/BLE、BR/HA或GPIO电气。独立复核发现初始位置失败与队列交错后,另新增tests/owner_body.py:完整实际input_task提取,加真实共享头,在8种SDK错误/锁忙/队列/64位时序夹具中核门禁;旧包同体反例为P1/R0,修后为P1/R1。这是新增作者回归,不是原包已有测试,也不是独立复核人的结论。
commissioning.csv/events.csv只有表头。板修订、BR/手机版本、Thread凭据匹配、实际事件、重启、移除/重配均待真实实验。先分BLE、关联、IPv6、fabric、实体定位,不靠反复整片擦除掩盖层次;原创代码MIT,外部许可保留。
clang++ -std=c++17 -Wall -Wextra -Werror -fsanitize=address,undefined \
-fno-omit-frame-pointer tests/host.cpp -o /tmp/r10-policy-host
/tmp/r10-policy-host参考:[1] 固定ESP-Matter Generic Switch端点[2] 固定Momentary/Release feature与事件wrapper[3] 固定属性更新与延后持久化[8] HA Core2026.10.2 Matter事件映射[9] HA Thread:首选网络与手机凭据[11] HA Matter控制器与手机条件
资料来源与版本说明
保留原计划ID/slug/M6与用途。R08以私有QEMU文件学习安全状态,原首次就地加密失败与文件替代路径分别留证;R09/R10完整配对路线未冒充无线或控制器实测。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。