Parts list
| Part | Qty | Approx. | Buy |
|---|---|---|---|
| Seeed XIAO ESP32C3 | 1 | ₹550 | Buy Seeed XIAO ESP32C3 |
| SHT40 temperature/humidity breakout | 1 | ₹450 | Buy SHT40 temperature/humidity breakout |
| TB6612FNG motor driver module | 1 | ₹150 | Buy TB6612FNG motor driver module |
| 5 V 40 mm or 60 mm DC fan (2-wire, under 0.3 A) | 1 | ₹120 | Buy 5 V 40 mm or 60 mm DC fan (2-wire, under 0.3 A) |
| 5 V 2 A USB adapter + USB-C cable | 1 | ₹200 | Buy 5 V 2 A USB adapter + USB-C cable |
| Jumper wires + small perfboard | 1 | ₹40 | Buy Jumper wires + small perfboard |
| Total | ₹1,510 |
Affiliate disclosure: “Buy” links may earn FlashReady a small commission at no extra cost to you. Prices are indicative.
Wiring
| XIAO ESP32C3 | Part · pin | Wire | Notes |
|---|---|---|---|
| — | TB6612FNG dual DC motor driver · A01 | — | To 5 V fan (+) (not drawn) |
| — | TB6612FNG dual DC motor driver · A02 | — | To 5 V fan (−) (not drawn) |
| D4 | SHT40 temperature and humidity sensor · SDA | blue | D4 = GPIO6, default I2C SDA |
| D5 | SHT40 temperature and humidity sensor · SCL | yellow | D5 = GPIO7, default I2C SCL |
| 3V3 | SHT40 temperature and humidity sensor · VCC | red | |
| GND | SHT40 temperature and humidity sensor · GND | black | |
| D2 | TB6612FNG dual DC motor driver · PWMA | orange | D2 = GPIO4 — 25 kHz PWM, fan speed |
| D1 | TB6612FNG dual DC motor driver · AIN1 | green | D1 = GPIO3, held HIGH (forward) |
| GND | TB6612FNG dual DC motor driver · AIN2 | black | Tied LOW: the fan only ever runs one way |
| D10 | TB6612FNG dual DC motor driver · STBY | purple | D10 = GPIO10 — LOW puts the driver in standby while the fan is off |
| 3V3 | TB6612FNG dual DC motor driver · VCC | red | Logic at 3.3 V so the control pins match the XIAO |
| 5V | TB6612FNG dual DC motor driver · VM | red | Motor supply = USB 5 V through the XIAO’s 5V pin (a 0.2 A fan is fine) |
| GND | TB6612FNG dual DC motor driver · GND | black |
Board quirks: The TB6612FNG is used as a fast, efficient PWM switch for the fan. With AIN1 HIGH and AIN2 on GND, A01 is + and A02 is −; a brushless fan only spins one way, so check the red lead goes to A01. PWM runs at 25 kHz so the fan doesn’t whine. Most small 2-wire fans stall below about a third of full speed, so the sketch never runs them below 35 % and gives a half-second full-speed kick when starting. Mount the SHT40 near the router’s vents, not in the fan’s air stream — otherwise the fan cools the sensor and switches itself off while the router is still hot. The fan runs on the USB 5 V from the XIAO’s 5V pin. Use a 2 A adapter; a laptop USB port may reset the XIAO when the fan starts.
Code
// Router / ONT cabinet cooling fan — Seeed XIAO ESP32C3 + SHT40 + TB6612FNG + 5 V fan.
// The fan stays off below FAN_ON_C, ramps from 35 % to 100 % between FAN_ON_C and FULL_C,
// and the XIAO sends one ntfy.sh alert if the cabinet passes HOT_C (it re-arms 5 C lower).
// SHT40: SDA D4 (GPIO6), SCL D5 (GPIO7). TB6612FNG: PWMA D2 (GPIO4), AIN1 D1 (GPIO3),
// AIN2 GND, STBY D10 (GPIO10), VCC 3V3, VM 5V (USB). Fan + to A01, fan - to A02.
#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <Adafruit_SHT4x.h>
#include "fr_settings.h"
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
const char* NTFY_TOPIC = "your-private-topic";
FrSetting SETTINGS[] = {{"wifi_ssid", &WIFI_SSID, true}, {"wifi_pass", &WIFI_PASS, true}, {"ntfy_topic", &NTFY_TOPIC, true}};
constexpr int PWMA = 4, AIN1 = 3, STBY = 10;
constexpr float FAN_ON_C = 35, FULL_C = 45, HOT_C = 50;
constexpr uint8_t MIN_DUTY = 35; // most small fans stall below about a third
Adafruit_SHT4x sht;
bool alerted = false, sensorOk = false;
uint8_t duty = 0;
void fan(uint8_t pct) {
duty = pct;
digitalWrite(STBY, pct ? HIGH : LOW); // standby when off: the driver draws almost nothing
ledcWrite(PWMA, pct * 255 / 100);
}
void push(const String& msg) {
if (!*WIFI_SSID || !*NTFY_TOPIC || WiFi.status() != WL_CONNECTED) {
Serial.println("Alert not sent - Wi-Fi or ntfy topic not set / not connected");
return;
}
HTTPClient http;
http.begin(String("http://ntfy.sh/") + NTFY_TOPIC);
http.addHeader("Title", "Router cabinet");
http.addHeader("Tags", "fire");
Serial.printf("ntfy -> %d\n", http.POST(msg));
http.end();
}
void setup() {
Serial.begin(115200);
frSettingsBegin(SETTINGS);
pinMode(AIN1, OUTPUT);
digitalWrite(AIN1, HIGH); // with AIN2 on GND: A01 = +, A02 = -
pinMode(STBY, OUTPUT);
ledcAttach(PWMA, 25000, 8); // 25 kHz: above hearing, fine for small 2-wire fans
fan(0);
Wire.begin(6, 7);
sensorOk = sht.begin();
if (!sensorOk) Serial.println("SHT40 not found at 0x44 - fan runs at 60 % as a safe default");
if (*WIFI_SSID) WiFi.begin(WIFI_SSID, WIFI_PASS);
else Serial.println("Alerts off - no Wi-Fi name set. Flash from flashready.in or type it into the code.");
Serial.println("temp_c,rh,fan_pct");
}
void loop() {
if (!sensorOk) {
fan(60);
delay(5000);
return;
}
sensors_event_t hum, temp;
sht.getEvent(&hum, &temp);
float t = temp.temperature;
uint8_t want;
if (t < FAN_ON_C - (duty ? 1.5 : 0)) want = 0; // 1.5 C hysteresis so it doesn't flicker on/off
else if (t >= FULL_C) want = 100;
else want = MIN_DUTY + (uint8_t)((t - FAN_ON_C) / (FULL_C - FAN_ON_C) * (100 - MIN_DUTY));
if (want && !duty) { // kick-start at full speed for half a second
fan(100);
delay(500);
}
fan(constrain(want, 0, 100));
Serial.printf("%.1f,%.0f,%u\n", t, hum.relative_humidity, duty);
if (t >= HOT_C && !alerted) {
push("Cabinet at " + String(t, 1) + " C with the fan at " + String(duty) + " %. Check ventilation.");
alerted = true;
} else if (t < HOT_C - 5) alerted = false;
delay(5000);
}// Router / ONT cabinet cooling fan — Seeed XIAO ESP32C3 + SHT40 + TB6612FNG + 5 V fan.
// The fan stays off below FAN_ON_C, ramps from 35 % to 100 % between FAN_ON_C and FULL_C,
// and the XIAO sends one ntfy.sh alert if the cabinet passes HOT_C (it re-arms 5 C lower).
// SHT40: SDA D4 (GPIO6), SCL D5 (GPIO7). TB6612FNG: PWMA D2 (GPIO4), AIN1 D1 (GPIO3),
// AIN2 GND, STBY D10 (GPIO10), VCC 3V3, VM 5V (USB). Fan + to A01, fan - to A02.
#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <Adafruit_SHT4x.h>
#include "fr_settings.h"
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
const char* NTFY_TOPIC = "your-private-topic";
FrSetting SETTINGS[] = {{"wifi_ssid", &WIFI_SSID, true}, {"wifi_pass", &WIFI_PASS, true}, {"ntfy_topic", &NTFY_TOPIC, true}};
constexpr int PWMA = 4, AIN1 = 3, STBY = 10;
constexpr float FAN_ON_C = 35, FULL_C = 45, HOT_C = 50;
constexpr uint8_t MIN_DUTY = 35; // most small fans stall below about a third
Adafruit_SHT4x sht;
bool alerted = false, sensorOk = false;
uint8_t duty = 0;
void fan(uint8_t pct) {
duty = pct;
digitalWrite(STBY, pct ? HIGH : LOW); // standby when off: the driver draws almost nothing
ledcWrite(PWMA, pct * 255 / 100);
}
void push(const String& msg) {
if (!*WIFI_SSID || !*NTFY_TOPIC || WiFi.status() != WL_CONNECTED) {
Serial.println("Alert not sent - Wi-Fi or ntfy topic not set / not connected");
return;
}
HTTPClient http;
http.begin(String("http://ntfy.sh/") + NTFY_TOPIC);
http.addHeader("Title", "Router cabinet");
http.addHeader("Tags", "fire");
Serial.printf("ntfy -> %d\n", http.POST(msg));
http.end();
}
void setup() {
Serial.begin(115200);
frSettingsBegin(SETTINGS);
pinMode(AIN1, OUTPUT);
digitalWrite(AIN1, HIGH); // with AIN2 on GND: A01 = +, A02 = -
pinMode(STBY, OUTPUT);
ledcAttach(PWMA, 25000, 8); // 25 kHz: above hearing, fine for small 2-wire fans
fan(0);
Wire.begin(6, 7);
sensorOk = sht.begin();
if (!sensorOk) Serial.println("SHT40 not found at 0x44 - fan runs at 60 % as a safe default");
if (*WIFI_SSID) WiFi.begin(WIFI_SSID, WIFI_PASS);
else Serial.println("Alerts off - no Wi-Fi name set. Flash from flashready.in or type it into the code.");
Serial.println("temp_c,rh,fan_pct");
}
void loop() {
if (!sensorOk) {
fan(60);
delay(5000);
return;
}
sensors_event_t hum, temp;
sht.getEvent(&hum, &temp);
float t = temp.temperature;
uint8_t want;
if (t < FAN_ON_C - (duty ? 1.5 : 0)) want = 0; // 1.5 C hysteresis so it doesn't flicker on/off
else if (t >= FULL_C) want = 100;
else want = MIN_DUTY + (uint8_t)((t - FAN_ON_C) / (FULL_C - FAN_ON_C) * (100 - MIN_DUTY));
if (want && !duty) { // kick-start at full speed for half a second
fan(100);
delay(500);
}
fan(constrain(want, 0, 100));
Serial.printf("%.1f,%.0f,%u\n", t, hum.relative_humidity, duty);
if (t >= HOT_C && !alerted) {
push("Cabinet at " + String(t, 1) + " C with the fan at " + String(duty) + " %. Check ventilation.");
alerted = true;
} else if (t < HOT_C - 5) alerted = false;
delay(5000);
}Arduino IDE: keep fr_settings.h in the same folder as the .ino (tab menu → New Tab, same name). Flashing from this page doesn’t need it.
// fr_settings.h — FlashReady on-board settings (Wi-Fi name, phone number, ...) over USB.
//
// Why: the pre-built file on flashready.in is the same for everyone, so your own settings
// can't be inside it. Instead the board asks for them over USB serial and keeps them in
// its EEPROM. If you use Arduino IDE you can still type them into the constants in the
// sketch — this file then changes nothing except a 2-second pause at start-up.
//
// The protocol, in plain words (one text line each way, ending in \n, at 115200 baud):
// 1. Right after Serial.begin(115200) the board prints
// FR?SETTINGS v1 keys=wifi_ssid,wifi_pass missing=wifi_pass
// "missing" lists the required settings that still have no real value.
// 2. If nothing is missing it listens for 2 seconds and then carries on. If something is
// missing it keeps listening, printing the line again every 2 seconds, for up to
// 30 seconds; then it carries on anyway and the sketch skips whatever needs the
// missing value (an alarm still sounds, it just can't send the SMS).
// 3. The flasher answers FR!SET wifi_ssid=My%20Home&wifi_pass=secret
// (values URL-encoded). The board stores them and answers FR!OK, then carries on with
// the new values. Errors: FR!ERR unknown <key>, FR!ERR long <key> (nothing is stored).
// A key that is left out keeps its stored value; "key=" (empty) removes it.
// "FR!SET" with nothing after it means "carry on now": FR!OK, nothing is stored.
// 4. FR!CLEAR wipes everything that was stored, answers FR!OK and keeps listening.
// Other lines are ignored.
//
// A stored value wins over the constant in the sketch. A constant that is still a
// placeholder ("YOUR_SSID", "your-wifi", "+91XXXXXXXXXX") or empty counts as not set.
//
// EEPROM block at FR_SETTINGS_ADDR: 'F' 'R' 0x01, length (uint16, low byte first), then
// "key=value\n" lines. Uses FR_SETTINGS_SIZE bytes; move FR_SETTINGS_ADDR with a #define
// before the #include if your sketch uses EEPROM for something else.
//
// Use in a sketch:
// const char* WIFI_SSID = "YOUR_SSID";
// FrSetting SETTINGS[] = { {"wifi_ssid", &WIFI_SSID, true} }; // key, constant, required
// void setup() { Serial.begin(115200); frSettingsBegin(SETTINGS); ... WiFi.begin(WIFI_SSID, ...); }
// After frSettingsBegin() each constant points at the value to use ("" if none is set).
#pragma once
#include <Arduino.h>
#include <EEPROM.h>
#include <string.h>
#ifndef FR_SETTINGS_ADDR
#define FR_SETTINGS_ADDR 0
#endif
#ifndef FR_SETTINGS_SIZE
#if defined(__AVR__)
#define FR_SETTINGS_SIZE 96 // 2 KB of RAM on an Uno: a phone number fits easily
#else
#define FR_SETTINGS_SIZE 512
#endif
#endif
#ifndef FR_LINE_MAX
#if defined(__AVR__)
#define FR_LINE_MAX 128
#else
#define FR_LINE_MAX 1024
#endif
#endif
#define FR_MAX_KEYS 8
#define FR_WAIT_MAX_MS 30000UL // never wait longer than this for missing settings
#define FR_DATA_MAX (FR_SETTINGS_SIZE - 5)
struct FrSetting {
const char* key; // [a-z0-9_], as in settings.json
const char** value; // the sketch's constant; replaced by the value to use
bool required;
};
// Stored settings in RAM as "key=value\0key=value\0" (only non-empty values).
static char frData[FR_DATA_MAX + 1];
static uint16_t frLen = 0;
static inline void frEepromBegin() {
#if defined(ESP32) || defined(ESP8266)
static bool started = false;
if (!started) EEPROM.begin(FR_SETTINGS_ADDR + FR_SETTINGS_SIZE);
started = true;
#endif
}
static inline void frLoad() {
frEepromBegin();
frLen = 0;
frData[0] = 0;
if (EEPROM.read(FR_SETTINGS_ADDR) != 'F' || EEPROM.read(FR_SETTINGS_ADDR + 1) != 'R' || EEPROM.read(FR_SETTINGS_ADDR + 2) != 1) return;
uint16_t n = EEPROM.read(FR_SETTINGS_ADDR + 3) | (EEPROM.read(FR_SETTINGS_ADDR + 4) << 8);
if (n > FR_DATA_MAX) return;
for (uint16_t i = 0; i < n; i++) {
char c = EEPROM.read(FR_SETTINGS_ADDR + 5 + i);
frData[i] = c == '\n' ? '\0' : c;
}
frData[n] = 0;
frLen = n;
}
static inline void frPut(int addr, uint8_t b) {
#if defined(__AVR__)
EEPROM.update(addr, b);
#else
EEPROM.write(addr, b);
#endif
}
static inline void frSave() {
frEepromBegin();
frPut(FR_SETTINGS_ADDR, 'F');
frPut(FR_SETTINGS_ADDR + 1, 'R');
frPut(FR_SETTINGS_ADDR + 2, 1);
frPut(FR_SETTINGS_ADDR + 3, frLen & 0xFF);
frPut(FR_SETTINGS_ADDR + 4, frLen >> 8);
for (uint16_t i = 0; i < frLen; i++) frPut(FR_SETTINGS_ADDR + 5 + i, frData[i] ? frData[i] : '\n');
#if defined(ESP32) || defined(ESP8266)
EEPROM.commit();
#endif
}
// The stored value for key, or nullptr.
static inline const char* frStored(const char* key) {
size_t kl = strlen(key);
for (uint16_t i = 0; i < frLen; i += strlen(frData + i) + 1) {
if (strncmp(frData + i, key, kl) == 0 && frData[i + kl] == '=') return frData + i + kl + 1;
}
return nullptr;
}
// Empty, "your_…" / "YOUR-…" / "your …", or a "+91XXXX…" phone number.
static inline bool frIsPlaceholder(const char* v) {
if (!v || !*v) return true;
if (strncasecmp(v, "your", 4) == 0 && (v[4] == '_' || v[4] == '-' || v[4] == ' ')) return true;
return v[0] == '+' && strstr(v, "XXXX") != nullptr;
}
// The stored value for key, else the fallback constant; "" if that is a placeholder.
static inline const char* frSettingsGet(const char* key, const char* fallback) {
const char* v = frStored(key);
if (!v) v = fallback;
return frIsPlaceholder(v) ? "" : v;
}
static inline bool frIsMissing(const FrSetting& s) {
return s.required && !*frSettingsGet(s.key, *s.value);
}
static inline void frRequest(FrSetting* s, uint8_t n) {
Serial.print(F("FR?SETTINGS v1 keys="));
for (uint8_t i = 0; i < n; i++) {
if (i) Serial.print(',');
Serial.print(s[i].key);
}
Serial.print(F(" missing="));
bool first = true;
for (uint8_t i = 0; i < n; i++) {
if (!frIsMissing(s[i])) continue;
if (!first) Serial.print(',');
Serial.print(s[i].key);
first = false;
}
Serial.print('\n');
}
static inline void frReply(const __FlashStringHelper* what, const char* key) {
Serial.print(what);
Serial.print(key);
Serial.print('\n');
}
static inline int8_t frHex(char c) {
if (c >= '0' && c <= '9') return c - '0';
c |= 0x20;
return (c >= 'a' && c <= 'f') ? c - 'a' + 10 : -1;
}
// Percent-decodes in place. Drops NUL, CR and LF: they can't be stored.
static inline void frDecode(char* s) {
char* o = s;
for (; *s; s++) {
char c = *s;
if (c == '%' && frHex(s[1]) >= 0 && frHex(s[2]) >= 0) {
c = (char)(frHex(s[1]) << 4 | frHex(s[2]));
s += 2;
}
if (c != '\0' && c != '\n' && c != '\r') *o++ = c;
}
*o = 0;
}
// "k1=v1&k2=v2" (cut short if the line overflowed). Saves and returns true, or replies with an error.
static inline bool frSet(FrSetting* s, uint8_t n, char* p, bool overflow) {
const char* upd[FR_MAX_KEYS] = {};
while (*p) {
char* amp = strchr(p, '&');
bool last = !amp;
if (amp) *amp = 0;
char* eq = strchr(p, '=');
if (eq) *eq = 0;
if (*p) {
if (last && overflow) {
frReply(F("FR!ERR long "), p);
return false;
}
uint8_t i = 0;
while (i < n && strcmp(s[i].key, p) != 0) i++;
if (i == n) {
frReply(F("FR!ERR unknown "), p);
return false;
}
char* v = eq ? eq + 1 : p + strlen(p); // "key" alone = "key="
frDecode(v);
upd[i] = v;
}
if (last) break;
p = amp + 1;
}
// Build the new block: for each key, the new value or the one already stored.
char next[FR_DATA_MAX + 1];
uint16_t len = 0;
for (uint8_t i = 0; i < n; i++) {
const char* v = upd[i] ? upd[i] : frStored(s[i].key);
if (!v || !*v) continue;
size_t kl = strlen(s[i].key), vl = strlen(v);
if (len + kl + vl + 2 > FR_DATA_MAX) {
frReply(F("FR!ERR long "), s[i].key);
return false;
}
memcpy(next + len, s[i].key, kl);
next[len + kl] = '=';
memcpy(next + len + kl + 1, v, vl + 1);
len += kl + vl + 2;
}
memcpy(frData, next, len);
frData[len] = 0;
frLen = len;
frSave();
return true;
}
// Handles one received line. Returns true once settings were saved (boot carries on).
static inline bool frCommand(FrSetting* s, uint8_t n, char* line, bool overflow) {
if (strcmp(line, "FR!CLEAR") == 0) {
frLen = 0;
frData[0] = 0;
frSave();
Serial.print(F("FR!OK\n"));
return false;
}
if (strncmp(line, "FR!SET", 6) != 0 || (line[6] != ' ' && line[6] != 0)) return false; // not for us
char* p = line + 6;
while (*p == ' ') p++;
if (*p && !frSet(s, n, p, overflow)) return false; // empty FR!SET = carry on, store nothing
Serial.print(F("FR!OK\n"));
return true;
}
// Call right after Serial.begin(115200). Prints the request, waits as described at the top
// (30 s at most), then points every constant in the list at the value to use ("" = not set).
static inline void frSettingsBegin(FrSetting* s, uint8_t n) {
frLoad();
char line[FR_LINE_MAX];
uint16_t len = 0;
bool overflow = false;
frRequest(s, n);
unsigned long start = millis(), quietSince = start;
while (millis() - start < FR_WAIT_MAX_MS) {
int c = Serial.read();
if (c >= 0) {
quietSince = millis();
if (c == '\r') continue;
if (c != '\n') {
if (len < FR_LINE_MAX - 1) line[len++] = (char)c;
else overflow = true;
continue;
}
line[len] = 0;
bool saved = frCommand(s, n, line, overflow);
len = 0;
overflow = false;
if (saved) break;
continue;
}
if (millis() - quietSince < 2000) {
delay(1); // also keeps the ESP8266 watchdog fed
continue;
}
len = 0; // drop half a line
overflow = false;
bool missing = false;
for (uint8_t i = 0; i < n; i++) missing |= frIsMissing(s[i]);
if (!missing) break;
frRequest(s, n);
quietSince = millis();
}
for (uint8_t i = 0; i < n; i++) *s[i].value = frSettingsGet(s[i].key, *s[i].value);
}
template <size_t N>
static inline void frSettingsBegin(FrSetting (&s)[N]) {
static_assert(N <= FR_MAX_KEYS, "fr_settings.h: at most FR_MAX_KEYS settings");
frSettingsBegin(s, (uint8_t)N);
}// fr_settings.h — FlashReady on-board settings (Wi-Fi name, phone number, ...) over USB.
//
// Why: the pre-built file on flashready.in is the same for everyone, so your own settings
// can't be inside it. Instead the board asks for them over USB serial and keeps them in
// its EEPROM. If you use Arduino IDE you can still type them into the constants in the
// sketch — this file then changes nothing except a 2-second pause at start-up.
//
// The protocol, in plain words (one text line each way, ending in \n, at 115200 baud):
// 1. Right after Serial.begin(115200) the board prints
// FR?SETTINGS v1 keys=wifi_ssid,wifi_pass missing=wifi_pass
// "missing" lists the required settings that still have no real value.
// 2. If nothing is missing it listens for 2 seconds and then carries on. If something is
// missing it keeps listening, printing the line again every 2 seconds, for up to
// 30 seconds; then it carries on anyway and the sketch skips whatever needs the
// missing value (an alarm still sounds, it just can't send the SMS).
// 3. The flasher answers FR!SET wifi_ssid=My%20Home&wifi_pass=secret
// (values URL-encoded). The board stores them and answers FR!OK, then carries on with
// the new values. Errors: FR!ERR unknown <key>, FR!ERR long <key> (nothing is stored).
// A key that is left out keeps its stored value; "key=" (empty) removes it.
// "FR!SET" with nothing after it means "carry on now": FR!OK, nothing is stored.
// 4. FR!CLEAR wipes everything that was stored, answers FR!OK and keeps listening.
// Other lines are ignored.
//
// A stored value wins over the constant in the sketch. A constant that is still a
// placeholder ("YOUR_SSID", "your-wifi", "+91XXXXXXXXXX") or empty counts as not set.
//
// EEPROM block at FR_SETTINGS_ADDR: 'F' 'R' 0x01, length (uint16, low byte first), then
// "key=value\n" lines. Uses FR_SETTINGS_SIZE bytes; move FR_SETTINGS_ADDR with a #define
// before the #include if your sketch uses EEPROM for something else.
//
// Use in a sketch:
// const char* WIFI_SSID = "YOUR_SSID";
// FrSetting SETTINGS[] = { {"wifi_ssid", &WIFI_SSID, true} }; // key, constant, required
// void setup() { Serial.begin(115200); frSettingsBegin(SETTINGS); ... WiFi.begin(WIFI_SSID, ...); }
// After frSettingsBegin() each constant points at the value to use ("" if none is set).
#pragma once
#include <Arduino.h>
#include <EEPROM.h>
#include <string.h>
#ifndef FR_SETTINGS_ADDR
#define FR_SETTINGS_ADDR 0
#endif
#ifndef FR_SETTINGS_SIZE
#if defined(__AVR__)
#define FR_SETTINGS_SIZE 96 // 2 KB of RAM on an Uno: a phone number fits easily
#else
#define FR_SETTINGS_SIZE 512
#endif
#endif
#ifndef FR_LINE_MAX
#if defined(__AVR__)
#define FR_LINE_MAX 128
#else
#define FR_LINE_MAX 1024
#endif
#endif
#define FR_MAX_KEYS 8
#define FR_WAIT_MAX_MS 30000UL // never wait longer than this for missing settings
#define FR_DATA_MAX (FR_SETTINGS_SIZE - 5)
struct FrSetting {
const char* key; // [a-z0-9_], as in settings.json
const char** value; // the sketch's constant; replaced by the value to use
bool required;
};
// Stored settings in RAM as "key=value\0key=value\0" (only non-empty values).
static char frData[FR_DATA_MAX + 1];
static uint16_t frLen = 0;
static inline void frEepromBegin() {
#if defined(ESP32) || defined(ESP8266)
static bool started = false;
if (!started) EEPROM.begin(FR_SETTINGS_ADDR + FR_SETTINGS_SIZE);
started = true;
#endif
}
static inline void frLoad() {
frEepromBegin();
frLen = 0;
frData[0] = 0;
if (EEPROM.read(FR_SETTINGS_ADDR) != 'F' || EEPROM.read(FR_SETTINGS_ADDR + 1) != 'R' || EEPROM.read(FR_SETTINGS_ADDR + 2) != 1) return;
uint16_t n = EEPROM.read(FR_SETTINGS_ADDR + 3) | (EEPROM.read(FR_SETTINGS_ADDR + 4) << 8);
if (n > FR_DATA_MAX) return;
for (uint16_t i = 0; i < n; i++) {
char c = EEPROM.read(FR_SETTINGS_ADDR + 5 + i);
frData[i] = c == '\n' ? '\0' : c;
}
frData[n] = 0;
frLen = n;
}
static inline void frPut(int addr, uint8_t b) {
#if defined(__AVR__)
EEPROM.update(addr, b);
#else
EEPROM.write(addr, b);
#endif
}
static inline void frSave() {
frEepromBegin();
frPut(FR_SETTINGS_ADDR, 'F');
frPut(FR_SETTINGS_ADDR + 1, 'R');
frPut(FR_SETTINGS_ADDR + 2, 1);
frPut(FR_SETTINGS_ADDR + 3, frLen & 0xFF);
frPut(FR_SETTINGS_ADDR + 4, frLen >> 8);
for (uint16_t i = 0; i < frLen; i++) frPut(FR_SETTINGS_ADDR + 5 + i, frData[i] ? frData[i] : '\n');
#if defined(ESP32) || defined(ESP8266)
EEPROM.commit();
#endif
}
// The stored value for key, or nullptr.
static inline const char* frStored(const char* key) {
size_t kl = strlen(key);
for (uint16_t i = 0; i < frLen; i += strlen(frData + i) + 1) {
if (strncmp(frData + i, key, kl) == 0 && frData[i + kl] == '=') return frData + i + kl + 1;
}
return nullptr;
}
// Empty, "your_…" / "YOUR-…" / "your …", or a "+91XXXX…" phone number.
static inline bool frIsPlaceholder(const char* v) {
if (!v || !*v) return true;
if (strncasecmp(v, "your", 4) == 0 && (v[4] == '_' || v[4] == '-' || v[4] == ' ')) return true;
return v[0] == '+' && strstr(v, "XXXX") != nullptr;
}
// The stored value for key, else the fallback constant; "" if that is a placeholder.
static inline const char* frSettingsGet(const char* key, const char* fallback) {
const char* v = frStored(key);
if (!v) v = fallback;
return frIsPlaceholder(v) ? "" : v;
}
static inline bool frIsMissing(const FrSetting& s) {
return s.required && !*frSettingsGet(s.key, *s.value);
}
static inline void frRequest(FrSetting* s, uint8_t n) {
Serial.print(F("FR?SETTINGS v1 keys="));
for (uint8_t i = 0; i < n; i++) {
if (i) Serial.print(',');
Serial.print(s[i].key);
}
Serial.print(F(" missing="));
bool first = true;
for (uint8_t i = 0; i < n; i++) {
if (!frIsMissing(s[i])) continue;
if (!first) Serial.print(',');
Serial.print(s[i].key);
first = false;
}
Serial.print('\n');
}
static inline void frReply(const __FlashStringHelper* what, const char* key) {
Serial.print(what);
Serial.print(key);
Serial.print('\n');
}
static inline int8_t frHex(char c) {
if (c >= '0' && c <= '9') return c - '0';
c |= 0x20;
return (c >= 'a' && c <= 'f') ? c - 'a' + 10 : -1;
}
// Percent-decodes in place. Drops NUL, CR and LF: they can't be stored.
static inline void frDecode(char* s) {
char* o = s;
for (; *s; s++) {
char c = *s;
if (c == '%' && frHex(s[1]) >= 0 && frHex(s[2]) >= 0) {
c = (char)(frHex(s[1]) << 4 | frHex(s[2]));
s += 2;
}
if (c != '\0' && c != '\n' && c != '\r') *o++ = c;
}
*o = 0;
}
// "k1=v1&k2=v2" (cut short if the line overflowed). Saves and returns true, or replies with an error.
static inline bool frSet(FrSetting* s, uint8_t n, char* p, bool overflow) {
const char* upd[FR_MAX_KEYS] = {};
while (*p) {
char* amp = strchr(p, '&');
bool last = !amp;
if (amp) *amp = 0;
char* eq = strchr(p, '=');
if (eq) *eq = 0;
if (*p) {
if (last && overflow) {
frReply(F("FR!ERR long "), p);
return false;
}
uint8_t i = 0;
while (i < n && strcmp(s[i].key, p) != 0) i++;
if (i == n) {
frReply(F("FR!ERR unknown "), p);
return false;
}
char* v = eq ? eq + 1 : p + strlen(p); // "key" alone = "key="
frDecode(v);
upd[i] = v;
}
if (last) break;
p = amp + 1;
}
// Build the new block: for each key, the new value or the one already stored.
char next[FR_DATA_MAX + 1];
uint16_t len = 0;
for (uint8_t i = 0; i < n; i++) {
const char* v = upd[i] ? upd[i] : frStored(s[i].key);
if (!v || !*v) continue;
size_t kl = strlen(s[i].key), vl = strlen(v);
if (len + kl + vl + 2 > FR_DATA_MAX) {
frReply(F("FR!ERR long "), s[i].key);
return false;
}
memcpy(next + len, s[i].key, kl);
next[len + kl] = '=';
memcpy(next + len + kl + 1, v, vl + 1);
len += kl + vl + 2;
}
memcpy(frData, next, len);
frData[len] = 0;
frLen = len;
frSave();
return true;
}
// Handles one received line. Returns true once settings were saved (boot carries on).
static inline bool frCommand(FrSetting* s, uint8_t n, char* line, bool overflow) {
if (strcmp(line, "FR!CLEAR") == 0) {
frLen = 0;
frData[0] = 0;
frSave();
Serial.print(F("FR!OK\n"));
return false;
}
if (strncmp(line, "FR!SET", 6) != 0 || (line[6] != ' ' && line[6] != 0)) return false; // not for us
char* p = line + 6;
while (*p == ' ') p++;
if (*p && !frSet(s, n, p, overflow)) return false; // empty FR!SET = carry on, store nothing
Serial.print(F("FR!OK\n"));
return true;
}
// Call right after Serial.begin(115200). Prints the request, waits as described at the top
// (30 s at most), then points every constant in the list at the value to use ("" = not set).
static inline void frSettingsBegin(FrSetting* s, uint8_t n) {
frLoad();
char line[FR_LINE_MAX];
uint16_t len = 0;
bool overflow = false;
frRequest(s, n);
unsigned long start = millis(), quietSince = start;
while (millis() - start < FR_WAIT_MAX_MS) {
int c = Serial.read();
if (c >= 0) {
quietSince = millis();
if (c == '\r') continue;
if (c != '\n') {
if (len < FR_LINE_MAX - 1) line[len++] = (char)c;
else overflow = true;
continue;
}
line[len] = 0;
bool saved = frCommand(s, n, line, overflow);
len = 0;
overflow = false;
if (saved) break;
continue;
}
if (millis() - quietSince < 2000) {
delay(1); // also keeps the ESP8266 watchdog fed
continue;
}
len = 0; // drop half a line
overflow = false;
bool missing = false;
for (uint8_t i = 0; i < n; i++) missing |= frIsMissing(s[i]);
if (!missing) break;
frRequest(s, n);
quietSince = millis();
}
for (uint8_t i = 0; i < n; i++) *s[i].value = frSettingsGet(s[i].key, *s[i].value);
}
template <size_t N>
static inline void frSettingsBegin(FrSetting (&s)[N]) {
static_assert(N <= FR_MAX_KEYS, "fr_settings.h: at most FR_MAX_KEYS settings");
frSettingsBegin(s, (uint8_t)N);
}Compiled for xiaoc3 on esp32 3.3.12 with Adafruit SHT4x Library 1.0.5 · 3 Nov 2026
How it works
Routers, fibre ONTs, PoE switches and NVRs are often shut inside a wooden TV unit or a wall cabinet. In an Indian summer the air in there can pass 45 °C; the equipment slows down, Wi-Fi drops and the ONT reboots at random. A small fan helps — but a fan running all year collects dust and makes noise. This one runs only when the cabinet is warm, and only as fast as it needs to.
An SHT40 sensor reads the cabinet temperature every five seconds. Below 35 °C the fan is off and the TB6612FNG driver sleeps in standby. From 35 °C to 45 °C the speed rises from 35 % to 100 %; above 45 °C it runs flat out. A 1.5 °C gap between switching on and off stops it flickering at the threshold. If the temperature still reaches 50 °C, the XIAO sends one alert to your phone through ntfy.sh, because something is wrong — a blocked vent or a dead fan.
The XIAO ESP32C3 is small enough to tuck behind the router, runs from the same USB charger as the fan, and its default I2C pins D4/D5 go straight to the SHT40. The C3’s LEDC peripheral produces the 25 kHz PWM on D2, well above anything you can hear, and every pin used avoids the strapping pins D8 and D9.
The Serial Monitor prints temp_c,rh,fan_pct every five seconds, handy for checking how hot your cabinet really gets.
Troubleshooting
The fan never starts
Check STBY on D10 and VM on 5V. Warm the sensor with your hand to above 35 °C and watch the CSV line in the Serial Monitor: the third number is the fan duty in %.
The fan buzzes but doesn’t turn
Its minimum speed is higher than 35 %. Raise MIN_DUTY to 50.
No alert at 50 °C
Subscribe to the same ntfy topic in the app and check the Wi-Fi name and password. The alert is sent once and re-arms when the cabinet cools to 45 °C.
Upgrades
- Add a second fan on channel B (BIN1/BIN2/PWMB) as an exhaust.
- Show the cabinet temperature on a small web page or in Home Assistant over MQTT.
- Reboot the router through a relay if it stops answering pings (see the router reboot controller project).



