腿毛利小五郎 发表于 2025-10-9 00:19:31

基于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]
查看完整版本: 基于FireBeetle 2 ESP32-C5的DFrobot粉丝数提示器