Files
telly-bme/telly_bme.ino
2026-03-15 07:51:40 +02:00

909 lines
30 KiB
C++

#include <BME280I2C.h>
#include <Wire.h>
#include <WiFi.h>
#include <NetworkClient.h>
#include <NetworkServer.h>
#include <Preferences.h>
#include <driver/gpio.h>
#include <ThreeWire.h>
#include <RtcDS1302.h>
#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<ThreeWire> 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 <ssid> <pass> — configure WiFi\r\n");
client.printf(" ifconfig wifi <ssid> 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 <YYYY-MM-DD HH:MM:SS> — set RTC\r\n");
client.printf(" info — device information\r\n");
client.printf(" ifconfig — show network info\r\n");
client.printf(" ifconfig wifi <ssid> <pass> — change WiFi (reboots)\r\n");
client.printf(" ifconfig wifi <ssid> open — open WiFi (reboots)\r\n");
client.printf(" ifconfig ip dhcp — use DHCP (reboots)\r\n");
client.printf(" ifconfig ip static <ip> <gw> <sn> — 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 <ssid> <password> | ifconfig wifi <ssid> 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 <ip> <gateway> <subnet>\r\n"); return; }
int s2 = rest.indexOf(' ', s1 + 1);
if (s2 == -1) { client.print("Usage: ifconfig ip static <ip> <gateway> <subnet>\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);
}
}