基于FireBeetle 2 ESP32-C5的DFrobot粉丝数提示器
本帖最后由 腿毛利小五郎 于 2025-10-9 00:28 编辑https://www.bilibili.com/video/BV1j3xBzSEbe/?spm_id_from=333.1387.homepage.video_card.click
项目概览
看到很多都是显示B站粉丝的小摆件,一直没有找到有人去做显示本社区粉丝的产品,于是就做了一个基于 FireBeetle 2 ESP32-C5 的“DFrobot粉丝数提示器”。ESP32 通过 Wi‑Fi , MQTT Broker(原本是想在32上做的但是代码过于臃肿,还有bug,还是在服务器上跑吧)。订阅粉丝数主题。收到粉丝变化后,设备会在 OLED 上显示当前数值,同时用 WS2812 灯带做视觉提示:粉丝增加时播放可动的彩虹流动,粉丝减少时红灯闪烁。设备还能通过 MQTT 接收配置并把 Wi‑Fi 信息保存在本地,重启后继续用上次的设置。代码框架来自我的上一个工程,基于FireBeetle 2 ESP32-C5 智能环境监测灯光控制 DF创客社区
核心功能
[*]自动连 Wi‑Fi,并做 NTP 同步把时间显示到 OLED。
[*]订阅 MQTT 主题,实时接收粉丝数(JSON 格式),对比新旧数值触发灯光提示。
[*]粉丝增加:彩虹流动效果并持续一段时间。
[*]粉丝减少:红色闪烁提示并持续一段时间。
[*]支持通过 MQTT 下发配置(例如更新 Wi‑Fi SSID/密码),并持久化保存(同上一个帖子)。
[*]Wi‑Fi 和 MQTT 都有更稳健的重连与退避策略,减少短时网络抖动带来的问题(同上一个帖子)。
硬件与软件清单
[*]硬件:FireBeetle 2 ESP32-C5 、WS2812 RGB全彩灯带(7灯珠)-LED灯带-DFRobot创客商城、Gravity: I2C OLED-2864显示屏-显示屏-DFRobot创客商城
[*]服务端:树莓派上一台 MQTT Broker(例如 mosquitto),负责发布粉丝数与下发配置。
[*]软件:FireBeetle 2 ESP32-C5 上跑的 Arduino程序(Wi‑Fi、MQTT、OLED等),以及在树莓派上运行的发布程序(可用 Python、Node.js 等)。
运行流程
开机后设备先加载本地保存的 Wi‑Fi 信息,尝试连接路由器。连上网并同步好 NTP 时间后,开始连接 MQTT Broker。只在确认有有效 IP 和 Wi‑Fi 联通的情况下再去连 MQTT,可以避免很多无谓的重试。连上 Broker 之后订阅两个主题:一个接粉丝数据,一个接远程配置。
当收到粉丝数据时,先把消息解析成数字,和之前的数值比较:如果变多,开启“彩虹流动”并记下何时结束;如果变少,开启“红灯闪烁”。LED 动画在独立任务里跑,OLED 也在独立任务里每秒刷新屏幕并显示当前粉丝数、Wi‑Fi 名称和 NTP 时间。远程下发新 Wi‑Fi 信息时会写入本地存储并触发重连。
服务端代码和配置说明
如下是config.ini文件 其中仅需要配置自己的
查询的uid,和mqtt服务器地址端口账号密码。
app_id sign_md5 login_success_sign 不用管,我也不清楚为啥
phone = 18666666666
password = 社区的密码
app_id = 432809143856280
sign_md5 = your_sign_here
login_success_sign = your_login_sign
uid = 841942
login_interval_minutes = 120
refresh_interval_seconds = 10
host = 127.0.0.1
port = 1883
topic = dfrobot/fans
username = mqttuser
password = 123456 以下是获取DF粉丝,转发MQTT的程序
模拟登录请求,然后拿到token到另外一个地址去拿html响应,解析其中的粉丝。所有的配置都是从config拿的。
import re, time, json, traceback, configparser
import requests
import paho.mqtt.client as mqtt
from urllib.parse import urlparse, parse_qs
CONFIG_FILE = "config.ini"
HTML_DUMP_FILE = "last_response.html"
REQUEST_TIMEOUT = 15
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0",
"Accept": "*/*",
}
def debug_print(*a):
print(*a, flush=True)
def save_html(path, html):
try:
with open(path, "w", encoding="utf-8") as f:
f.write(html)
except Exception as e:
debug_print("save_html failed:", e)
def load_config():
cfg = configparser.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
return cfg
def login_api(session, phone, password, app_id, sign_md5):
timestamp = str(int(time.time() * 1000))
biz_content = json.dumps({"password": password, "phone": phone}, separators=(",", ":"))
data = {
"app_auth_token": "",
"app_id": app_id,
"biz_content": biz_content,
"sign_type": "md5",
"sign": sign_md5,
"timestamp": timestamp,
"version": "1"
}
r = session.post("https://api.dfrobot.com.cn/user/login", headers={
"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8",
"Origin":"https://auth.dfrobot.com.cn",
"Referer":"https://auth.dfrobot.com.cn/",
**DEFAULT_HEADERS
}, data=data, timeout=REQUEST_TIMEOUT)
try:
return r.json().get("data", {}).get("app_auth_token")
except Exception:
return None
def request_login_success(session, sign, app_token):
url = (
"https://api.dfrobot.com.cn/user/login/success?"
"back_url=https://mc.dfrobot.com.cn/ucenter.php?returnUrl=https://mc.dfrobot.com.cn/portal.php"
f"&sign={sign}"
)
headers = {
"Referer":"https://auth.dfrobot.com.cn/",
"Host":"api.dfrobot.com.cn",
**DEFAULT_HEADERS
}
if app_token:
headers["Authorization"] = f"Bearer {app_token}"
session.cookies.set("app_auth_token", app_token, domain="mc.dfrobot.com.cn", path="/")
r = session.get(url, headers=headers, timeout=REQUEST_TIMEOUT, allow_redirects=True)
save_html(HTML_DUMP_FILE, r.text or "")
return r
def extract_p_param_from_html(path):
try:
html = open(path, "r", encoding="utf-8").read()
except Exception:
return None
m = re.search(r"[?&]p=(+)", html)
return m.group(1) if m else None
def call_site_connect(session, p_value):
if not p_value:
return None
url = f"https://mc.dfrobot.com.cn/member.php?mod=logging&action=connect&p={p_value}"
r = session.get(url, headers={"Referer":"https://auth.dfrobot.com.cn", **DEFAULT_HEADERS}, timeout=REQUEST_TIMEOUT)
save_html(HTML_DUMP_FILE, r.text or "")
return r
def fetch_follower_page(session, uid):
url = f"https://mc.dfrobot.com.cn/home.php?mod=follow&do=follower&uid={uid}"
r = session.get(url, headers={"Referer":f"https://mc.dfrobot.com.cn/home.php?mod=space&uid={uid}", **DEFAULT_HEADERS}, timeout=REQUEST_TIMEOUT)
save_html(HTML_DUMP_FILE, r.text or "")
return r.text or ""
def parse_fans(html):
m = re.search(r"<b>\s*(\d+)\s*</b>\s*粉丝", html, re.S)
if m:
return int(m.group(1))
m2 = re.search(r"(\d+)\s*粉丝", html)
return int(m2.group(1)) if m2 else None
def publish_fans_count(mqtt_client, topic, fans):
payload = json.dumps({"fans": fans, "timestamp": int(time.time())})
mqtt_client.publish(topic, payload)
def login_and_prepare_session(cfg):
session = requests.Session()
session.headers.update(DEFAULT_HEADERS)
token = login_api(session, cfg["auth"]["phone"], cfg["auth"]["password"], cfg["auth"]["app_id"], cfg["auth"]["sign_md5"])
r_success = request_login_success(session, cfg["auth"]["login_success_sign"], token)
p_value = extract_p_param_from_html(HTML_DUMP_FILE)
if not p_value:
try:
final_url = r_success.url
parsed = parse_qs(urlparse(final_url).query)
if "p" in parsed:
p_value = parsed["p"]
except Exception:
pass
if p_value:
call_site_connect(session, p_value)
return session
def main():
cfg = load_config()
login_interval = int(cfg["timing"]["login_interval_minutes"]) * 60
refresh_interval = int(cfg["timing"]["refresh_interval_seconds"])
mqtt_client = mqtt.Client(protocol=mqtt.MQTTv311)
mqtt_user = cfg["mqtt"].get("username", "")
mqtt_pass = cfg["mqtt"].get("password", "")
if mqtt_user:
mqtt_client.username_pw_set(mqtt_user, mqtt_pass)
mqtt_client.connect(cfg["mqtt"]["host"], int(cfg["mqtt"]["port"]))
mqtt_topic = cfg["mqtt"]["topic"]
uid = cfg["target"]["uid"]
session = login_and_prepare_session(cfg)
last_login_time = time.time()
while True:
try:
now = time.time()
if now - last_login_time > login_interval:
session = login_and_prepare_session(cfg)
last_login_time = now
html = fetch_follower_page(session, uid)
if "请登录" in html or "未登录" in html:
debug_print("未登录,尝试重新登录")
session = login_and_prepare_session(cfg)
last_login_time = time.time()
continue
fans = parse_fans(html)
if fans is not None:
debug_print("粉丝数:", fans)
publish_fans_count(mqtt_client, mqtt_topic, fans)
else:
debug_print("未能解析粉丝数")
except Exception:
traceback.print_exc()
time.sleep(refresh_interval)
if __name__ == "__main__":
main()
设备端代码
MQTT订阅,解析显示:
#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <Preferences.h>
#include "ESP32_WS2812_Lib.h"
#include <U8g2lib.h>
#include <ArduinoJson.h>
// 硬件
#define LEDS_COUNT 7
#define LEDS_PIN 2
#define CHANNEL 0
#define SDA_PIN 9
#define SCL_PIN 10
// Wi‑Fi
const char* WIFI_SSID = "您的wifi";
const char* WIFI_PASSWORD = "您的wifi密码";
// MQTT
const char* MQTT_SERVER = "MQTT地址";
const int MQTT_PORT = MQTT端口;
const char* MQTT_USER = "mqtt用户名";
const char* MQTT_PASS = "mqtt密码";
const char* MQTT_TOPIC_FANS = "dfrobot/fans";
const char* MQTT_TOPIC_CONFIG = "devices/config";
// 行为参数
const unsigned long LIGHT_DURATION_MS = 5000UL; // 灯持续时间
const unsigned long BLINK_INTERVAL_MS= 500UL;// 红灯闪烁间隔
const unsigned long RAINBOW_STEP_MS = 80UL; // 彩虹步进间隔
// 全局对象
ESP32_WS2812 strip(LEDS_COUNT, LEDS_PIN, CHANNEL, TYPE_GRB);
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);
Preferences prefs;
WiFiClientnetClient;
PubSubClient mqtt(netClient);
// 粉丝业务
volatile int currentFans = -1;
volatile int lastFans = -1;
volatile unsigned long lightUntil = 0;
volatile int lightMode = 0; // 0=off,1=colorful,2=red blink
// 彩虹位置
volatile uint8_t rainbowPos = 0;
// 任务句柄
TaskHandle_t ledTaskHandle;
TaskHandle_t oledTaskHandle;
TaskHandle_t wifiTaskHandle;
TaskHandle_t mqttTaskHandle;
// 灯带控制
void clearStrip() {
for (int i = 0; i < strip.getLedCount(); i++) strip.setLedColorData(i, 0, 0, 0);
strip.show();
}
// 带偏移的彩虹显示
void showColorful(uint8_t offset) {
for (int i = 0; i < strip.getLedCount(); i++) {
int pos = ((i * 256 / strip.getLedCount()) + offset) & 0xFF;
strip.setLedColorData(i, strip.Wheel(pos));
}
strip.show();
}
void showRed(bool on) {
for (int i = 0; i < strip.getLedCount(); i++) strip.setLedColorData(i, on ? 255 : 0, 0, 0);
strip.show();
}
// Wi‑Fi 连接
void connectWiFi(const String& ssid, const String& pass) {
if (ssid.length() == 0) return;
WiFi.disconnect(true);
WiFi.mode(WIFI_MODE_STA);
Serial.printf("→ WiFi connecting: %s\n", ssid.c_str());
WiFi.begin(ssid.c_str(), pass.c_str());
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
delay(200);
Serial.print(".");
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\n→ WiFi IP: %s\n", WiFi.localIP().toString().c_str());
} else {
Serial.println("\n→ WiFi failed");
}
}
void mqttCallback(char* topic, byte* payload, unsigned int length) {
String t = String(topic);
if (t == String(MQTT_TOPIC_CONFIG)) {
String msg; msg.reserve(length);
for (unsigned int i = 0; i < length; i++) msg += (char)payload;
Serial.printf("← MQTT [%s]: %s\n", topic, msg.c_str());
StaticJsonDocument<256> doc;
auto err = deserializeJson(doc, msg);
if (err) {
Serial.println("devices/config JSON parse error");
return;
}
if (doc.containsKey("ssid") && doc.containsKey("pass")) {
String ssid = doc["ssid"].as<String>();
String pass = doc["pass"].as<String>();
prefs.putString("wifi_ssid", ssid);
prefs.putString("wifi_pass", pass);
Serial.printf("→ Saved WiFi: %s\n", ssid.c_str());
connectWiFi(ssid, pass);
}
return;
}
if (t == String(MQTT_TOPIC_FANS)) {
StaticJsonDocument<128> doc;
DeserializationError err = deserializeJson(doc, payload, length);
if (err) {
Serial.println("fans JSON parse error");
return;
}
if (!doc.containsKey("fans")) return;
int fans = doc["fans"].as<int>();
lastFans = currentFans;
currentFans = fans;
if (lastFans >= 0) {
if (fans > lastFans) {
lightMode = 1;
lightUntil = millis() + LIGHT_DURATION_MS;
Serial.printf("粉丝增加: %d -> %d\n", lastFans, currentFans);
} else if (fans < lastFans) {
lightMode = 2;
lightUntil = millis() + LIGHT_DURATION_MS;
Serial.printf("粉丝减少: %d -> %d\n", lastFans, currentFans);
}
} else {
Serial.printf("首次粉丝数: %d\n", currentFans);
}
return;
}
}
void mqttTask(void* pv) {
mqtt.setServer(MQTT_SERVER, MQTT_PORT);
mqtt.setCallback(mqttCallback);
while (true) {
if (WiFi.status() == WL_CONNECTED) {
while (!mqtt.connected()) {
Serial.print("→ MQTT connect... ");
String clientId = "ESP32Client-";
clientId += String((uint32_t)ESP.getEfuseMac(), HEX);
if (mqtt.connect(clientId.c_str(), MQTT_USER, MQTT_PASS)) {
Serial.println("OK");
mqtt.subscribe(MQTT_TOPIC_CONFIG);
mqtt.subscribe(MQTT_TOPIC_FANS);
Serial.printf("→ Subscribed to %s and %s\n", MQTT_TOPIC_CONFIG, MQTT_TOPIC_FANS);
} else {
int rc = mqtt.state();
Serial.printf("Failed rc=%d\n", rc);
vTaskDelay(pdMS_TO_TICKS(5000));
}
}
mqtt.loop();
}
vTaskDelay(pdMS_TO_TICKS(100));
}
}
// OLED 显示
void oledTask(void* pv) {
const TickType_t interval = pdMS_TO_TICKS(1000);
TickType_t nextWake = xTaskGetTickCount();
struct tm timeinfo;
while (true) {
bool hasTime = getLocalTime(&timeinfo);
char timeBuf; // "YYYY-MM-DD HH:MM:SS"
if (hasTime) {
snprintf(timeBuf, sizeof(timeBuf), "%04d-%02d-%02d %02d:%02d:%02d",
timeinfo.tm_year + 1900,
timeinfo.tm_mon + 1,
timeinfo.tm_mday,
timeinfo.tm_hour,
timeinfo.tm_min,
timeinfo.tm_sec);
} else {
strncpy(timeBuf, "Time: --:--:--", sizeof(timeBuf));
timeBuf = '\0';
}
u8g2.firstPage();
do {
u8g2.setFont(u8g2_font_7x14_tf);
u8g2.setCursor(5, 14);
u8g2.print("Fans Monitor");
u8g2.setFont(u8g2_font_6x12_tf);
u8g2.setCursor(0, 34);
if (currentFans < 0) u8g2.print("Current fans: --");
else {
char fansBuf;
snprintf(fansBuf, sizeof(fansBuf), "Current fans: %d", currentFans);
u8g2.print(fansBuf);
}
u8g2.setCursor(0, 50);
u8g2.printf("WiFi: %s", WiFi.SSID().c_str());
u8g2.setCursor(0, 62);
u8g2.print(timeBuf);
} while (u8g2.nextPage());
vTaskDelayUntil(&nextWake, interval);
}
}
// LED 控制任务
void ledTask(void* pv) {
const TickType_t interval = pdMS_TO_TICKS(50);
TickType_t nextWake = xTaskGetTickCount();
unsigned long lastBlink = 0;
unsigned long lastRainbowStep = 0;
bool blinkState = false;
while (true) {
unsigned long now = millis();
if (now < lightUntil) {
if (lightMode == 1) {
if (now - lastRainbowStep >= RAINBOW_STEP_MS) {
rainbowPos++; // 自动溢出
lastRainbowStep = now;
}
showColorful(rainbowPos);
} else if (lightMode == 2) {
if (now - lastBlink >= BLINK_INTERVAL_MS) {
blinkState = !blinkState;
lastBlink = now;
}
showRed(blinkState);
} else {
clearStrip();
}
} else {
if (lightMode != 0) {
lightMode = 0;
clearStrip();
}
}
vTaskDelayUntil(&nextWake, interval);
}
}
// Wi‑Fi 任务(保持连接与 NTP)
void wifiTask(void* pv) {
const TickType_t retry = pdMS_TO_TICKS(5000);
bool ntpDone = false;
String ssid = prefs.getString("wifi_ssid", String(WIFI_SSID));
String pass = prefs.getString("wifi_pass", String(WIFI_PASSWORD));
connectWiFi(ssid, pass);
while (true) {
if (WiFi.status() != WL_CONNECTED) {
Serial.println("→ Wi‑Fi lost, retry...");
connectWiFi(ssid, pass);
vTaskDelay(retry);
} else {
if (!ntpDone) {
configTime(8*3600, 0, "pool.ntp.org", "ntp.aliyun.com");
Serial.println("→ NTP sync");
ntpDone = true;
}
vTaskDelay(pdMS_TO_TICKS(10000));
}
}
}
// setup
void setup() {
Serial.begin(115200);
prefs.begin("cfg", false);
Wire.begin(SDA_PIN, SCL_PIN, 100000);
delay(20);
strip.begin();
strip.setBrightness(30);
clearStrip();
u8g2.begin();
String ssid = prefs.getString("wifi_ssid", String(WIFI_SSID));
String pass = prefs.getString("wifi_pass", String(WIFI_PASSWORD));
connectWiFi(ssid, pass);
mqtt.setServer(MQTT_SERVER, MQTT_PORT);
mqtt.setCallback(mqttCallback);
xTaskCreate(ledTask,"LED", 3072, NULL, 1, &ledTaskHandle);
xTaskCreate(oledTask, "OLED",3072, NULL, 1, &oledTaskHandle);
xTaskCreate(wifiTask, "WiFi",4096, NULL, 1, &wifiTaskHandle);
xTaskCreate(mqttTask, "MQTT",4096, NULL, 1, &mqttTaskHandle);
Serial.println("→ Setup complete");
}
void loop() {
delay(1000);
}
		页: 
[1]