From f1bf34ab97066f57ee5fac2ab5586f30bd189d91 Mon Sep 17 00:00:00 2001 From: Splatink Date: Sun, 15 Mar 2026 07:51:40 +0200 Subject: [PATCH] Added the Arduino INO file --- telly_bme.ino | 909 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 909 insertions(+) create mode 100644 telly_bme.ino diff --git a/telly_bme.ino b/telly_bme.ino new file mode 100644 index 0000000..fdd5632 --- /dev/null +++ b/telly_bme.ino @@ -0,0 +1,909 @@ +#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); + } +} \ No newline at end of file