#include #include #include #include #include #include #include #include #include #define DATA 1 #define CLOCK 0 #define WAKE_PIN 2 #define RUN_PIN 8 #define BATT_PIN 3 #define RTC_CLK 6 #define RTC_DAT 4 #define RTC_RST 5 #define VERSION "1.6" #define NAME "telly-bme" #define AP_SSID "telly-bme-setup" #define RESET_HOLD_MS 10000 #define BLINK_MS 150 #define HISTORY_SIZE 10 #define MAX_CMD_LEN 128 // Battery constants #define BATT_MAX_V 4.20f // fully charged #define BATT_MIN_V 3.00f // TP4056 protection cutoff #define BATT_CAPACITY 3000.0f // mAh — typical 18650 #define BATT_SLEEP_MA 0.0095f // measured 9.5µA deep sleep #define BATT_AWAKE_MA 50.0f // measured ~50mA awake #define BATT_WAKE_S 60.0f // assumed awake window per wake #define BATT_WAKES_PER_HR 1.0f // assumed wakes per hour #define ADC_SAMPLES 16 // oversample for stability #define ADC_VREF 3.3f #define ADC_CAL 0.909f #define ADC_RESOLUTION 4095.0f #define DIVIDER_RATIO 2.0f // 100k+100k voltage divider #define IAC 255 // ── Persistent config ───────────────────────────────────────────────────────── Preferences prefs; struct NetConfig { char ssid[64]; char pass[64]; bool staticIP; uint32_t ip; uint32_t gw; uint32_t sn; bool configured; }; NetConfig netCfg; void loadNetConfig() { prefs.begin("net", true); netCfg.configured = prefs.getBool("configured", false); netCfg.staticIP = prefs.getBool("staticIP", false); netCfg.ip = prefs.getUInt("ip", 0); netCfg.gw = prefs.getUInt("gw", 0); netCfg.sn = prefs.getUInt("sn", 0); prefs.getString("ssid", netCfg.ssid, sizeof(netCfg.ssid)); prefs.getString("pass", netCfg.pass, sizeof(netCfg.pass)); prefs.end(); } void saveNetConfig() { prefs.begin("net", false); prefs.putBool("configured", netCfg.configured); prefs.putBool("staticIP", netCfg.staticIP); prefs.putUInt("ip", netCfg.ip); prefs.putUInt("gw", netCfg.gw); prefs.putUInt("sn", netCfg.sn); prefs.putString("ssid", netCfg.ssid); prefs.putString("pass", netCfg.pass); prefs.end(); } void eraseNetConfig() { prefs.begin("net", false); prefs.clear(); prefs.end(); memset(&netCfg, 0, sizeof(netCfg)); } // ── RTC DATA ATTR ───────────────────────────────────────────────────────────── RTC_DATA_ATTR bool rtcValid = false; RTC_DATA_ATTR uint8_t rtcBSSID[6]; RTC_DATA_ATTR int32_t rtcChannel; RTC_DATA_ATTR uint32_t rtcIP; RTC_DATA_ATTR uint32_t rtcGW; RTC_DATA_ATTR uint32_t rtcSN; RTC_DATA_ATTR uint32_t rtcDNS; // ── Globals ─────────────────────────────────────────────────────────────────── BME280I2C bme; NetworkServer server(23); ThreeWire myWire(RTC_DAT, RTC_CLK, RTC_RST); RtcDS1302 rtcModule(myWire); unsigned long bootTime = 0; bool apMode = false; bool btnArmed = false; unsigned long btnSince = 0; // ── LED ─────────────────────────────────────────────────────────────────────── bool blinkActive = false; bool blinkState = false; unsigned long lastBlink = 0; void blinkTick() { if (!blinkActive) return; if (millis() - lastBlink >= BLINK_MS) { blinkState = !blinkState; digitalWrite(RUN_PIN, blinkState ? 0 : 1); lastBlink = millis(); } } void ledOn() { blinkActive = false; digitalWrite(RUN_PIN, 0); } void ledOff() { blinkActive = false; digitalWrite(RUN_PIN, 1); } void ledBlink() { blinkActive = true; lastBlink = millis(); } // ── Battery ─────────────────────────────────────────────────────────────────── float readBatteryVoltage() { // Discard first few samples to let ADC settle for (int i = 0; i < 5; i++) { analogRead(BATT_PIN); delay(10); } long sum = 0; for (int i = 0; i < ADC_SAMPLES; i++) { sum += analogRead(BATT_PIN); delay(2); } float avg = sum / (float)ADC_SAMPLES; float adcV = (avg / ADC_RESOLUTION) * ADC_VREF; float battV = adcV * DIVIDER_RATIO * ADC_CAL; return battV; } int batteryPercent(float voltage) { if (voltage >= BATT_MAX_V) return 100; if (voltage <= BATT_MIN_V) return 0; // LiPo discharge is not linear — approximate with a piecewise curve // based on typical 18650 discharge profile struct { float v; int pct; } curve[] = { { 4.20f, 100 }, { 4.10f, 90 }, { 4.00f, 80 }, { 3.90f, 70 }, { 3.80f, 60 }, { 3.70f, 50 }, { 3.60f, 40 }, { 3.50f, 30 }, { 3.40f, 20 }, { 3.20f, 10 }, { 3.00f, 0 }, }; int n = sizeof(curve) / sizeof(curve[0]); for (int i = 0; i < n - 1; i++) { if (voltage <= curve[i].v && voltage >= curve[i+1].v) { float t = (voltage - curve[i+1].v) / (curve[i].v - curve[i+1].v); return (int)(curve[i+1].pct + t * (curve[i].pct - curve[i+1].pct)); } } return 0; } // Returns estimated remaining runtime in hours based on: // - current battery percentage // - measured sleep/awake current // - assumed duty cycle (1 wake/hr, 60s awake) float estimateRuntime(float voltage) { int pct = batteryPercent(voltage); float remainingMah = BATT_CAPACITY * (pct / 100.0f); // Average current per hour: // awake portion: BATT_WAKES_PER_HR * BATT_WAKE_S seconds at BATT_AWAKE_MA // sleep portion: remainder of hour at BATT_SLEEP_MA float awakeSecsPerHr = BATT_WAKES_PER_HR * BATT_WAKE_S; float sleepSecsPerHr = 3600.0f - awakeSecsPerHr; float avgMa = ((BATT_AWAKE_MA * awakeSecsPerHr) + (BATT_SLEEP_MA * sleepSecsPerHr)) / 3600.0f; if (avgMa <= 0) return 0; return remainingMah / avgMa; } void printBattery(NetworkClient &client) { float voltage = readBatteryVoltage(); int pct = batteryPercent(voltage); float runtime = estimateRuntime(voltage); int rDays = (int)(runtime / 24); int rHours = (int)fmod(runtime, 24.0f); // Simple bar — 20 chars wide int filled = pct / 5; String bar = "["; for (int i = 0; i < 20; i++) bar += (i < filled ? "#" : "-"); bar += "]"; client.printf("\r\n Battery\r\n"); client.printf(" Voltage: %.2f V\r\n", voltage); client.printf(" Charge: %s %d%%\r\n", bar.c_str(), pct); client.printf(" Est. runtime: %dd %dh\r\n", rDays, rHours); client.printf(" (assumes %d wake/hr, %ds awake, %.1fmA awake, %.4fmA sleep)\r\n\r\n", (int)BATT_WAKES_PER_HR, (int)BATT_WAKE_S, BATT_AWAKE_MA, BATT_SLEEP_MA); } String batteryInline() { float voltage = readBatteryVoltage(); int pct = batteryPercent(voltage); char buf[32]; snprintf(buf, sizeof(buf), "%.2fV %d%%", voltage, pct); return String(buf); } // ── Quoted argument parser ──────────────────────────────────────────────────── int parseArgs(const String &str, String *args, int maxArgs) { int count = 0; int i = 0; int len = str.length(); while (i < len && count < maxArgs) { while (i < len && str[i] == ' ') i++; if (i >= len) break; if (str[i] == '"') { i++; String token = ""; while (i < len && str[i] != '"') token += str[i++]; if (i < len) i++; args[count++] = token; } else { String token = ""; while (i < len && str[i] != ' ') token += str[i++]; args[count++] = token; } } return count; } // ── RTC helpers ─────────────────────────────────────────────────────────────── bool rtcGetDateTime(RtcDateTime &out) { for (int i = 0; i < 3; i++) { if (rtcModule.GetIsRunning() && rtcModule.IsDateTimeValid()) { out = rtcModule.GetDateTime(); if (out.Year() >= 2020 && out.Year() <= 2099) return true; } delay(10); } return false; } String formatDateTime(const RtcDateTime &dt) { char buf[32]; snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d", dt.Year(), dt.Month(), dt.Day(), dt.Hour(), dt.Minute(), dt.Second()); return String(buf); } bool parseDateTime(const String &s, RtcDateTime &out) { if (s.length() < 19) return false; int year = s.substring(0, 4).toInt(); int month = s.substring(5, 7).toInt(); int day = s.substring(8, 10).toInt(); int hour = s.substring(11, 13).toInt(); int min = s.substring(14, 16).toInt(); int sec = s.substring(17, 19).toInt(); if (year < 2020 || year > 2099) return false; if (month < 1 || month > 12) return false; if (day < 1 || day > 31) return false; if (hour > 23 || min > 59 || sec > 59) return false; out = RtcDateTime(year - 2000, month, day, hour, min, sec); return true; } // ── Telnet helpers ──────────────────────────────────────────────────────────── void flushTelnetNegotiation(NetworkClient &client) { unsigned long start = millis(); while (millis() - start < 200) { while (client.available()) { uint8_t c = client.read(); if (c == IAC) { unsigned long t = millis(); while (client.available() < 2 && millis() - t < 100); if (client.available() >= 2) { client.read(); client.read(); } } } delay(10); } } struct History { String entries[HISTORY_SIZE]; int count = 0; int head = 0; // index where next entry goes (ring) void push(const String &cmd) { if (cmd.length() == 0) return; // Don't push duplicate of last entry if (count > 0) { int last = (head - 1 + HISTORY_SIZE) % HISTORY_SIZE; if (entries[last] == cmd) return; } entries[head] = cmd; head = (head + 1) % HISTORY_SIZE; if (count < HISTORY_SIZE) count++; } // offset 1 = most recent, 2 = one before that, etc. String get(int offset) { if (offset < 1 || offset > count) return ""; int idx = (head - offset + HISTORY_SIZE * 2) % HISTORY_SIZE; return entries[idx]; } }; History cmdHistory; // Redraw the current line in place static void redrawLine(NetworkClient &client, const String &buf, int cursor) { // Move to start of line, reprint prompt + buffer, clear tail, reposition client.print("\r"); client.print(apMode ? "[setup] ~> " : "~> "); client.print(buf); client.print("\033[K"); // erase to end of line // Move cursor back to correct position if not at end int tail = buf.length() - cursor; if (tail > 0) { client.printf("\033[%dD", tail); } } String readCommand(NetworkClient &client) { String buf = ""; int cursor = 0; // insertion point within buf int histOff = 0; // 0 = live buf, 1 = most recent history entry, etc. String savedBuf = ""; // preserves live edit when scrolling history // Escape sequence state enum EscState { ES_NONE, ES_ESC, ES_BRACKET } escState = ES_NONE; char escParam = 0; while (client.connected()) { blinkTick(); if (!client.available()) continue; uint8_t c = client.read(); // ── IAC telnet negotiation ──────────────────────────────────────────── if (c == IAC) { unsigned long t = millis(); while (client.available() < 2 && millis() - t < 100); if (client.available() >= 2) { client.read(); client.read(); } continue; } // ── Escape sequence parser ──────────────────────────────────────────── if (escState == ES_ESC) { if (c == '[') { escState = ES_BRACKET; escParam = 0; continue; } escState = ES_NONE; continue; } if (escState == ES_BRACKET) { escState = ES_NONE; switch (c) { case 'D': // left arrow if (cursor > 0) { cursor--; client.print("\033[D"); } continue; case 'C': // right arrow if (cursor < (int)buf.length()) { cursor++; client.print("\033[C"); } continue; case 'A': // up arrow — older history if (histOff == 0) savedBuf = buf; if (histOff < cmdHistory.count) { histOff++; buf = cmdHistory.get(histOff); cursor = buf.length(); redrawLine(client, buf, cursor); } continue; case 'B': // down arrow — newer history if (histOff > 0) { histOff--; buf = histOff == 0 ? savedBuf : cmdHistory.get(histOff); cursor = buf.length(); redrawLine(client, buf, cursor); } continue; case '3': // start of ESC[3~ (Delete key) — need to consume ~ escParam = '3'; escState = ES_BRACKET; // wait for ~ continue; case '~': // finish ESC[3~ — forward delete if (escParam == '3' && cursor < (int)buf.length()) { buf.remove(cursor, 1); redrawLine(client, buf, cursor); } escParam = 0; continue; default: continue; } } // ── Regular characters ──────────────────────────────────────────────── switch (c) { case 0x1B: // ESC escState = ES_ESC; break; case '\r': break; // ignore CR case '\n': { client.print("\r\n"); String cmd = buf; cmd.trim(); cmdHistory.push(cmd); return cmd; } case 0x7F: // backspace (DEL) case 0x08: // backspace (BS) if (cursor > 0) { buf.remove(cursor - 1, 1); cursor--; redrawLine(client, buf, cursor); } break; default: if (c >= 0x20 && c < 0x7F && (int)buf.length() < MAX_CMD_LEN) { buf = buf.substring(0, cursor) + (char)c + buf.substring(cursor); cursor++; // If inserting at end, just echo the char — cheaper than full redraw if (cursor == (int)buf.length()) { client.write(c); } else { redrawLine(client, buf, cursor); } } break; } } return ""; } void printPrompt(NetworkClient &client) { client.print(apMode ? "[setup] ~> " : "~> "); } // ── Sleep ───────────────────────────────────────────────────────────────────── void goToSleep() { ledOff(); while (digitalRead(WAKE_PIN) == LOW) delay(10); delay(50); esp_deep_sleep_enable_gpio_wakeup(1ULL << WAKE_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); esp_deep_sleep_start(); } // ── WiFi ────────────────────────────────────────────────────────────────────── void startAP() { apMode = true; rtcValid = false; WiFi.mode(WIFI_AP); WiFi.softAP(AP_SSID); server.begin(); ledBlink(); } void connectWiFi() { apMode = false; ledOff(); WiFi.mode(WIFI_STA); WiFi.setHostname("esp32-bme"); if (netCfg.staticIP && netCfg.ip != 0) { WiFi.config(IPAddress(netCfg.ip), IPAddress(netCfg.gw), IPAddress(netCfg.sn)); } else if (rtcValid) { WiFi.config(IPAddress(rtcIP), IPAddress(rtcGW), IPAddress(rtcSN), IPAddress(rtcDNS)); } if (rtcValid && !netCfg.staticIP) { WiFi.begin(netCfg.ssid, netCfg.pass, rtcChannel, rtcBSSID, true); } else { WiFi.begin(netCfg.ssid, netCfg.pass); } unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED) { delay(500); if (millis() - start > 15000) { rtcValid = false; startAP(); return; } } rtcChannel = WiFi.channel(); memcpy(rtcBSSID, WiFi.BSSID(), 6); rtcIP = (uint32_t)WiFi.localIP(); rtcGW = (uint32_t)WiFi.gatewayIP(); rtcSN = (uint32_t)WiFi.subnetMask(); rtcDNS = (uint32_t)WiFi.dnsIP(); rtcValid = true; server.begin(); ledOn(); } // ── Commands ────────────────────────────────────────────────────────────────── void printBanner(NetworkClient &client) { client.printf("\r\n"); client.printf(" +---------------------------------+\r\n"); client.printf(" | %s v%s |\r\n", NAME, VERSION); client.printf(" | ESP32-C3 BME280 Sensor Shell |\r\n"); if (apMode) client.printf(" | *** SETUP MODE *** |\r\n"); client.printf(" +---------------------------------+\r\n"); client.printf(" Type 'help' for available commands.\r\n\r\n"); } void printHelp(NetworkClient &client) { client.printf(" Commands:\r\n"); if (apMode) { client.printf(" ifconfig wifi — configure WiFi\r\n"); client.printf(" ifconfig wifi open — configure open WiFi\r\n"); client.printf(" (use quotes for spaces: \"My WiFi\" \"my password\")\r\n"); client.printf(" help — show this help\r\n"); } else { client.printf(" read — single sensor reading\r\n"); client.printf(" watch — live sensor + battery (q to stop)\r\n"); client.printf(" battery — battery voltage, charge and runtime\r\n"); client.printf(" date — show RTC date and time\r\n"); client.printf(" date set — set RTC\r\n"); client.printf(" info — device information\r\n"); client.printf(" ifconfig — show network info\r\n"); client.printf(" ifconfig wifi — change WiFi (reboots)\r\n"); client.printf(" ifconfig wifi open — open WiFi (reboots)\r\n"); client.printf(" ifconfig ip dhcp — use DHCP (reboots)\r\n"); client.printf(" ifconfig ip static — set static IP (reboots)\r\n"); client.printf(" (use quotes for spaces: \"My WiFi\" \"my password\")\r\n"); client.printf(" help — show this help\r\n"); client.printf(" quit — disconnect\r\n"); client.printf(" sleep — disconnect and deep sleep\r\n"); } } void cmdIfconfig(NetworkClient &client, const String &args) { if (args.length() == 0) { if (apMode) { client.printf(" Mode: Access Point (setup)\r\n"); client.printf(" AP SSID: %s\r\n", AP_SSID); client.printf(" AP IP: %s\r\n\r\n", WiFi.softAPIP().toString().c_str()); return; } uint8_t *bssid = WiFi.BSSID(); client.printf("\r\n Network\r\n"); client.printf(" Mode: STA\r\n"); client.printf(" IP mode: %s\r\n", netCfg.staticIP ? "static" : "DHCP"); client.printf(" Hostname: %s\r\n", WiFi.getHostname()); client.printf(" MAC: %s\r\n", WiFi.macAddress().c_str()); client.printf(" IP: %s\r\n", WiFi.localIP().toString().c_str()); client.printf(" Subnet: %s\r\n", WiFi.subnetMask().toString().c_str()); client.printf(" Gateway: %s\r\n", WiFi.gatewayIP().toString().c_str()); client.printf(" DNS: %s\r\n", WiFi.dnsIP().toString().c_str()); client.printf(" SSID: %s\r\n", WiFi.SSID().c_str()); client.printf(" BSSID: %02X:%02X:%02X:%02X:%02X:%02X\r\n", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); client.printf(" Channel: %d\r\n", WiFi.channel()); client.printf(" RSSI: %d dBm\r\n", WiFi.RSSI()); client.printf(" TX power: %d dBm\r\n", WiFi.getTxPower()); client.printf(" WiFi cache: %s\r\n\r\n", rtcValid ? "valid" : "cold boot"); return; } if (args.startsWith("wifi ")) { String rest = args.substring(5); rest.trim(); String tokens[2]; int count = parseArgs(rest, tokens, 2); if (count < 2) { client.print("Usage: ifconfig wifi | ifconfig wifi open\r\n"); client.print(" Use quotes for spaces: \"My WiFi\" \"my password\"\r\n"); return; } strncpy(netCfg.ssid, tokens[0].c_str(), sizeof(netCfg.ssid) - 1); strncpy(netCfg.pass, tokens[1] == "open" ? "" : tokens[1].c_str(), sizeof(netCfg.pass) - 1); netCfg.configured = true; saveNetConfig(); rtcValid = false; client.printf("WiFi set to '%s'. Connecting...\r\n", netCfg.ssid); delay(200); client.print("Goodbye.\r\n"); client.stop(); delay(300); ESP.restart(); } else if (args == "ip dhcp") { netCfg.staticIP = false; netCfg.ip = netCfg.gw = netCfg.sn = 0; saveNetConfig(); rtcValid = false; client.print("Switched to DHCP. Rebooting...\r\n"); client.stop(); delay(300); ESP.restart(); } else if (args.startsWith("ip static ")) { String rest = args.substring(10); rest.trim(); int s1 = rest.indexOf(' '); if (s1 == -1) { client.print("Usage: ifconfig ip static \r\n"); return; } int s2 = rest.indexOf(' ', s1 + 1); if (s2 == -1) { client.print("Usage: ifconfig ip static \r\n"); return; } IPAddress ip, gw, sn; if (!ip.fromString(rest.substring(0, s1)) || !gw.fromString(rest.substring(s1 + 1, s2)) || !sn.fromString(rest.substring(s2 + 1))) { client.print("Invalid IP address format.\r\n"); return; } netCfg.staticIP = true; netCfg.ip = (uint32_t)ip; netCfg.gw = (uint32_t)gw; netCfg.sn = (uint32_t)sn; saveNetConfig(); rtcValid = false; client.printf("Static IP set to %s. Rebooting...\r\n", ip.toString().c_str()); client.stop(); delay(300); ESP.restart(); } else { client.print("Unknown ifconfig subcommand. Type 'help' for usage.\r\n"); } } void cmdDate(NetworkClient &client, const String &args) { if (args.length() == 0) { RtcDateTime now; if (!rtcGetDateTime(now)) { client.print("RTC unavailable or not set. Use: date set YYYY-MM-DD HH:MM:SS\r\n"); return; } client.printf("%s\r\n", formatDateTime(now).c_str()); } else if (args.startsWith("set ")) { String value = args.substring(4); value.trim(); RtcDateTime dt; if (!parseDateTime(value, dt)) { client.print("Invalid format. Use: date set YYYY-MM-DD HH:MM:SS\r\n"); return; } rtcModule.SetIsWriteProtected(false); rtcModule.SetIsRunning(true); rtcModule.SetDateTime(dt); rtcModule.SetIsWriteProtected(true); RtcDateTime verify; if (rtcGetDateTime(verify) && verify.Year() == dt.Year()) { client.printf("RTC set to: %s\r\n", formatDateTime(dt).c_str()); } else { client.print("Warning: RTC write may have failed. Check wiring.\r\n"); } } else { client.print("Usage: date | date set YYYY-MM-DD HH:MM:SS\r\n"); } } void printInfo(NetworkClient &client) { unsigned long up = (millis() - bootTime) / 1000; unsigned long h = up / 3600; unsigned long m = (up % 3600) / 60; unsigned long s = up % 60; RtcDateTime now; bool dateValid = rtcGetDateTime(now); float voltage = readBatteryVoltage(); int pct = batteryPercent(voltage); client.printf("\r\n [ %s v%s ]\r\n\r\n", NAME, VERSION); client.printf(" Device\r\n"); client.printf(" Chip: ESP32-C3\r\n"); client.printf(" Uptime: %02luh %02lum %02lus\r\n", h, m, s); client.printf(" Free heap: %lu bytes\r\n", ESP.getFreeHeap()); client.printf(" Flash size: %lu bytes\r\n", ESP.getFlashChipSize()); client.printf(" CPU freq: %lu MHz\r\n", ESP.getCpuFreqMHz()); client.printf(" WiFi cache: %s\r\n", rtcValid ? "valid" : "cold boot"); client.printf(" DS1302: %s\r\n", rtcModule.GetIsRunning() ? "running" : "stopped"); client.printf(" Date/time: %s\r\n", dateValid ? formatDateTime(now).c_str() : "not set"); client.printf(" Battery: %.2fV %d%%\r\n\r\n", voltage, pct); } // ── Button handling ─────────────────────────────────────────────────────────── void checkButton() { if (digitalRead(WAKE_PIN) == LOW) { if (!btnArmed) { btnArmed = true; btnSince = millis(); } else if (millis() - btnSince > RESET_HOLD_MS) { eraseNetConfig(); rtcValid = false; startAP(); btnArmed = false; } } else { if (btnArmed && !apMode && (millis() - btnSince > 50)) { goToSleep(); } btnArmed = false; } } // ── Setup & loop ────────────────────────────────────────────────────────────── void setup() { bootTime = millis(); pinMode(WAKE_PIN, INPUT_PULLUP); pinMode(RUN_PIN, OUTPUT); pinMode(BATT_PIN, INPUT); analogReadResolution(12); ledOff(); rtcModule.Begin(); rtcModule.SetIsWriteProtected(false); if (!rtcModule.GetIsRunning()) rtcModule.SetIsRunning(true); rtcModule.SetIsWriteProtected(true); Wire.setPins(DATA, CLOCK); Wire.begin(); bme.begin(); loadNetConfig(); if (!netCfg.configured) { startAP(); } else { connectWiFi(); } } void loop() { blinkTick(); checkButton(); NetworkClient client = server.accept(); if (!client) return; flushTelnetNegotiation(client); printBanner(client); printPrompt(client); while (client.connected()) { blinkTick(); if (digitalRead(WAKE_PIN) == LOW) { if (!btnArmed) { btnArmed = true; btnSince = millis(); } else if (millis() - btnSince > RESET_HOLD_MS) { client.print("\r\nFactory reset — entering setup mode.\r\n"); client.stop(); eraseNetConfig(); rtcValid = false; startAP(); btnArmed = false; return; } } else { if (btnArmed && !apMode && (millis() - btnSince > 50)) { client.print("\r\nButton pressed — sleeping.\r\n"); client.stop(); goToSleep(); } btnArmed = false; } String cmd = readCommand(client); if (!client.connected()) break; if (cmd == "ifconfig") { cmdIfconfig(client, ""); } else if (cmd.startsWith("ifconfig ")) { cmdIfconfig(client, cmd.substring(9)); } else if (cmd == "help") { printHelp(client); } else if (!apMode) { if (cmd == "read") { float temp = bme.temp(); float hum = bme.hum(); float press = bme.pres(); client.printf("Temperature: %.1f C | Humidity: %.0f %% | Pressure: %.0f hPa\r\n", temp, hum, press); } else if (cmd == "watch") { client.print("q + Enter to stop.\r\n"); while (client.connected()) { blinkTick(); float temp = bme.temp(); float hum = bme.hum(); float press = bme.pres(); String batt = batteryInline(); client.printf("\rT: %.1f C | H: %.0f %% | P: %.0f hPa | Batt: %s ", temp, hum, press, batt.c_str()); bool quit = false; unsigned long t = millis(); String watchCmd = ""; while (millis() - t < 1000) { blinkTick(); if (digitalRead(WAKE_PIN) == LOW) { client.print("\r\nButton pressed — sleeping.\r\n"); client.stop(); goToSleep(); } if (client.available()) { char ch = client.read(); if (ch == '\r') continue; if (ch == '\n') { watchCmd.trim(); if (watchCmd == "q") { quit = true; break; } watchCmd = ""; } else if ((uint8_t)ch != IAC) { watchCmd += ch; } } delay(10); } if (quit) { client.print("\r\n"); break; } } } else if (cmd == "battery") { printBattery(client); } else if (cmd == "date") { cmdDate(client, ""); } else if (cmd.startsWith("date ")) { cmdDate(client, cmd.substring(5)); } else if (cmd == "info") { printInfo(client); } else if (cmd == "quit") { client.print("Goodbye.\r\n"); client.stop(); return; } else if (cmd == "sleep") { client.print("Sleeping...\r\n"); client.stop(); goToSleep(); } else if (cmd.length() > 0) { client.print("Unknown command.\r\n"); } } else if (cmd.length() > 0) { client.print("Unknown command. Use 'ifconfig wifi' to configure, or 'help'.\r\n"); } printPrompt(client); } }