| 本帖最后由 腿毛利小五郎 于 2025-10-9 00:28 编辑 
 
 
 项目概览
 看到很多都是显示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 都有更稳健的重连与退避策略,减少短时网络抖动带来的问题(同上一个帖子)。
 开机后设备先加载本地保存的 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 不用管,我也不清楚为啥
 
 以下是获取DF粉丝,转发MQTT的程序复制代码[auth]
phone = 18666666666
password = 社区的密码
app_id = 432809143856280
sign_md5 = your_sign_here
login_success_sign = your_login_sign
[target]
uid = 841942
[timing]
login_interval_minutes = 120
refresh_interval_seconds = 10
[mqtt]
host = 127.0.0.1
port = 1883
topic = dfrobot/fans
username = mqttuser
password = 123456
 模拟登录请求,然后拿到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=([A-Za-z0-9_\-%.]+)", 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"][0]
        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;
WiFiClient  netClient;
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[i];
    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[24]; // "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[sizeof(timeBuf)-1] = '\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[32];
        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);
}
 
 
 
 
 
 
 |