Mains voltage (230 V) — can kill. The low-voltage side (board, sensors, display, relay module) is safe to build yourself. The mains side must be wired by a licensed electrician, and tested with the circuit switched off at the MCB.
- Never switch a motor with a relay module. Motors pull several times their running current at start-up; hobby relay contacts weld or overheat. The relay only drives a contactor’s coil (or your pump starter’s coil input).
- Contactor rated for motors (AC-3) at or above the load’s nameplate current.
- Protect and earth: MCB on live, RCCB/ELCB on the circuit, metal bodies earthed. Switch live only — never neutral or earth.
- Keep the two worlds apart: mains and low-voltage wiring in separate compartments of a closed, non-metallic enclosure; no bare 230 V terminals.
- Fail safe: the code switches the load off when a sensor stops answering. Still fit a manual override at the starter.
Parts list
| Part | Qty | Approx. | Buy |
|---|---|---|---|
| ESP32 DevKit V1 (30-pin) | 1 | ₹380 | Buy ESP32 DevKit V1 (30-pin) |
| DS18B20 waterproof probe (stainless) + 4.7 kΩ resistor | 1 | ₹120 | Buy DS18B20 waterproof probe (stainless) + 4.7 kΩ resistor |
| 0.96" SSD1306 I2C OLED (4-pin) | 1 | ₹220 | Buy 0.96" SSD1306 I2C OLED (4-pin) |
| 1-channel relay module, 5 V coil, 3.3 V-trigger (active-LOW) | 2 | ₹280 | Buy 1-channel relay module, 5 V coil, 3.3 V-trigger (active-LOW) |
| Active buzzer module | 1 | ₹25 | Buy Active buzzer module |
| Submersible aquarium heater with its own thermostat (100–200 W) | 1 | ₹700 | Buy Submersible aquarium heater with its own thermostat (100–200 W) |
| Enclosure mounted above and away from the tank (drip loops on all cables) | 1 | ₹250 | Buy Enclosure mounted above and away from the tank (drip loops on all cables) |
| 5 V 1 A USB adapter + cable | 1 | ₹120 | Buy 5 V 1 A USB adapter + cable |
| Total | ₹2,095 |
Affiliate disclosure: “Buy” links may earn FlashReady a small commission at no extra cost to you. Prices are indicative.
Wiring
| ESP32 DevKit V1 | Part · pin | Wire | Notes |
|---|---|---|---|
| D4 | DS18B20 (1-Wire) · DQ | yellow | |
| 3V3 | DS18B20 (1-Wire) · DQ | red | Via 4.7 kΩ — Pull-up from DQ to 3.3 V |
| 3V3 | DS18B20 (1-Wire) · VDD | red | |
| GND | DS18B20 (1-Wire) · GND | black | |
| D21 | SSD1306 OLED 0.96″ (128×64, I2C) · SDA | blue | |
| D22 | SSD1306 OLED 0.96″ (128×64, I2C) · SCL | yellow | |
| 3V3 | SSD1306 OLED 0.96″ (128×64, I2C) · VCC | red | |
| GND | SSD1306 OLED 0.96″ (128×64, I2C) · GND | black | |
| D26 | 1-channel relay module (5 V coil) · IN | orange | Heater relay, active-LOW; HIGH before it becomes an output |
| VIN | 1-channel relay module (5 V coil) · DC+ | red | |
| GND | 1-channel relay module (5 V coil) · DC− | black | |
| Heater circuit, 230 V (electrician) · L | 1-channel relay module (5 V coil) · COM | brown | Live from the RCCB/MCB — 230 V, electrician only |
| Heater circuit, 230 V (electrician) · Heater L | 1-channel relay module (5 V coil) · NO | brown | To the heater’s live; its neutral and earth go straight to the heater |
| D27 | 1-channel relay module (5 V coil) #2 · IN | orange | Light relay |
| VIN | 1-channel relay module (5 V coil) #2 · DC+ | red | |
| GND | 1-channel relay module (5 V coil) #2 · DC− | black | |
| Tank light circuit, 230 V (electrician) · L | 1-channel relay module (5 V coil) #2 · COM | brown | Live — electrician only |
| Tank light circuit, 230 V (electrician) · Light L | 1-channel relay module (5 V coil) #2 · NO | brown | To the LED light’s adapter / live |
| D25 | Active buzzer module (high-level trigger) · I/O | orange | |
| 3V3 | Active buzzer module (high-level trigger) · VCC | red | |
| GND | Active buzzer module (high-level trigger) · GND | black |
Board quirks: Water and 230 V together need an <strong>RCCB (30 mA)</strong> on the aquarium circuit — that is what saves a life if a heater glass cracks. An electrician wires the relay contacts, the RCCB and earth. Every cable gets a drip loop so water can’t run along it into a socket or the box. A 200 W heater draws under 1 A, and an LED light much less — both are inside a 10 A relay contact. Don’t put the filter or air pump on these relays; they should run all the time anyway. Set the heater’s own thermostat 1–2 °C <strong>above</strong> TARGET_C. The ESP32 normally controls it; the built-in thermostat is the backup if a relay ever sticks closed. If the probe fails, the heater stays off and the buzzer beeps. If the heater runs 45 minutes without reaching the target (heater dead, probe out of the water), it rests 10 minutes before trying again.
Code
// Aquarium heater and light controller — ESP32 DevKit V1 + DS18B20 + OLED + 2 relays + buzzer
// DS18B20 probe on D4 (4.7 kΩ pull-up to 3V3). OLED on D21/D22. Buzzer module on D25.
// Relay 1 IN D26: aquarium heater (≤ 200 W, within the relay rating). Relay 2 IN D27: LED light.
// Both relay modules active-LOW; 230 V side by an electrician, on an RCCB.
// Light schedule from internet time (IST): on 10:00-13:00 and 15:00-20:00 (a midday break cuts algae).
#include <WiFi.h>
#include <time.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "fr_settings.h"
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
// Filled in from the FlashReady flasher over USB (kept on the board), or type the values above.
FrSetting SETTINGS[] = {{"wifi_ssid", &WIFI_SSID, true}, {"wifi_pass", &WIFI_PASS, true}};
constexpr uint8_t TEMP_PIN = 4;
constexpr uint8_t HEATER_PIN = 26;
constexpr uint8_t LIGHT_PIN = 27;
constexpr uint8_t BUZZER_PIN = 25;
constexpr bool RELAY_ACTIVE_LOW = true;
constexpr float TARGET_C = 26.0; // most tropical fish: 24-28 °C
constexpr float HYST = 0.3;
constexpr float ALARM_LOW = 22.0, ALARM_HIGH = 31.0;
constexpr uint32_t MAX_HEAT_MS = 45UL * 60UL * 1000UL; // a heater on this long means something is wrong
constexpr uint32_t REST_MS = 10UL * 60UL * 1000UL;
struct Slot { uint8_t fromH, fromM, toH, toM; };
const Slot LIGHT[] = {{10, 0, 13, 0}, {15, 0, 20, 0}};
OneWire bus(TEMP_PIN);
DallasTemperature probe(&bus);
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
bool heater = false, light = false;
uint32_t heaterSince = 0, restUntil = 0;
void relay(uint8_t pin, bool on) { digitalWrite(pin, (on != RELAY_ACTIVE_LOW) ? HIGH : LOW); }
void setHeater(bool on) {
if (on == heater) return;
heater = on;
heaterSince = millis();
relay(HEATER_PIN, on);
Serial.printf("Heater %s\n", on ? "ON" : "OFF");
}
void setup() {
relay(HEATER_PIN, false); // both relays off before the pins become outputs
relay(LIGHT_PIN, false);
pinMode(HEATER_PIN, OUTPUT);
pinMode(LIGHT_PIN, OUTPUT);
pinMode(BUZZER_PIN, OUTPUT);
Serial.begin(115200);
frSettingsBegin(SETTINGS);
probe.begin();
Wire.begin(21, 22);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println("OLED not found at 0x3C");
oled.setTextColor(SSD1306_WHITE);
if (*WIFI_SSID) {
WiFi.begin(WIFI_SSID, WIFI_PASS);
configTime(19800, 0, "pool.ntp.org", "time.google.com"); // IST
} else {
Serial.println("Light schedule off — no Wi-Fi name set. The heater control works without it.");
}
}
void loop() {
probe.requestTemperatures();
float t = probe.getTempCByIndex(0);
bool probeOk = t > -50 && t < 85;
uint32_t ms = millis();
// Heater: thermostat with hysteresis, a 45-minute run limit, and off on a probe fault.
if (!probeOk) {
setHeater(false);
} else if (restUntil && (int32_t)(ms - restUntil) < 0) {
setHeater(false);
} else {
restUntil = 0;
if (!heater && t < TARGET_C - HYST) setHeater(true);
if (heater && t > TARGET_C + HYST) setHeater(false);
if (heater && ms - heaterSince > MAX_HEAT_MS) {
setHeater(false);
restUntil = ms + REST_MS;
Serial.println("Heater ran 45 min without reaching the target - resting 10 min");
}
}
// Light from the schedule
time_t now = time(nullptr);
bool timeOk = now > 1700000000;
struct tm lt;
localtime_r(&now, <);
int m = lt.tm_hour * 60 + lt.tm_min;
bool wantLight = false;
for (const Slot& s : LIGHT)
if (m >= s.fromH * 60 + s.fromM && m < s.toH * 60 + s.toM) wantLight = true;
wantLight = wantLight && timeOk;
if (wantLight != light) {
light = wantLight;
relay(LIGHT_PIN, light);
Serial.printf("Light %s\n", light ? "ON" : "OFF");
}
bool alarm = !probeOk || t < ALARM_LOW || t > ALARM_HIGH;
if (alarm) { // a short beep every loop (2 s)
digitalWrite(BUZZER_PIN, HIGH);
delay(150);
digitalWrite(BUZZER_PIN, LOW);
}
oled.clearDisplay();
oled.setCursor(0, 0);
oled.setTextSize(1);
if (timeOk) oled.printf("%02d:%02d set %.1f C\n", lt.tm_hour, lt.tm_min, TARGET_C);
else oled.printf("--:-- set %.1f C\n", TARGET_C);
oled.setTextSize(3);
if (probeOk) oled.printf("%.1f\n", t);
else oled.println("--.-");
oled.setTextSize(1);
oled.printf("Heater %s %s\n", heater ? "ON " : "off", restUntil ? "(rest)" : "");
oled.printf("Light %s %s", light ? "ON " : "off", timeOk ? "" : "(no time)");
if (alarm) {
oled.setCursor(80, 56);
oled.print(probeOk ? "TEMP!" : "PROBE!");
}
oled.display();
Serial.printf("t=%.1f heater=%d light=%d time=%s\n", t, heater, light, timeOk ? "ok" : "none");
delay(2000);
}// Aquarium heater and light controller — ESP32 DevKit V1 + DS18B20 + OLED + 2 relays + buzzer
// DS18B20 probe on D4 (4.7 kΩ pull-up to 3V3). OLED on D21/D22. Buzzer module on D25.
// Relay 1 IN D26: aquarium heater (≤ 200 W, within the relay rating). Relay 2 IN D27: LED light.
// Both relay modules active-LOW; 230 V side by an electrician, on an RCCB.
// Light schedule from internet time (IST): on 10:00-13:00 and 15:00-20:00 (a midday break cuts algae).
#include <WiFi.h>
#include <time.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "fr_settings.h"
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
// Filled in from the FlashReady flasher over USB (kept on the board), or type the values above.
FrSetting SETTINGS[] = {{"wifi_ssid", &WIFI_SSID, true}, {"wifi_pass", &WIFI_PASS, true}};
constexpr uint8_t TEMP_PIN = 4;
constexpr uint8_t HEATER_PIN = 26;
constexpr uint8_t LIGHT_PIN = 27;
constexpr uint8_t BUZZER_PIN = 25;
constexpr bool RELAY_ACTIVE_LOW = true;
constexpr float TARGET_C = 26.0; // most tropical fish: 24-28 °C
constexpr float HYST = 0.3;
constexpr float ALARM_LOW = 22.0, ALARM_HIGH = 31.0;
constexpr uint32_t MAX_HEAT_MS = 45UL * 60UL * 1000UL; // a heater on this long means something is wrong
constexpr uint32_t REST_MS = 10UL * 60UL * 1000UL;
struct Slot { uint8_t fromH, fromM, toH, toM; };
const Slot LIGHT[] = {{10, 0, 13, 0}, {15, 0, 20, 0}};
OneWire bus(TEMP_PIN);
DallasTemperature probe(&bus);
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
bool heater = false, light = false;
uint32_t heaterSince = 0, restUntil = 0;
void relay(uint8_t pin, bool on) { digitalWrite(pin, (on != RELAY_ACTIVE_LOW) ? HIGH : LOW); }
void setHeater(bool on) {
if (on == heater) return;
heater = on;
heaterSince = millis();
relay(HEATER_PIN, on);
Serial.printf("Heater %s\n", on ? "ON" : "OFF");
}
void setup() {
relay(HEATER_PIN, false); // both relays off before the pins become outputs
relay(LIGHT_PIN, false);
pinMode(HEATER_PIN, OUTPUT);
pinMode(LIGHT_PIN, OUTPUT);
pinMode(BUZZER_PIN, OUTPUT);
Serial.begin(115200);
frSettingsBegin(SETTINGS);
probe.begin();
Wire.begin(21, 22);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println("OLED not found at 0x3C");
oled.setTextColor(SSD1306_WHITE);
if (*WIFI_SSID) {
WiFi.begin(WIFI_SSID, WIFI_PASS);
configTime(19800, 0, "pool.ntp.org", "time.google.com"); // IST
} else {
Serial.println("Light schedule off — no Wi-Fi name set. The heater control works without it.");
}
}
void loop() {
probe.requestTemperatures();
float t = probe.getTempCByIndex(0);
bool probeOk = t > -50 && t < 85;
uint32_t ms = millis();
// Heater: thermostat with hysteresis, a 45-minute run limit, and off on a probe fault.
if (!probeOk) {
setHeater(false);
} else if (restUntil && (int32_t)(ms - restUntil) < 0) {
setHeater(false);
} else {
restUntil = 0;
if (!heater && t < TARGET_C - HYST) setHeater(true);
if (heater && t > TARGET_C + HYST) setHeater(false);
if (heater && ms - heaterSince > MAX_HEAT_MS) {
setHeater(false);
restUntil = ms + REST_MS;
Serial.println("Heater ran 45 min without reaching the target - resting 10 min");
}
}
// Light from the schedule
time_t now = time(nullptr);
bool timeOk = now > 1700000000;
struct tm lt;
localtime_r(&now, <);
int m = lt.tm_hour * 60 + lt.tm_min;
bool wantLight = false;
for (const Slot& s : LIGHT)
if (m >= s.fromH * 60 + s.fromM && m < s.toH * 60 + s.toM) wantLight = true;
wantLight = wantLight && timeOk;
if (wantLight != light) {
light = wantLight;
relay(LIGHT_PIN, light);
Serial.printf("Light %s\n", light ? "ON" : "OFF");
}
bool alarm = !probeOk || t < ALARM_LOW || t > ALARM_HIGH;
if (alarm) { // a short beep every loop (2 s)
digitalWrite(BUZZER_PIN, HIGH);
delay(150);
digitalWrite(BUZZER_PIN, LOW);
}
oled.clearDisplay();
oled.setCursor(0, 0);
oled.setTextSize(1);
if (timeOk) oled.printf("%02d:%02d set %.1f C\n", lt.tm_hour, lt.tm_min, TARGET_C);
else oled.printf("--:-- set %.1f C\n", TARGET_C);
oled.setTextSize(3);
if (probeOk) oled.printf("%.1f\n", t);
else oled.println("--.-");
oled.setTextSize(1);
oled.printf("Heater %s %s\n", heater ? "ON " : "off", restUntil ? "(rest)" : "");
oled.printf("Light %s %s", light ? "ON " : "off", timeOk ? "" : "(no time)");
if (alarm) {
oled.setCursor(80, 56);
oled.print(probeOk ? "TEMP!" : "PROBE!");
}
oled.display();
Serial.printf("t=%.1f heater=%d light=%d time=%s\n", t, heater, light, timeOk ? "ok" : "none");
delay(2000);
}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 esp32 on esp32 3.3.12 with OneWire 2.3.8 · 3 Nov 2026
How it works
Tropical aquarium fish need steady water — about 24–28 °C — and a regular day and night. In much of India the room is warm enough most of the year, but winter nights in the north and air-conditioned rooms in the south both chill a tank. Cheap aquarium heaters’ dials are often off by 2–3 °C, and lights left on all day grow algae.
How it works
A DS18B20 waterproof probe sits in the tank. Every 2 seconds the ESP32 reads it and runs a thermostat with 0.3 °C hysteresis around TARGET_C (26 °C): heater relay on below 25.7 °C, off above 26.3 °C.
Safety rules come first:
- probe missing or faulty → heater off, buzzer beeps;
- heater on for 45 minutes without reaching the target → off for 10 minutes (dead heater, probe out of the water);
- water below 22 °C or above 31 °C → a beep every 2 seconds and “TEMP!” on the OLED.
The light follows a schedule from internet time (NTP, IST): on from 10:00 to 13:00 and from 15:00 to 20:00. The midday break is a common trick to keep algae down without dimming the tank in the evening when you watch it. Without Wi-Fi the light stays off and the OLED says “no time”; the heater works anyway.
Why these parts
- DS18B20 — sealed steel probe, accurate to ±0.5 °C, unaffected by long cables.
- Two relay modules — one for the heater, one for the light; both loads are small and resistive/LED.
- OLED — temperature, both relay states and the time at a glance.
Calibration
Put the probe and a good aquarium thermometer side by side in the tank for 10 minutes. If they differ, adjust TARGET_C by the difference rather than trusting either blindly.
India notes
Summer room temperatures over 32 °C will push the tank above 31 °C with the heater off — the alarm tells you, but the fix is shade, a fan across the water surface, or a cooler room. In power cuts, heaters stop; a well-covered tank loses only 1–2 °C an hour, so most fish ride out a typical cut.
Troubleshooting
The OLED shows --.- and PROBE!
The DS18B20 doesn’t answer. Check the 4.7 kΩ pull-up between DQ and 3V3, and the probe’s three wires.
The light never comes on
The ESP32 has no time: the OLED shows --:-- and “(no time)”. Check the Wi-Fi name and password from the flasher, and that the internet works.
The temperature overshoots by 1 °C
The heater keeps releasing heat after it switches off. Place the probe on the opposite side of the tank from the heater, near the filter outflow, and keep the 0.3 °C hysteresis.
Upgrades
- Add a pH or TDS sensor and show it on the OLED.
- Fade the light up and down with a MOSFET and a 12 V LED strip instead of switching it.
- Send a phone alert through ntfy.sh on a temperature alarm.



