/**
* PotWatch v4.0
* ESP32-C3 Super Mini
*
* Clip-on pot thermometer: pick a preset, drop the probe in, walk away.
* Detects boiling, counts down, alarms when the dish is ready.
*
* Project home: https://potwatch.net
*
* Copyright (C) 2026 Ilia Kuzmin
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*
* Changelog:
* v3.2 Removed Bounce2 -> transparent SimpleButton (reliable on ESP32-C3).
* v4.0 Rebrand ClipEgg -> PotWatch. Non-blocking sound engine (device stays
* responsive during the alarm). Timer screen: dedicated input handler
* with chord window (fixes off-by-one) + hold-both-to-exit. Temperature
* polled during COUNTDOWN; optional dry-pot protection + dead-probe
* indication. Preset minutes single-sourced from PRESET_MINUTES[].
* NVS namespace kept as "clipegg" so saved calibration survives upgrade.
*/
#include
#include
#include
#include
#include
#include
#include
#define FW_VERSION "v4.0"
#define PIN_DS18B20 3
#define PIN_SDA 4
#define PIN_SCL 5
#define PIN_BUZZER 6
#define PIN_BTN_OK 7
#define PIN_BTN_SET 10
#define OLED_WIDTH 128
#define OLED_HEIGHT 64
#define OLED_ADDR 0x3C
#define BUZZER_FREQ 3000
#define TEMP_POLL_MS 1000
#define DS18B20_CONV_MS 800
#define BOIL_CONFIRM_COUNT 5
#define DEFAULT_BOIL_TEMP 97.6f
#define DEFAULT_BOIL_PRES 1013.25f
// Dry-pot protection: if the probe reads well above boiling for several
// seconds (no water / pot boiled dry) during heat-up or countdown, raise the
// alarm instead of cooking a dead sensor. Set DRY_POT_PROTECT to 0 to disable.
#define DRY_POT_PROTECT 1
#define DRY_POT_TEMP 105.0f
#define DRY_POT_CONFIRM 4
#define SENSOR_DEAD_COUNT 5
#define DEBOUNCE_MS 15
#define HOLD_RESET_MS 3000
#define HOLD_CAL_SAVE_MS 2000
#define HOLD_PHASE1_MS 500
#define HOLD_PHASE2_MS 300
#define HOLD_PHASE3_MS 200
#define HOLD_P2_START 2000
#define HOLD_P3_START 5000
#define CHORD_WINDOW_MS 80 // two presses within this = a chord, not two taps
#define CHORD_EXIT_MS 1500 // hold both buttons this long = exit Timer
#define MENU_BENEDICT 0
#define MENU_SOFT 1
#define MENU_HARD 2
#define MENU_SOUP 3
#define MENU_TIMER 4
#define MENU_CALIBRATE 5
#define MENU_COUNT 6
#define MENU_VISIBLE 4
#define SOUP_MIN 15
#define SOUP_MAX 120
#define SOUP_STEP 15
#define TIMER_MIN 1
#define TIMER_MAX 360
// Alarm reasons (plain ints to avoid Arduino enum-prototype quirks)
#define ALARM_DONE 0
#define ALARM_DRY 1
// ─────────────────────────────────────────────────
// Simple transparent button debouncer — no library
// ─────────────────────────────────────────────────
class SimpleButton {
uint8_t _pin;
bool _raw; // last raw digitalRead (true = pressed = LOW pin)
bool _state; // confirmed debounced state
unsigned long _changeMs; // when raw last changed
bool _fell; // pressed event this update()
bool _rose; // released event this update()
public:
void begin(uint8_t pin) {
_pin = pin;
pinMode(pin, INPUT_PULLUP);
_raw = (digitalRead(pin) == LOW);
_state = _raw;
_changeMs = 0;
_fell = _rose = false;
}
void update(unsigned long now) {
_fell = _rose = false;
bool r = (digitalRead(_pin) == LOW);
if (r != _raw) { _raw = r; _changeMs = now; } // edge: restart timer
if (now - _changeMs >= DEBOUNCE_MS && r != _state) {
_state = r;
if (_state) _fell = true; else _rose = true; // confirmed transition
}
}
bool fell() const { return _fell; } // just pressed
bool rose() const { return _rose; } // just released
bool isDown() const { return _state; } // currently held
};
// ─────────────────────────────────────────────────
OneWire oneWire(PIN_DS18B20);
DallasTemperature tempSensor(&oneWire);
Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, -1);
Adafruit_BMP280 bmp;
SimpleButton btnSet, btnOk;
Preferences prefs;
enum State {
STATE_MENU, STATE_SOUP_TIME, STATE_CUSTOM_TIME,
STATE_WAITING_BOIL, STATE_COUNTDOWN, STATE_ALARM, STATE_CALIBRATION
};
State appState = STATE_MENU;
int menuIndex = 0;
int menuScroll = 0;
const char* PRESET_NAMES[MENU_COUNT] = {
"Benedict", "Soft egg", "Hard egg", "Soup", "Timer", ""
};
// Single source of truth for the fixed egg presets' cook time (minutes).
// 0 = not a fixed-time preset (Soup/Timer/Calibrate handle their own).
const int PRESET_MINUTES[MENU_COUNT] = { 1, 3, 5, 0, 0, 0 };
// Menu labels: egg rows generated from name + minutes so they can never
// disagree with PRESET_MINUTES; the rest are static.
char menuLabelBuf[3][20];
const char* MENU_LABELS[MENU_COUNT];
int activeMinutes = 3;
int activePresetIdx = MENU_SOFT;
int soupMinutes = 60;
int timerMinutes = 5;
float savedBoilBase = DEFAULT_BOIL_TEMP;
float savedBoilPres = DEFAULT_BOIL_PRES;
bool hasBmp = false;
float calBoilTemp = DEFAULT_BOIL_TEMP;
float currentTemp = 0.0f;
int boilConfirm = 0;
int dryConfirm = 0;
int consecutiveBad= 0;
unsigned long convRequestMs = 0;
bool convPending = false;
unsigned long countdownEnd = 0;
int alarmReason = ALARM_DONE;
// SET hold state
unsigned long setHoldStart = 0;
unsigned long setLastRepeat = 0;
bool setActive = false;
bool setHoldFired = false;
// OK hold state
unsigned long okHoldStart = 0;
unsigned long okLastRepeat = 0;
bool okActive = false;
bool okHoldFired = false;
// Timer-screen (STATE_CUSTOM_TIME) dedicated input state
bool ctSetPend = false;
bool ctOkPend = false;
unsigned long ctSetPendMs = 0;
unsigned long ctOkPendMs = 0;
bool ctChordActive= false;
bool ctChordFired = false;
unsigned long ctChordStart = 0;
// ─────────────────────────────────────────────────
// Non-blocking sound sequencer (freq 0 = rest)
// ─────────────────────────────────────────────────
#define SEQ_GAP 30
const int* seqFreq = nullptr;
const int* seqDur = nullptr;
int seqLen = 0;
int seqIdx = 0;
unsigned long seqNoteStart = 0;
bool seqPlaying = false;
bool seqLoop = false;
const int CONFIRM_F[] = { BUZZER_FREQ, 0, BUZZER_FREQ, 0, BUZZER_FREQ };
const int CONFIRM_D[] = { 140, 70, 140, 70, 140 };
const int CONFIRM_LEN = 5;
const int ALARM_F[] = { BUZZER_FREQ, 0, BUZZER_FREQ, 0, BUZZER_FREQ, 0 };
const int ALARM_D[] = { 180, 110, 180, 110, 180, 650 };
const int ALARM_LEN = 6;
const int RESET_F[] = { BUZZER_FREQ, 0, BUZZER_FREQ };
const int RESET_D[] = { 200, 80, 200 };
const int RESET_LEN = 3;
void soundStart(const int* f, const int* d, int len, bool loop) {
seqFreq = f; seqDur = d; seqLen = len;
seqIdx = 0; seqLoop = loop; seqPlaying = true;
seqNoteStart = millis();
if (f[0] > 0) tone(PIN_BUZZER, f[0], d[0]); else noTone(PIN_BUZZER);
}
void soundStop() { seqPlaying = false; noTone(PIN_BUZZER); }
void soundService(unsigned long now) {
if (!seqPlaying) return;
if (now - seqNoteStart < (unsigned long)seqDur[seqIdx] + SEQ_GAP) return;
seqIdx++;
if (seqIdx >= seqLen) {
if (seqLoop) seqIdx = 0;
else { seqPlaying = false; noTone(PIN_BUZZER); return; }
}
seqNoteStart = now;
if (seqFreq[seqIdx] > 0) tone(PIN_BUZZER, seqFreq[seqIdx], seqDur[seqIdx]);
else noTone(PIN_BUZZER);
}
void beepClick() { tone(PIN_BUZZER, BUZZER_FREQ, 25); }
// ─────────────────────────────────────────────────
float currentPressureHpa() {
return hasBmp ? bmp.readPressure() / 100.0f : DEFAULT_BOIL_PRES;
}
float boilPoint() {
return constrain(savedBoilBase + (currentPressureHpa() - savedBoilPres) * 0.03f, 85.0f, 102.0f);
}
bool sensorDead() { return consecutiveBad >= SENSOR_DEAD_COUNT; }
void buildMenuLabels() {
for (int i = 0; i < 3; i++) {
snprintf(menuLabelBuf[i], sizeof(menuLabelBuf[i]), "%-9s%d min",
PRESET_NAMES[i], PRESET_MINUTES[i]);
MENU_LABELS[i] = menuLabelBuf[i];
}
MENU_LABELS[MENU_SOUP] = "Soup...";
MENU_LABELS[MENU_TIMER] = "Timer...";
MENU_LABELS[MENU_CALIBRATE] = "Calibrate";
}
// ─────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
delay(200);
pinMode(PIN_BUZZER, OUTPUT);
noTone(PIN_BUZZER);
Wire.begin(PIN_SDA, PIN_SCL);
Wire.setClock(400000);
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR))
Serial.println("OLED not found");
display.clearDisplay();
display.display();
hasBmp = bmp.begin(0x76);
if (!hasBmp) Serial.println("BMP280 not found");
tempSensor.begin();
tempSensor.setWaitForConversion(false);
tempSensor.setResolution(12);
Serial.printf("DS18B20: %d found\n", tempSensor.getDeviceCount());
btnSet.begin(PIN_BTN_SET);
btnOk.begin(PIN_BTN_OK);
buildMenuLabels();
loadPrefs();
tone(PIN_BUZZER, BUZZER_FREQ, 80); delay(150);
tone(PIN_BUZZER, BUZZER_FREQ, 80);
Serial.printf("Boot OK PotWatch %s menu=%d soup=%d timer=%d boilBase=%.1f\n",
FW_VERSION, menuIndex, soupMinutes, timerMinutes, savedBoilBase);
}
// ─────────────────────────────────────────────────
void loop() {
unsigned long now = millis();
btnSet.update(now);
btnOk.update(now);
if (appState == STATE_CUSTOM_TIME) {
handleCustomTime(now);
} else {
handleSetButton(now);
handleOkButton(now);
}
pollTemperature(now);
soundService(now);
if (appState == STATE_COUNTDOWN && now >= countdownEnd) {
triggerAlarm(ALARM_DONE);
}
// Rate-limited display: buttons polled thousands of times between frames
static unsigned long lastDraw = 0;
if (now - lastDraw >= 50) {
lastDraw = now;
switch (appState) {
case STATE_MENU: drawMenu(); break;
case STATE_SOUP_TIME: drawSoupTime(); break;
case STATE_CUSTOM_TIME: drawCustomTime(); break;
case STATE_CALIBRATION: drawCalibration(); break;
case STATE_WAITING_BOIL: drawWaitingBoil(); break;
case STATE_COUNTDOWN: drawCountdown(now); break;
case STATE_ALARM: drawAlarm(now); break;
}
}
}
// ─────────────────────────────────────────────────
// SET BUTTON (all states except STATE_CUSTOM_TIME)
// ─────────────────────────────────────────────────
void handleSetButton(unsigned long now) {
if (btnSet.fell()) {
setHoldStart = now;
setLastRepeat = now;
setActive = true;
setHoldFired = false;
// Calibration steps on release (tap vs hold-to-save); others step now.
if (appState != STATE_CALIBRATION) onSetStep(1);
}
if (btnSet.isDown() && setActive && !setHoldFired) {
unsigned long held = now - setHoldStart;
if (appState == STATE_CALIBRATION && held >= HOLD_CAL_SAVE_MS) {
setHoldFired = true;
saveCalibration();
return;
}
if (appState == STATE_SOUP_TIME) {
unsigned long interval; int steps;
if (held < HOLD_P2_START) { interval = HOLD_PHASE1_MS; steps = 1; }
else if (held < HOLD_P3_START) { interval = HOLD_PHASE2_MS; steps = 5; }
else { interval = HOLD_PHASE3_MS; steps = 10; }
if (now - setLastRepeat >= interval) {
setLastRepeat = now;
onSetStep(steps);
}
}
}
if (btnSet.rose()) {
if (appState == STATE_CALIBRATION && !setHoldFired) onSetStep(1);
setActive = false;
}
}
void onSetStep(int steps) {
switch (appState) {
case STATE_MENU:
menuIndex = (menuIndex + 1) % MENU_COUNT;
if (menuIndex < menuScroll) menuScroll = menuIndex;
if (menuIndex >= menuScroll + MENU_VISIBLE) menuScroll = menuIndex - MENU_VISIBLE + 1;
beepClick();
break;
case STATE_SOUP_TIME: {
int range = SOUP_MAX - SOUP_MIN + SOUP_STEP;
soupMinutes = SOUP_MIN + ((soupMinutes - SOUP_MIN + SOUP_STEP) % range);
beepClick();
break;
}
case STATE_CUSTOM_TIME: {
int range = TIMER_MAX - TIMER_MIN + 1;
timerMinutes = TIMER_MIN + ((timerMinutes - TIMER_MIN + steps + range * 100) % range);
beepClick();
break;
}
case STATE_CALIBRATION:
calBoilTemp = constrain(calBoilTemp + 0.1f, 85.0f, 102.0f);
beepClick();
break;
default: break;
}
}
// ─────────────────────────────────────────────────
// OK BUTTON (all states except STATE_CUSTOM_TIME)
// short press = action; hold 3s = full reset to menu
// ─────────────────────────────────────────────────
void handleOkButton(unsigned long now) {
if (btnOk.fell()) {
okHoldStart = now;
okActive = true;
okHoldFired = false;
beepClick();
onOkPress();
}
if (btnOk.isDown() && okActive && !okHoldFired) {
if (now - okHoldStart >= HOLD_RESET_MS) {
okHoldFired = true;
fullReset();
}
}
if (btnOk.rose()) okActive = false;
}
void onOkPress() {
switch (appState) {
case STATE_MENU:
switch (menuIndex) {
case MENU_BENEDICT:
case MENU_SOFT:
case MENU_HARD:
activeMinutes = PRESET_MINUTES[menuIndex];
activePresetIdx = menuIndex;
savePrefs();
startWaitingBoil();
break;
case MENU_SOUP: appState = STATE_SOUP_TIME; break;
case MENU_TIMER: enterCustomTime(); break;
case MENU_CALIBRATE: calBoilTemp = boilPoint(); appState = STATE_CALIBRATION; break;
}
break;
case STATE_SOUP_TIME:
activeMinutes = soupMinutes; activePresetIdx = MENU_SOUP;
savePrefs(); startWaitingBoil();
break;
case STATE_WAITING_BOIL:
soundStop();
appState = STATE_MENU;
Serial.println("WAITING_BOIL cancelled -> MENU");
break;
case STATE_COUNTDOWN:
soundStop();
appState = STATE_MENU;
Serial.println("COUNTDOWN cancelled -> MENU");
break;
case STATE_CALIBRATION:
calBoilTemp = constrain(calBoilTemp - 0.1f, 85.0f, 102.0f);
break;
case STATE_ALARM:
soundStop();
alarmReason = ALARM_DONE;
loadPrefs();
appState = STATE_MENU;
break;
default: break;
}
}
// ─────────────────────────────────────────────────
// TIMER SCREEN input — dedicated handler
// SET tap = +1 min, hold = accelerating +
// OK tap = -1 min, hold = accelerating -
// both tapped together = start
// both held >1.5s = exit to menu
// The chord window defers a single-button step long enough to tell a
// deliberate chord from a lone tap, so START no longer lands off-by-one.
// ─────────────────────────────────────────────────
void enterCustomTime() {
appState = STATE_CUSTOM_TIME;
setActive = okActive = false;
ctSetPend = ctOkPend = false;
ctChordActive = ctChordFired = false;
}
void handleCustomTime(unsigned long now) {
bool s = btnSet.isDown();
bool o = btnOk.isDown();
// ---------- both down = chord ----------
if (s && o) {
ctSetPend = ctOkPend = false; // it's a chord, not two taps
if (!ctChordActive) { ctChordActive = true; ctChordFired = false; ctChordStart = now; }
if (!ctChordFired && now - ctChordStart >= CHORD_EXIT_MS) {
ctChordFired = true; // long chord -> exit
setActive = okActive = false;
appState = STATE_MENU;
beepClick();
Serial.println("CUSTOM_TIME exit -> MENU");
}
return;
}
// ---------- chord released ----------
if (ctChordActive) {
ctChordActive = false;
setActive = okActive = false;
if (!ctChordFired) { // short chord -> start
activeMinutes = timerMinutes; activePresetIdx = MENU_TIMER;
savePrefs(); startWaitingBoil();
}
return;
}
// ---------- SET alone: +1 tap / accelerating hold ----------
if (btnSet.fell()) { ctSetPend = true; ctSetPendMs = now; }
if (ctSetPend && now - ctSetPendMs >= CHORD_WINDOW_MS) { // survived window -> real press
ctSetPend = false;
setActive = true; setHoldStart = ctSetPendMs; setLastRepeat = now;
onSetStep(1);
}
if (s && setActive) {
unsigned long held = now - setHoldStart;
unsigned long interval; int steps;
if (held < HOLD_P2_START) { interval = HOLD_PHASE1_MS; steps = 1; }
else if (held < HOLD_P3_START) { interval = HOLD_PHASE2_MS; steps = 5; }
else { interval = HOLD_PHASE3_MS; steps = 10; }
if (now - setLastRepeat >= interval) { setLastRepeat = now; onSetStep(steps); }
}
if (btnSet.rose()) {
if (ctSetPend) { ctSetPend = false; onSetStep(1); } // quick tap shorter than window
setActive = false;
}
// ---------- OK alone: -1 tap / accelerating hold ----------
if (btnOk.fell()) { ctOkPend = true; ctOkPendMs = now; }
if (ctOkPend && now - ctOkPendMs >= CHORD_WINDOW_MS) {
ctOkPend = false;
okActive = true; okHoldStart = ctOkPendMs; okLastRepeat = now;
onSetStep(-1);
}
if (o && okActive) {
unsigned long held = now - okHoldStart;
unsigned long interval; int steps;
if (held < HOLD_P2_START) { interval = HOLD_PHASE1_MS; steps = 1; }
else if (held < HOLD_P3_START) { interval = HOLD_PHASE2_MS; steps = 5; }
else { interval = HOLD_PHASE3_MS; steps = 10; }
if (now - okLastRepeat >= interval) { okLastRepeat = now; onSetStep(-steps); }
}
if (btnOk.rose()) {
if (ctOkPend) { ctOkPend = false; onSetStep(-1); }
okActive = false;
}
}
// ─────────────────────────────────────────────────
void startWaitingBoil() {
boilConfirm = 0;
dryConfirm = 0;
consecutiveBad = 0;
convPending = false;
convRequestMs = 0; // trigger first DS18B20 read immediately
appState = STATE_WAITING_BOIL;
tone(PIN_BUZZER, BUZZER_FREQ, 60);
Serial.printf("-> WAITING_BOIL %s %d min boilPt=%.2fC\n",
PRESET_NAMES[activePresetIdx], activeMinutes, boilPoint());
}
void startCountdown() {
appState = STATE_COUNTDOWN;
countdownEnd = millis() + (unsigned long)activeMinutes * 60UL * 1000UL;
dryConfirm = 0;
soundStart(CONFIRM_F, CONFIRM_D, CONFIRM_LEN, false);
Serial.println("Boil confirmed -> COUNTDOWN");
}
void triggerAlarm(int reason) {
alarmReason = reason;
appState = STATE_ALARM;
soundStart(ALARM_F, ALARM_D, ALARM_LEN, true);
Serial.printf("-> ALARM (%s)\n", reason == ALARM_DRY ? "DRY POT" : "READY");
}
void saveCalibration() {
savedBoilBase = calBoilTemp;
savedBoilPres = currentPressureHpa();
prefs.begin("clipegg", false);
prefs.putFloat("boil_base", savedBoilBase);
prefs.putFloat("boil_pres", savedBoilPres);
prefs.end();
appState = STATE_MENU;
soundStart(CONFIRM_F, CONFIRM_D, CONFIRM_LEN, false);
Serial.printf("Calibration saved: %.1fC @ %.1f hPa\n", savedBoilBase, savedBoilPres);
}
void fullReset() {
soundStop();
setActive = false;
okActive = false;
loadPrefs();
appState = STATE_MENU;
soundStart(RESET_F, RESET_D, RESET_LEN, false);
Serial.println("Full reset -> MENU");
}
// ─────────────────────────────────────────────────
// TEMPERATURE — async, polled whenever the reading matters
// ─────────────────────────────────────────────────
void pollTemperature(unsigned long now) {
bool needsTemp = (appState == STATE_WAITING_BOIL ||
appState == STATE_CALIBRATION ||
appState == STATE_COUNTDOWN ||
appState == STATE_ALARM);
if (!needsTemp) {
convPending = false;
convRequestMs = now - TEMP_POLL_MS;
return;
}
if (!convPending && now - convRequestMs >= TEMP_POLL_MS) {
tempSensor.requestTemperatures();
convRequestMs = now;
convPending = true;
return;
}
if (!convPending || now - convRequestMs < DS18B20_CONV_MS) return;
convPending = false;
float t = tempSensor.getTempCByIndex(0);
// Classify the reading. 85.0 is the DS18B20 power-on reset value; -127 is
// "disconnected"; both are garbage. A sustained 105-150C is a plausible
// real reading meaning the water is gone, not noise.
bool garbage = (t == DEVICE_DISCONNECTED_C || t == 85.0f || t < -10.0f || t > 150.0f);
if (garbage) {
if (consecutiveBad < 1000) consecutiveBad++;
Serial.printf("DS18B20 bad: %.2f (badN=%d)\n", t, consecutiveBad);
return; // keep last good currentTemp on screen
}
consecutiveBad = 0;
currentTemp = t;
#if DRY_POT_PROTECT
if (appState == STATE_WAITING_BOIL || appState == STATE_COUNTDOWN) {
if (currentTemp >= DRY_POT_TEMP) {
if (++dryConfirm >= DRY_POT_CONFIRM) {
Serial.println("DRY POT detected");
triggerAlarm(ALARM_DRY);
return;
}
} else {
dryConfirm = 0;
}
}
#endif
float threshold = boilPoint() - 0.3f;
Serial.printf("T=%.2f boilPt=%.2f thr=%.2f confirm=%d\n",
currentTemp, boilPoint(), threshold, boilConfirm);
if (appState == STATE_WAITING_BOIL) {
if (currentTemp >= threshold) {
if (++boilConfirm >= BOIL_CONFIRM_COUNT) startCountdown();
} else {
boilConfirm = 0;
}
}
// After the ready signal, pulling the probe out (temp drops) returns to menu.
if (appState == STATE_ALARM && currentTemp < (boilPoint() - 5.0f)) {
soundStop();
alarmReason = ALARM_DONE;
loadPrefs();
appState = STATE_MENU;
Serial.println("Probe removed -> MENU");
}
}
// ─────────────────────────────────────────────────
// DISPLAY
// ─────────────────────────────────────────────────
void drawMenu() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(25, 0);
display.print("PotWatch " FW_VERSION);
display.drawLine(0, 9, 127, 9, SSD1306_WHITE);
for (int i = 0; i < MENU_VISIBLE; i++) {
int idx = menuScroll + i;
if (idx >= MENU_COUNT) break;
int y = 12 + i * 13;
if (idx == menuIndex) {
display.fillRect(0, y-1, 128, 12, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
} else {
display.setTextColor(SSD1306_WHITE);
}
display.setCursor(4, y);
display.print(MENU_LABELS[idx]);
}
display.setTextColor(SSD1306_WHITE);
if (menuScroll > 0) { display.setCursor(120,10); display.print("^"); }
if (menuScroll + MENU_VISIBLE < MENU_COUNT) { display.setCursor(120,56); display.print("v"); }
display.display();
}
void drawSoupTime() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(22, 0); display.print("Soup timer");
display.drawLine(0, 9, 127, 9, SSD1306_WHITE);
char buf[10];
int h = soupMinutes/60, m = soupMinutes%60;
if (h > 0) snprintf(buf, sizeof(buf), "%d:%02d h", h, m);
else snprintf(buf, sizeof(buf), "%d min", soupMinutes);
display.setTextSize(3);
int16_t x1,y1; uint16_t w,ht;
display.getTextBounds(buf,0,0,&x1,&y1,&w,&ht);
display.setCursor((128-w)/2, 14); display.print(buf);
display.setTextSize(1);
display.setCursor(0,48); display.print("SET=+15min OK=start");
display.setCursor(0,56); display.print("holdOK=exit");
display.display();
}
void drawCustomTime() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(30, 0); display.print("Set timer");
display.drawLine(0, 9, 127, 9, SSD1306_WHITE);
char buf[10];
int h = timerMinutes/60, m = timerMinutes%60;
if (h > 0) snprintf(buf, sizeof(buf), "%d:%02d", h, m);
else snprintf(buf, sizeof(buf), "%d min", timerMinutes);
display.setTextSize(3);
int16_t x1,y1; uint16_t w,ht;
display.getTextBounds(buf,0,0,&x1,&y1,&w,&ht);
display.setCursor((128-w)/2, 14); display.print(buf);
display.setTextSize(1);
display.setCursor(0,48); display.print("SET + OK - hold=fast");
display.setCursor(0,56); display.print("both=start hold both=exit");
display.display();
}
void drawCalibration() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
// Title
display.setTextSize(1);
display.setCursor(28, 0); display.print("Calibration");
display.drawLine(0, 9, 127, 9, SSD1306_WHITE);
// Vertical divider splitting screen in half
display.drawLine(63, 10, 63, 44, SSD1306_WHITE);
// Column labels
display.setTextSize(1);
display.setCursor(16, 11); display.print("live");
display.setCursor(76, 11); display.print("boil pt");
int16_t x1, y1; uint16_t w, h;
char buf[8];
// Live temperature — left half, large number ("--" if probe is dead)
if (sensorDead()) snprintf(buf, sizeof(buf), "--");
else snprintf(buf, sizeof(buf), "%.1f", currentTemp);
display.setTextSize(2);
display.getTextBounds(buf, 0, 0, &x1, &y1, &w, &h);
display.setCursor((62 - (int)w) / 2, 21); display.print(buf);
display.setTextSize(1); display.print("C");
// Adjustable boil point — right half, large number
snprintf(buf, sizeof(buf), "%.1f", calBoilTemp);
display.setTextSize(2);
display.getTextBounds(buf, 0, 0, &x1, &y1, &w, &h);
display.setCursor(64 + (62 - (int)w) / 2, 21); display.print(buf);
display.setTextSize(1); display.print("C");
// Bottom separator + hints
display.drawLine(0, 44, 127, 44, SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 47); display.print("SET=+0.1 OK=-0.1");
display.setCursor(0, 56); display.print("holdSET=save holdOK=exit");
display.display();
}
void drawWaitingBoil() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
char top[22];
snprintf(top, sizeof(top), "Heat > %s", PRESET_NAMES[activePresetIdx]);
display.setCursor(0, 0); display.print(top);
display.drawLine(0, 9, 127, 9, SSD1306_WHITE);
if (sensorDead()) {
display.setTextSize(2);
display.setCursor(4, 20); display.print("CHECK");
display.setCursor(4, 40); display.print("PROBE");
display.display();
return;
}
char buf[12];
snprintf(buf, sizeof(buf), "%.1fC", currentTemp);
display.setTextSize(2);
display.setCursor(8, 12); display.print(buf);
display.setTextSize(1);
snprintf(buf, sizeof(buf), "Boil pt: %.1fC", boilPoint());
display.setCursor(0, 34); display.print(buf);
char tbuf[18];
int th=activeMinutes/60, tm=activeMinutes%60;
if (th > 0) snprintf(tbuf, sizeof(tbuf), "Timer: %d:%02d h", th, tm);
else snprintf(tbuf, sizeof(tbuf), "Timer: %d min", activeMinutes);
display.setCursor(0, 44); display.print(tbuf);
float frac = constrain((currentTemp-20.0f)/(boilPoint()-20.0f), 0.0f, 1.0f);
int barW = (int)(frac * 124);
display.drawRect(2, 55, 124, 7, SSD1306_WHITE);
display.fillRect(2, 55, barW, 7, SSD1306_WHITE);
display.display();
}
void drawCountdown(unsigned long now) {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
unsigned long remaining = (countdownEnd > now) ? (countdownEnd - now) : 0;
char buf[8];
snprintf(buf, sizeof(buf), "%d:%02d", (int)(remaining/60000), (int)((remaining%60000)/1000));
display.setTextSize(3);
int16_t x1,y1; uint16_t w,h;
display.getTextBounds(buf,0,0,&x1,&y1,&w,&h);
display.setCursor((128-w)/2, 8); display.print(buf);
unsigned long total = (unsigned long)activeMinutes * 60UL * 1000UL;
float frac = constrain((float)(total-remaining)/(float)total, 0.0f, 1.0f);
int barW = (int)(frac * 124);
display.drawRect(2, 47, 124, 8, SSD1306_WHITE);
display.fillRect(2, 47, barW, 8, SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(20, 58); display.print("Water at temp!");
display.display();
}
void drawAlarm(unsigned long now) {
display.clearDisplay();
if ((now/400) % 2 == 0) {
display.drawRect(0, 0, 128, 64, SSD1306_WHITE);
display.drawRect(2, 2, 124, 60, SSD1306_WHITE);
display.setTextSize(2); display.setTextColor(SSD1306_WHITE);
const char* msg = (alarmReason == ALARM_DRY) ? "NO WATER" : "READY!";
int16_t x1,y1; uint16_t w,h;
display.getTextBounds(msg,0,0,&x1,&y1,&w,&h);
display.setCursor((128-w)/2, 14); display.print(msg);
display.setTextSize(1);
display.setCursor(34, 46); display.print("Press OK");
}
display.display();
}
// ─────────────────────────────────────────────────
// NVS (namespace kept "clipegg" so saved calibration survives the rebrand)
// ─────────────────────────────────────────────────
void savePrefs() {
prefs.begin("clipegg", false);
prefs.putInt("menu_idx", menuIndex);
prefs.putInt("soup_min", soupMinutes);
prefs.putInt("timer_min", timerMinutes);
prefs.end();
}
void loadPrefs() {
prefs.begin("clipegg", true);
menuIndex = prefs.getInt( "menu_idx", 0);
soupMinutes = prefs.getInt( "soup_min", 60);
timerMinutes = prefs.getInt( "timer_min", 5);
savedBoilBase = prefs.getFloat("boil_base", DEFAULT_BOIL_TEMP);
savedBoilPres = prefs.getFloat("boil_pres", DEFAULT_BOIL_PRES);
prefs.end();
menuIndex = constrain(menuIndex, 0, MENU_COUNT-1);
soupMinutes = constrain(soupMinutes, SOUP_MIN, SOUP_MAX);
timerMinutes = constrain(timerMinutes, TIMER_MIN,TIMER_MAX);
savedBoilBase = constrain(savedBoilBase, 85.0f, 102.0f);
menuScroll = (menuIndex >= MENU_VISIBLE) ? menuIndex - MENU_VISIBLE + 1 : 0;
}