💡 划重点

  • ESP32 作为主控,通过 Wi‑Fi 定时抓取飞书日历,解析会议室忙闲状态
  • 使用单颗 WS2812 三色灯珠直观显示占用(红)、空闲(绿)、即将占用(黄)
  • 支持本地按键一键切换状态,应对临时会议或紧急占用
  • 全量数据通过 MQTT 上报,便于物业或行政系统集成
  • 硬件成本锁定在 65 元以内,单点部署无压力

需求洞察

目标用户:需要频繁使用小型会议室的知识工作者、行政管理人员、共享办公空间运营方。

真实痛点:纸质预约表易被忽略,线上日历看不到门口实况;会议室门口“有人但未被预订”的冲突频发;临时占用后忘记释放导致下一位预约者等待;行政人员无法实时掌握空间使用数据。

为什么值得做

方案设计

整体分四层:感知层、控制层、执行层、日志/联网层。

感知层

核心感知来自飞书日历 API,ESP32 每隔 60 秒通过 HTTPS GET 请求当前房间的日程列表。为避免频繁调用,引入本地缓存和忙闲推断逻辑。本地按键(GPIO 0)提供物理交互,短按在空闲与占用间切换,长按 3 秒进入“即将占用”状态(用于临时延长会议)。

控制层

ESP32 上运行 Arduino 框架,兼顾 Wi‑Fi 客户端、HTTP 请求、JSON 解析与 WS2812 驱动。主循环中维护状态机:IDLE、OCCUPIED、UPCOMING(15 分钟内即将开始)。状态机综合日历数据和按键输入决定最终颜色。为减少云端依赖,系统保留离线模式——无网络时以按键为主,并将脱机事件缓存在本地。

执行层

单颗 WS2812 灯珠布置在雾面亚克力散光罩内,通过三色明确传达会议室状态:

灯效通过 FastLED 库实现非阻塞动画,例如黄色呼吸灯每 2 秒完成一次渐亮渐灭,红色流水灯以 60 帧速度平滑滚动。

日志/联网层

设备启动后连接指定 Wi‑Fi,并注册到 MQTT Broker(测试可用 EMQX Cloud 免费版或本地 Mosquitto)。每次状态变化时,向主题 room/{room_id}/status 发布 JSON 消息,包含 occupiedupcomingtimestampsource(calendar / button)。同时订阅 room/{room_id}/override,允许远程管理系统强制设置状态(如物业紧急关闭会议室)。心跳包每 5 分钟上报一次,包含信号强度、堆栈余量、运行时间,便于集中监控。

物料清单

物料 数量 用途 估算单价(元)
ESP32‑WROOM‑32 开发板 1 主控 + Wi‑Fi 18
WS2812B 5050 灯珠 1 三色状态指示 1.5
轻触按键(6×6×5mm) 1 本地切换 0.5
10kΩ 贴片电阻 2 按键上拉、限流 0.2
1000µF/6.3V 电解电容 1 稳定 WS2812 供电 0.5
AMS1117‑3.3V 稳压模块 1 供电(可选,若开发板自带可省) 1.5
Micro‑USB 母座/供电线 1 取电 2
雾面亚克力外壳(定制雕刻) 1 散光及保护 15
杜邦线、排针、热缩管 若干 内部连线 3
合计 42.2

目标成本 65 元,实际物料总价不到 43 元,留有充足余量应对单价波动或替换更美观的外壳。

接线与结构

遵照建议接线并微调:WS2812 数据线接 GPIO 13,理由是该引脚在 ESP32 上为 RMT 外设默认通道之一,FastLED 可无冲突驱动;按键一端接 GPIO 0,另一端接地,启用内部上拉以省掉外部电阻。补充说明:

核心代码

以下为 Arduino 平台骨架,基于 ESP32 和 FastLED,已包含日历拉取、状态机、MQTT 上报框架。请自行替换 Wi‑Fi 凭证、飞书应用凭证及 MQTT 地址。


#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <FastLED.h>
#include <PubSubClient.h>

#define LED_PIN    13
#define BTN_PIN     0
#define NUM_LEDS    1
CRGB leds[NUM_LEDS];

const char* ssid = "yourSSID";
const char* password = "yourPASS";
const char* mqtt_server = "broker.emqx.io";
const char* room_id = "meeting_room_301";

WiFiClient espClient;
PubSubClient mqtt(espClient);

enum State { FREE, UPCOMING, OCCUPIED };
State currentState = FREE;
State lastPublished = FREE;
unsigned long lastCalendarCheck = 0;
bool lastBtn = HIGH;
unsigned long btnPressStart = 0;

void setup() {
  Serial.begin(115200);
  FastLED.addLeds<WS2812, LED_PIN, GRB>(leds, NUM_LEDS);
  FastLED.setBrightness(64);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  connectMQTT();
}

void loop() {
  if (!mqtt.connected()) connectMQTT();
  mqtt.loop();

  // 日历拉取
  if (millis() - lastCalendarCheck > 60000) {
    checkCalendar();
    lastCalendarCheck = millis();
  }

  // 按键处理
  bool btnState = digitalRead(BTN_PIN);
  if (lastBtn == HIGH && btnState == LOW) {
    btnPressStart = millis();
  } else if (lastBtn == LOW && btnState == HIGH) {
    unsigned long pressLen = millis() - btnPressStart;
    if (pressLen > 3000) {
      currentState = UPCOMING;
    } else if (pressLen > 50) {
      currentState = (currentState == OCCUPIED) ? FREE : OCCUPIED;
    }
    publishState("button");
  }
  lastBtn = btnState;

  // 灯效
  updateLEDs();
}

void checkCalendar() {
  HTTPClient http;
  http.begin("https://open.feishu.cn/open-apis/calendar/v4/calendars/primary/events?page_size=1");
  int code = http.GET();
  if (code == 200) {
    StaticJsonDocument<1024> doc;
    bool hasActive = false, hasUpcoming = false;
    // 解析 events,判断当前是否被占用或 15 分钟内即将开始
    // 此处为示意,实际需根据 start_time/end_time 计算
    // ...
    if (hasActive) currentState = OCCUPIED;
    else if (hasUpcoming) currentState = UPCOMING;
    else currentState = FREE;
    publishState("calendar");
  }
  http.end();
}

void updateLEDs() {
  switch (currentState) {
    case FREE:
      leds[0] = CRGB::Green;
      FastLED.show();
      break;
    case UPCOMING: {
      uint8_t val = (sin8(millis() / 4) + 128) >> 1;
      leds[0] = CHSV(42, 255, val);
      FastLED.show();
      break;
    }
    case OCCUPIED: {
      uint8_t pos = (millis() / 10) % 255;
      leds[0] = CHSV(0, 255, beatsin8(30, 50, 255));
      break;
    }
  }
}

void publishState(String source) {
  if (currentState == lastPublished) return;
  lastPublished = currentState;
  StaticJsonDocument<200> doc;
  doc["occupied"] = (currentState == OCCUPIED);
  doc["upcoming"] = (currentState == UPCOMING);
  doc["timestamp"] = millis();
  doc["source"] = source;
  char buf[200];
  serializeJson(doc, buf);
  mqtt.publish(("room/" + String(room_id) + "/status").c_str(), buf);
}

void mqttCallback(char* topic, byte* payload, unsigned int length) {
  // 接收远程 override 指令
}

void connectMQTT() {
  while (!mqtt.connected()) {
    delay(2000);
  }
  mqtt.subscribe(("room/" + String(room_id) + "/override").c_str());
}

完整工程可加入 OTA 更新、网页配网等功能,上述代码已覆盖核心业务流程。

调试步骤

  1. 上电前检查:用万用表确认 5V 与 GND 无短路,按键按下时 GPIO0 电压接近 0V,抬起时接近 3.3V。
  2. 串口监视:烧录后打开串口终端(115200 bps),观察 Wi‑Fi 连接与 MQTT 状态,连接失败会有打印提示。
  3. 灯色验证:初次启动默认为 FREE,灯珠应亮绿色。若灯珠不亮或颜色不对,检查 FastLED 芯片类型是否为 WS2812B、引脚定义是否匹配。
  4. 按键测试:短按按键,观察串口是否输出状态变化,灯色应由绿变红,再按变绿。长按 3 秒以上应显示黄色呼吸效果。
  5. 日历测试:在飞书日历中为指定房间创建一条进行中的事件,等待 60 秒后灯应变为红色流水;创建一个 15 分钟内开始的事件,空闲状态下应变为黄色。
  6. MQTT 检查:使用 MQTT 客户端订阅 room/meeting_room_301/status,每次状态变化应收到 JSON 消息;向 room/meeting_room_301/override 发送 {"occupied":true} 应强制红灯。
  7. 稳定性考核:连续运行 24 小时,通过串口观察堆栈和心跳消息是否正常,无重启或内存泄漏。

设计图

三色灯智能会议室状态结构flow
1
感知输入(飞书日历API、本地按键)
2
控制处理(ESP32状态机、JSON解析、MQTT)
3
执行反馈(WS2812三色灯效)
4
数据上报(MQTT Broker → 管理后台)