Files
telly-bme/telly-bme-v1.9.ino

1178 lines
46 KiB
C++

/*
* telly-bme v1.9
* ESP32-C3 BME280 sensor shell with telnet interface
*/
#include <BME280I2C.h>
#include <Wire.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <NetworkClient.h>
#include <NetworkServer.h>
#include <Preferences.h>
#include <driver/gpio.h>
#include <ThreeWire.h>
#include <RtcDS1302.h>
#include <LittleFS.h>
#include <HTTPClient.h>
#include <lwip/sockets.h>
#include <lwip/netdb.h>
#include <lwip/icmp.h>
#include <lwip/ip.h>
#include <lwip/ip_addr.h>
#include <math.h>
// ── Pin definitions ───────────────────────────────────────────────────────────
#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
// ── Config ────────────────────────────────────────────────────────────────────
#define VERSION "1.9"
#define NAME "telly-bme"
#define AP_SSID "telly-bme-setup"
#define RESET_HOLD_MS 10000
#define BLINK_MS 150
#define BATT_MAX_V 4.20f
#define BATT_MIN_V 3.00f
#define BATT_CAPACITY 3000.0f
#define BATT_SLEEP_MA 0.0095f
#define BATT_AWAKE_MA 50.0f
#define BATT_WAKE_S 60.0f
#define BATT_WAKES_PER_HR 1.0f
#define ADC_SAMPLES 16
#define ADC_VREF 3.3f
#define ADC_RESOLUTION 4095.0f
#define DIVIDER_RATIO 2.0f
#define ADC_CAL 0.909f
#define HISTORY_SIZE 10
#define MAX_CMD_LEN 128
#define ED_MAX_LINES 200
#define ED_MAX_LINE_LEN 256
#define ED_MAX_FILENAME 64
#define IAC 255
// ── Key codes ────────────────────────────────────────────────────────────────
#define KEY_NONE 0
#define KEY_UP 1001
#define KEY_DOWN 1002
#define KEY_LEFT 1003
#define KEY_RIGHT 1004
#define KEY_HOME 1005
#define KEY_END 1006
#define KEY_DEL 1007
#define KEY_BACKSPACE 1008
#define KEY_ENTER 1009
#define KEY_ESC 1010
// ── TelnetStream ─────────────────────────────────────────────────────────────
struct TelnetStream {
NetworkClient *client = nullptr;
void begin(NetworkClient &c) { client = &c; }
void flushNegotiation() {
unsigned long start = millis();
while (millis() - start < 500) {
while (client->available()) {
uint8_t b = client->read();
if (b == IAC) {
unsigned long t = millis();
while (client->available() < 2 && millis() - t < 100);
if (client->available() >= 2) { client->read(); client->read(); }
}
}
delay(10);
}
}
int readRaw() {
while (client->available()) {
uint8_t b = client->read();
if (b == IAC) {
unsigned long t = millis();
while (client->available() < 2 && millis() - t < 50);
if (client->available() >= 2) { client->read(); client->read(); }
continue;
}
return b;
}
return -1;
}
int readRawTimeout(int timeoutMs = 150) {
unsigned long start = millis();
while (millis() - start < (unsigned long)timeoutMs) {
int b = readRaw();
if (b >= 0) return b;
yield();
}
return -1;
}
int readKey() {
int b = readRaw();
if (b < 0) return KEY_NONE;
if (b == 0x1B) {
int b2 = readRawTimeout(80);
if (b2 < 0) return KEY_ESC;
if (b2 == '[') {
int b3 = readRawTimeout(80);
if (b3 < 0) return KEY_ESC;
if (b3 >= '0' && b3 <= '9') {
int num = b3 - '0';
int b4 = readRawTimeout(80);
while (b4 >= '0' && b4 <= '9') { num = num*10+(b4-'0'); b4=readRawTimeout(80); }
switch (num) {
case 1: case 7: return KEY_HOME;
case 4: case 8: return KEY_END;
case 3: return KEY_DEL;
default: return KEY_NONE;
}
}
switch (b3) {
case 'A': return KEY_UP; case 'B': return KEY_DOWN;
case 'C': return KEY_RIGHT; case 'D': return KEY_LEFT;
case 'H': return KEY_HOME; case 'F': return KEY_END;
default: return KEY_NONE;
}
}
if (b2 == 'O') {
int b3 = readRawTimeout(80);
switch (b3) {
case 'H': return KEY_HOME; case 'F': return KEY_END;
case 'A': return KEY_UP; case 'B': return KEY_DOWN;
case 'C': return KEY_RIGHT;case 'D': return KEY_LEFT;
default: return KEY_NONE;
}
}
return KEY_ESC;
}
if (b == '\r') { readRawTimeout(20); return KEY_ENTER; }
if (b == '\n') return KEY_ENTER;
if (b == 0x7F || b == 0x08) return KEY_BACKSPACE;
return b;
}
} ts;
// ── Persistent config ─────────────────────────────────────────────────────────
Preferences prefs;
struct NetConfig {
char ssid[64], pass[64];
bool staticIP, configured;
uint32_t ip, gw, sn;
};
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, rtcGW, rtcSN, 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, 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() {
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); }
return (sum/(float)ADC_SAMPLES/ADC_RESOLUTION)*ADC_VREF*DIVIDER_RATIO*ADC_CAL;
}
int batteryPercent(float v) {
if (v>=BATT_MAX_V) return 100;
if (v<=BATT_MIN_V) return 0;
struct { float v; int p; } c[] = {
{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}
};
for (int i=0;i<10;i++)
if (v<=c[i].v&&v>=c[i+1].v)
return (int)(c[i+1].p+(v-c[i+1].v)/(c[i].v-c[i+1].v)*(c[i].p-c[i+1].p));
return 0;
}
float estimateRuntime(float v) {
float rem=BATT_CAPACITY*(batteryPercent(v)/100.0f);
float awake=BATT_WAKES_PER_HR*BATT_WAKE_S;
float avg=(BATT_AWAKE_MA*awake+BATT_SLEEP_MA*(3600.0f-awake))/3600.0f;
return avg>0?rem/avg:0;
}
String batteryInline() {
float v=readBatteryVoltage();
char buf[24]; snprintf(buf,sizeof(buf),"%.2fV %d%%",v,batteryPercent(v));
return String(buf);
}
// ── RTC ───────────────────────────────────────────────────────────────────────
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 yr=s.substring(0,4).toInt(),mo=s.substring(5,7).toInt(),
dy=s.substring(8,10).toInt(),hr=s.substring(11,13).toInt(),
mn=s.substring(14,16).toInt(),sc=s.substring(17,19).toInt();
if (yr<2020||yr>2099||mo<1||mo>12||dy<1||dy>31||hr>23||mn>59||sc>59) return false;
out=RtcDateTime(yr-2000,mo,dy,hr,mn,sc); return true;
}
// ── Arg parser ────────────────────────────────────────────────────────────────
int parseArgs(const String &str, String *args, int maxArgs) {
int count=0,i=0,len=str.length();
while (i<len&&count<maxArgs) {
while (i<len&&str[i]==' ') i++;
if (i>=len) break;
if (str[i]=='"') {
i++; String tok="";
while (i<len&&str[i]!='"') tok+=str[i++];
if (i<len) i++;
args[count++]=tok;
} else {
String tok="";
while (i<len&&str[i]!=' ') tok+=str[i++];
args[count++]=tok;
}
}
return count;
}
// ── Line editor ───────────────────────────────────────────────────────────────
struct History {
String entries[HISTORY_SIZE];
int count=0, head=0;
void push(const String &cmd) {
if (!cmd.length()) return;
if (count>0&&entries[(head-1+HISTORY_SIZE)%HISTORY_SIZE]==cmd) return;
entries[head]=cmd; head=(head+1)%HISTORY_SIZE;
if (count<HISTORY_SIZE) count++;
}
String get(int offset) {
if (offset<1||offset>count) return "";
return entries[(head-offset+HISTORY_SIZE*10)%HISTORY_SIZE];
}
} cmdHistory;
void printPrompt(NetworkClient &client) {
client.print(apMode ? "[setup] ~> " : "~> ");
}
static void redrawLine(NetworkClient &client, const String &buf, int cursor) {
client.print("\r");
client.print(apMode ? "[setup] ~> " : "~> ");
client.print(buf);
client.print("\033[K");
int tail=buf.length()-cursor;
if (tail>0) client.printf("\033[%dD",tail);
}
String readCommand(NetworkClient &client) {
String buf=""; int cursor=0, histOff=0; String savedBuf="";
while (client.connected()) {
blinkTick();
int k=ts.readKey();
if (k==KEY_NONE) { yield(); continue; }
switch (k) {
case KEY_ENTER: {
client.print("\r\n");
String cmd=buf; cmd.trim();
cmdHistory.push(cmd); return cmd;
}
case KEY_BACKSPACE:
if (cursor>0) { buf.remove(cursor-1,1); cursor--; redrawLine(client,buf,cursor); }
break;
case KEY_DEL:
if (cursor<(int)buf.length()) { buf.remove(cursor,1); redrawLine(client,buf,cursor); }
break;
case KEY_LEFT:
if (cursor>0) { cursor--; client.print("\033[D"); } break;
case KEY_RIGHT:
if (cursor<(int)buf.length()) { cursor++; client.print("\033[C"); } break;
case KEY_HOME:
if (cursor>0) { client.printf("\033[%dD",cursor); cursor=0; } break;
case KEY_END:
if (cursor<(int)buf.length()) {
client.printf("\033[%dC",buf.length()-cursor); cursor=buf.length();
} break;
case KEY_UP:
if (histOff==0) savedBuf=buf;
if (histOff<cmdHistory.count) {
histOff++; buf=cmdHistory.get(histOff); cursor=buf.length();
redrawLine(client,buf,cursor);
} break;
case KEY_DOWN:
if (histOff>0) {
histOff--;
buf=(histOff==0)?savedBuf:cmdHistory.get(histOff);
cursor=buf.length(); redrawLine(client,buf,cursor);
} break;
case KEY_ESC:
buf=""; cursor=0; redrawLine(client,buf,cursor); break;
default:
if (k>=0x20&&k<0x7F&&(int)buf.length()<MAX_CMD_LEN) {
buf=buf.substring(0,cursor)+(char)k+buf.substring(cursor);
cursor++;
if (cursor==(int)buf.length()) client.write((char)k);
else redrawLine(client,buf,cursor);
}
break;
}
}
return "";
}
// ── 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)
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();
}
// ── ed-style line editor ──────────────────────────────────────────────────────
struct Ed {
String lines[ED_MAX_LINES];
int numLines = 0;
int cur = 0; // current line (0-indexed)
bool modified = false;
char filename[ED_MAX_FILENAME] = "";
void init(const char *fname) {
numLines=0; cur=0; modified=false;
strncpy(filename,fname,ED_MAX_FILENAME-1);
File f=LittleFS.open(filename,"r");
if (f) {
while (f.available()&&numLines<ED_MAX_LINES) {
String l=f.readStringUntil('\n'); l.replace("\r","");
lines[numLines++]=l;
}
f.close();
}
if (numLines==0) { lines[0]=""; numLines=1; }
cur=numLines-1;
}
bool save() {
File f=LittleFS.open(filename,"w");
if (!f) return false;
for (int i=0;i<numLines;i++) { f.print(lines[i]); f.print("\n"); }
f.close(); modified=false; return true;
}
// ── Address parsing ───────────────────────────────────────────────────────
// Resolves a single address token: number, '.', '$', or empty (returns cur)
// Returns -1 on error
int resolveAddr(const String &s) {
if (s==""||s==".") return cur;
if (s=="$") return numLines-1;
int n=s.toInt();
if (n<1||n>numLines) return -1;
return n-1;
}
// Parse "addr1,addr2cmd args" or "addrcmd args"
// Fills a1,a2 (0-indexed line range) and cmd char and rest of args
// Returns false if unparseable
bool parseCommand(const String &input, int &a1, int &a2, char &cmd, String &args) {
a1=cur; a2=cur; args="";
int i=0; int len=input.length();
if (len==0) return false;
// Read first address
String addrBuf="";
while (i<len&&(isDigit(input[i])||input[i]=='.'||input[i]=='$')) addrBuf+=input[i++];
bool hasAddr=addrBuf.length()>0;
int addr1=resolveAddr(addrBuf);
if (hasAddr&&addr1<0) return false;
if (hasAddr) { a1=addr1; a2=addr1; }
// Check for comma (range)
if (i<len&&input[i]==',') {
i++;
String addr2Buf="";
while (i<len&&(isDigit(input[i])||input[i]=='.'||input[i]=='$')) addr2Buf+=input[i++];
// % is shorthand for 1,$
int addr2=resolveAddr(addr2Buf==""?"$":addr2Buf);
if (addr2<0) return false;
a2=addr2;
}
// % shorthand for whole file
if (!hasAddr&&i<len&&input[i]=='%') {
a1=0; a2=numLines-1; i++;
}
if (i>=len) { cmd='\0'; return true; }
cmd=input[i++];
args=input.substring(i); args.trim();
return true;
}
// ── Read input lines until lone "." ───────────────────────────────────────
void readInputLines(NetworkClient &cl, String *out, int &count, int maxLines) {
count=0;
cl.print(" (end with a line containing only .)\r\n");
while (cl.connected()&&count<maxLines) {
cl.print(" ");
String line=readCommand(cl);
if (line==".") break;
out[count++]=line;
}
}
// ── Substitute s/from/to/[g] on a single line ─────────────────────────────
bool substitute(String &line, const String &from, const String &to, bool global) {
if (from.length()==0) return false;
bool changed=false;
if (global) {
int pos=0;
String result="";
while (pos<=(int)line.length()) {
int found=line.indexOf(from,pos);
if (found<0) { result+=line.substring(pos); break; }
result+=line.substring(pos,found)+to;
pos=found+from.length();
changed=true;
if (from.length()==0) break;
}
if (changed) line=result;
} else {
int found=line.indexOf(from);
if (found>=0) {
line=line.substring(0,found)+to+line.substring(found+from.length());
changed=true;
}
}
return changed;
}
// Parse s/from/to/[g]
bool parseSubst(const String &args, String &from, String &to, bool &global) {
if (args.length()<2||args[0]!='/') return false;
char delim=args[0];
int p1=args.indexOf(delim,1);
if (p1<0) return false;
int p2=args.indexOf(delim,p1+1);
if (p2<0) return false;
from=args.substring(1,p1);
to=args.substring(p1+1,p2);
global=(args.length()>p2+1&&args[p2+1]=='g');
return true;
}
// ── Main run loop ─────────────────────────────────────────────────────────
void run(NetworkClient &cl) {
cl.printf(" \"%s\" %d lines\r\n", filename, numLines);
cl.print(" Commands: p n = i a c d s/// m w q q! (type 'h' for help)\r\n");
while (cl.connected()) {
cl.print(":");
String input=readCommand(cl);
if (!cl.connected()) break;
input.trim();
if (input.length()==0) {
// bare Enter — advance one line and print it
if (cur<numLines-1) cur++;
cl.printf("%s\r\n",lines[cur].c_str());
continue;
}
// Special: h for help
if (input=="h") {
cl.print(
" Addressing\r\n"
" . current line\r\n"
" $ last line\r\n"
" N line number\r\n"
" N,M line range\r\n"
" % all lines (same as 1,$)\r\n"
" Commands\r\n"
" p print line(s)\r\n"
" n print line(s) with line numbers\r\n"
" = print line count / current line number\r\n"
" i insert line(s) before current (end with .)\r\n"
" a append line(s) after current (end with .)\r\n"
" c change (replace) line(s) with new input\r\n"
" d delete line(s)\r\n"
" m N move line(s) after line N\r\n"
" s/x/y/ substitute x with y on current line\r\n"
" s/x/y/g substitute all occurrences\r\n"
" N,Ms/x/y/g substitute over range\r\n"
" w save file\r\n"
" q quit (warns if unsaved)\r\n"
" q! force quit\r\n"
" wq save and quit\r\n"
);
continue;
}
int a1,a2; char cmd; String args;
if (!parseCommand(input,a1,a2,cmd,args)) {
cl.print(" ?\r\n"); continue;
}
// Validate range
if (a1<0||a1>=numLines||a2<a1||a2>=numLines) {
cl.print(" ? (address out of range)\r\n"); continue;
}
switch (cmd) {
case '\0':
// Just an address — print that line and move there
cur=a2;
cl.printf("%s\r\n",lines[cur].c_str());
break;
case 'p':
for (int i=a1;i<=a2;i++) cl.printf("%s\r\n",lines[i].c_str());
cur=a2;
break;
case 'n':
for (int i=a1;i<=a2;i++) cl.printf("%4d %s\r\n",i+1,lines[i].c_str());
cur=a2;
break;
case '=':
if (input=="=")
cl.printf(" %d lines total, current line %d\r\n",numLines,cur+1);
else
cl.printf(" %d\r\n",a2+1);
break;
case 'i': {
String newLines[ED_MAX_LINES]; int count=0;
readInputLines(cl,newLines,count,ED_MAX_LINES-numLines);
if (count>0) {
// Shift lines down
for (int i=numLines+count-1;i>=a1+count;i--) lines[i]=lines[i-count];
for (int i=0;i<count;i++) lines[a1+i]=newLines[i];
numLines+=count; cur=a1+count-1; modified=true;
}
break;
}
case 'a': {
String newLines[ED_MAX_LINES]; int count=0;
readInputLines(cl,newLines,count,ED_MAX_LINES-numLines);
int insertAt=a2+1;
if (count>0) {
for (int i=numLines+count-1;i>=insertAt+count;i--) lines[i]=lines[i-count];
for (int i=0;i<count;i++) lines[insertAt+i]=newLines[i];
numLines+=count; cur=insertAt+count-1; modified=true;
}
break;
}
case 'c': {
// Delete range, insert new lines
int delCount=a2-a1+1;
String newLines[ED_MAX_LINES]; int count=0;
readInputLines(cl,newLines,count,ED_MAX_LINES-numLines+delCount);
// Delete old lines
for (int i=a1;i<numLines-delCount;i++) lines[i]=lines[i+delCount];
numLines-=delCount;
// Insert new
for (int i=numLines+count-1;i>=a1+count;i--) lines[i]=lines[i-count];
for (int i=0;i<count;i++) lines[a1+i]=newLines[i];
numLines+=count;
if (numLines==0) { lines[0]=""; numLines=1; }
cur=count>0?a1+count-1:max(0,a1-1);
modified=true;
break;
}
case 'd': {
int delCount=a2-a1+1;
for (int i=a1;i<numLines-delCount;i++) lines[i]=lines[i+delCount];
numLines-=delCount;
if (numLines==0) { lines[0]=""; numLines=1; }
cur=min(a1,numLines-1);
modified=true;
cl.printf(" (%d line%s deleted)\r\n",delCount,delCount==1?"":"s");
break;
}
case 'm': {
// Move lines a1-a2 after line dest
int dest=args.toInt()-1;
if (dest<-1||dest>=numLines||(dest>=a1&&dest<=a2)) {
cl.print(" ? (invalid destination)\r\n"); break;
}
int count=a2-a1+1;
// Copy lines to temp
String tmp[ED_MAX_LINES];
for (int i=0;i<count;i++) tmp[i]=lines[a1+i];
// Remove from original position
for (int i=a1;i<numLines-count;i++) lines[i]=lines[i+count];
numLines-=count;
// Adjust dest for removal
int insertAt=dest>a2?dest-count+1:dest+1;
if (insertAt<0) insertAt=0;
// Insert
for (int i=numLines+count-1;i>=insertAt+count;i--) lines[i]=lines[i-count];
for (int i=0;i<count;i++) lines[insertAt+i]=tmp[i];
numLines+=count;
cur=insertAt+count-1; modified=true;
break;
}
case 's': {
String from,to; bool global=false;
if (!parseSubst(args,from,to,global)) { cl.print(" ? (bad syntax, use s/from/to/ or s/from/to/g)\r\n"); break; }
int changed=0;
for (int i=a1;i<=a2;i++) if (substitute(lines[i],from,to,global)) changed++;
if (changed) { modified=true; cl.printf(" (%d line%s changed)\r\n",changed,changed==1?"":"s"); }
else cl.print(" (no match)\r\n");
cur=a2;
break;
}
case 'w':
if (save()) cl.printf(" \"%s\" %d lines written\r\n",filename,numLines);
else cl.print(" ? (write failed)\r\n");
break;
case 'q':
if (modified) { cl.print(" ? (unsaved changes — use q! to force quit or w to save)\r\n"); break; }
cl.print("\r\n"); return;
default:
cl.print(" ? (unknown command, type h for help)\r\n");
break;
}
// Handle wq as a combined command
if (input=="wq"||input=="wq!") {
if (save()) { cl.printf(" \"%s\" written\r\n",filename); cl.print("\r\n"); return; }
else { cl.print(" ? (write failed)\r\n"); }
}
if (input=="q!") { cl.print("\r\n"); return; }
}
}
} ed;
// ── File commands ─────────────────────────────────────────────────────────────
void cmdLs(NetworkClient &cl) {
File root=LittleFS.open("/");
if (!root||!root.isDirectory()) { cl.print("Error opening filesystem.\r\n"); return; }
cl.print("\r\n Files on flash:\r\n");
int n=0; File f=root.openNextFile();
while (f) {
cl.printf(" %-36s %6lu bytes\r\n",f.name(),f.size());
n++; f=root.openNextFile();
}
if (!n) cl.print(" (no files)\r\n");
size_t total=LittleFS.totalBytes(),used=LittleFS.usedBytes();
cl.printf("\r\n Used: %u / %u bytes (%.1f%%)\r\n\r\n",used,total,100.0f*used/total);
}
void cmdCat(NetworkClient &cl, const String &name) {
String path=name.startsWith("/")?name:"/"+name;
if (!LittleFS.exists(path)) { cl.printf("Not found: %s\r\n",path.c_str()); return; }
File f=LittleFS.open(path,"r");
if (!f) { cl.print("Error opening file.\r\n"); return; }
while (f.available()) { char c=f.read(); if (c=='\n') cl.print("\r\n"); else cl.write(c); }
f.close(); cl.print("\r\n");
}
void cmdRm(NetworkClient &cl, const String &name) {
String path=name.startsWith("/")?name:"/"+name;
if (!LittleFS.exists(path)) { cl.printf("Not found: %s\r\n",path.c_str()); return; }
cl.print(LittleFS.remove(path)?"Deleted.\r\n":"Error deleting.\r\n");
}
// ── ping ──────────────────────────────────────────────────────────────────────
void cmdPing(NetworkClient &cl, const String &host, int count=4) {
// Resolve hostname
struct hostent *he = gethostbyname(host.c_str());
if (!he) { cl.printf("ping: cannot resolve %s\r\n", host.c_str()); return; }
struct in_addr addr; memcpy(&addr, he->h_addr, sizeof(addr));
char ipStr[16]; inet_ntoa_r(addr, ipStr, sizeof(ipStr));
cl.printf("PING %s (%s)\r\n", host.c_str(), ipStr);
int sock = socket(AF_INET, SOCK_RAW, IP_PROTO_ICMP);
if (sock < 0) { cl.print("ping: failed to create socket (needs root?)\r\n"); return; }
struct timeval tv; tv.tv_sec=2; tv.tv_usec=0;
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
struct sockaddr_in dest;
memset(&dest,0,sizeof(dest));
dest.sin_family=AF_INET;
memcpy(&dest.sin_addr, he->h_addr, sizeof(dest.sin_addr));
int sent=0, received=0;
long minMs=99999, maxMs=0, totalMs=0;
for (int seq=1; seq<=count; seq++) {
// Build ICMP echo request
uint8_t pkt[64]; memset(pkt,0,sizeof(pkt));
struct icmp_echo_hdr *icmp=(struct icmp_echo_hdr*)pkt;
ICMPH_TYPE_SET(icmp, ICMP_ECHO);
ICMPH_CODE_SET(icmp, 0);
icmp->id = htons(0x1234);
icmp->seqno = htons(seq);
// Fill payload
for (int i=sizeof(struct icmp_echo_hdr);i<64;i++) pkt[i]=(uint8_t)(i&0xFF);
// Checksum
icmp->chksum=0;
uint32_t sum=0;
for (int i=0;i<64;i+=2) sum+=((uint16_t)pkt[i]<<8)|pkt[i+1];
while (sum>>16) sum=(sum&0xFFFF)+(sum>>16);
icmp->chksum=htons(~sum);
unsigned long t0=millis();
int s=sendto(sock,(void*)pkt,sizeof(pkt),0,(struct sockaddr*)&dest,sizeof(dest));
sent++;
if (s<0) { cl.printf("Request %d: send failed\r\n",seq); delay(1000); continue; }
// Wait for reply
uint8_t rbuf[128];
struct sockaddr_in from; socklen_t fromLen=sizeof(from);
int r=recvfrom(sock,rbuf,sizeof(rbuf),0,(struct sockaddr*)&from,&fromLen);
long ms=(long)(millis()-t0);
if (r>0) {
received++;
totalMs+=ms;
if (ms<minMs) minMs=ms;
if (ms>maxMs) maxMs=ms;
char fromIp[16]; inet_ntoa_r(from.sin_addr,fromIp,sizeof(fromIp));
cl.printf(" %d bytes from %s: seq=%d time=%ldms\r\n",r,fromIp,seq,ms);
} else {
cl.printf(" Request %d: timeout\r\n",seq);
}
if (seq<count) delay(1000);
blinkTick();
}
close(sock);
int loss=sent>0?(int)((sent-received)*100/sent):100;
cl.printf("--- %s ping statistics ---\r\n",host.c_str());
cl.printf(" %d sent, %d received, %d%% loss\r\n",sent,received,loss);
if (received>0)
cl.printf(" rtt min/avg/max = %ld/%ld/%ld ms\r\n",minMs,totalMs/received,maxMs);
cl.print("\r\n");
}
// ── curl ──────────────────────────────────────────────────────────────────────
void cmdCurl(NetworkClient &cl, const String &url) {
if (!url.startsWith("http://")&&!url.startsWith("https://")) {
cl.print("Usage: curl <url> (http:// or https://)\r\n"); return;
}
HTTPClient http;
http.begin(url);
http.setTimeout(10000);
http.addHeader("User-Agent", NAME "/" VERSION);
cl.printf("GET %s\r\n",url.c_str());
int code=http.GET();
if (code>0) {
cl.printf("HTTP %d\r\n",code);
// Print response headers we care about
String ct=http.header("Content-Type");
if (ct.length()) cl.printf("Content-Type: %s\r\n",ct.c_str());
cl.printf("Content-Length: %d\r\n\r\n",http.getSize());
// Stream response body, converting \n to \r\n
WiFiClient *stream=http.getStreamPtr();
int remaining=http.getSize();
unsigned long t=millis();
while ((remaining>0||remaining==-1)&&stream->connected()&&millis()-t<15000) {
blinkTick();
if (!stream->available()) { delay(10); continue; }
char c=stream->read();
if (remaining>0) remaining--;
if (c=='\n') cl.print("\r\n");
else cl.write(c);
}
cl.print("\r\n");
} else {
cl.printf("Error: %s\r\n",http.errorToString(code).c_str());
}
http.end();
}
// ── Sensor commands ───────────────────────────────────────────────────────────
void cmdRead(NetworkClient &cl, const String &sub) {
float t=bme.temp(),h=bme.hum(),p=bme.pres(),v=readBatteryVoltage();
int bp=batteryPercent(v);
if (sub==""||sub=="all")
cl.printf(" Temperature: %.1f C\r\n Humidity: %.0f %%\r\n"
" Pressure: %.0f hPa\r\n Battery: %.2fV %d%%\r\n",t,h,p,v,bp);
else if (sub=="temp") cl.printf("%.1f C\r\n",t);
else if (sub=="hum") cl.printf("%.0f %%\r\n",h);
else if (sub=="pressure") cl.printf("%.0f hPa\r\n",p);
else if (sub=="battery") cl.printf("%.2fV %d%%\r\n",v,bp);
else cl.printf("Unknown: '%s'. Try: temp hum pressure battery\r\n",sub.c_str());
}
void printBattery(NetworkClient &cl) {
float v=readBatteryVoltage(); int pct=batteryPercent(v);
float rt=estimateRuntime(v); int rd=(int)(rt/24),rh=(int)fmod(rt,24.0f);
String bar="["; for(int i=0;i<20;i++) bar+=(i<pct/5?"#":"-"); bar+="]";
cl.printf("\r\n Battery\r\n Voltage: %.2f V\r\n Charge: %s %d%%\r\n"
" Est. runtime: %dd %dh\r\n"
" (assumes %d wake/hr, %ds awake, %.1fmA awake, %.4fmA sleep)\r\n\r\n",
v,bar.c_str(),pct,rd,rh,
(int)BATT_WAKES_PER_HR,(int)BATT_WAKE_S,BATT_AWAKE_MA,BATT_SLEEP_MA);
}
// ── ifconfig ─────────────────────────────────────────────────────────────────
void cmdIfconfig(NetworkClient &cl, const String &args) {
if (!args.length()) {
if (apMode) {
cl.printf(" Mode: AP (setup)\r\n SSID: %s\r\n IP: %s\r\n\r\n",
AP_SSID,WiFi.softAPIP().toString().c_str()); return;
}
uint8_t *b=WiFi.BSSID();
cl.printf("\r\n Network\r\n Mode: STA\r\n IP mode: %s\r\n"
" Hostname: %s\r\n MAC: %s\r\n IP: %s\r\n"
" Subnet: %s\r\n Gateway: %s\r\n DNS: %s\r\n"
" SSID: %s\r\n BSSID: %02X:%02X:%02X:%02X:%02X:%02X\r\n"
" Channel: %d\r\n RSSI: %d dBm\r\n TX power: %d dBm\r\n"
" WiFi cache: %s\r\n\r\n",
netCfg.staticIP?"static":"DHCP",WiFi.getHostname(),
WiFi.macAddress().c_str(),WiFi.localIP().toString().c_str(),
WiFi.subnetMask().toString().c_str(),WiFi.gatewayIP().toString().c_str(),
WiFi.dnsIP().toString().c_str(),WiFi.SSID().c_str(),
b[0],b[1],b[2],b[3],b[4],b[5],
WiFi.channel(),WiFi.RSSI(),WiFi.getTxPower(),
rtcValid?"valid":"cold boot");
return;
}
if (args.startsWith("wifi ")) {
String rest=args.substring(5); rest.trim();
String t[2];
if (parseArgs(rest,t,2)<2) { cl.print("Usage: ifconfig wifi <ssid> <pass>\r\n"); return; }
strncpy(netCfg.ssid,t[0].c_str(),sizeof(netCfg.ssid)-1);
strncpy(netCfg.pass,t[1]=="open"?"":t[1].c_str(),sizeof(netCfg.pass)-1);
netCfg.configured=true; saveNetConfig(); rtcValid=false;
cl.printf("WiFi set to '%s'.\r\nGoodbye.\r\n",netCfg.ssid);
cl.stop(); delay(300); ESP.restart();
} else if (args=="ip dhcp") {
netCfg.staticIP=false; netCfg.ip=netCfg.gw=netCfg.sn=0;
saveNetConfig(); rtcValid=false;
cl.print("Switched to DHCP.\r\n"); cl.stop(); delay(300); ESP.restart();
} else if (args.startsWith("ip static ")) {
String rest=args.substring(10); rest.trim();
int s1=rest.indexOf(' '),s2=rest.indexOf(' ',s1+1);
if (s1<0||s2<0) { cl.print("Usage: ifconfig ip static <ip> <gw> <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))) { cl.print("Invalid IP.\r\n"); return; }
netCfg.staticIP=true; netCfg.ip=(uint32_t)ip; netCfg.gw=(uint32_t)gw; netCfg.sn=(uint32_t)sn;
saveNetConfig(); rtcValid=false;
cl.printf("Static IP set to %s.\r\n",ip.toString().c_str());
cl.stop(); delay(300); ESP.restart();
} else cl.print("Unknown ifconfig subcommand.\r\n");
}
// ── date ──────────────────────────────────────────────────────────────────────
void cmdDate(NetworkClient &cl, const String &args) {
if (!args.length()) {
RtcDateTime now;
if (!rtcGetDateTime(now)) { cl.print("RTC not set.\r\n"); return; }
cl.printf("%s\r\n",formatDateTime(now).c_str());
} else if (args.startsWith("set ")) {
String v=args.substring(4); v.trim(); RtcDateTime dt;
if (!parseDateTime(v,dt)) { cl.print("Format: YYYY-MM-DD HH:MM:SS\r\n"); return; }
rtcModule.SetIsWriteProtected(false); rtcModule.SetIsRunning(true);
rtcModule.SetDateTime(dt); rtcModule.SetIsWriteProtected(true);
RtcDateTime verify;
cl.print(rtcGetDateTime(verify)&&verify.Year()==dt.Year()
?"RTC set.\r\n":"Warning: RTC write may have failed.\r\n");
} else cl.print("Usage: date | date set YYYY-MM-DD HH:MM:SS\r\n");
}
// ── info ──────────────────────────────────────────────────────────────────────
void printInfo(NetworkClient &cl) {
unsigned long up=(millis()-bootTime)/1000;
RtcDateTime now; bool dv=rtcGetDateTime(now); float v=readBatteryVoltage();
cl.printf("\r\n [ %s v%s ]\r\n\r\n Device\r\n"
" Chip: ESP32-C3\r\n Uptime: %02luh %02lum %02lus\r\n"
" Free heap: %lu bytes\r\n Flash: %lu bytes\r\n"
" CPU freq: %lu MHz\r\n WiFi cache: %s\r\n"
" DS1302: %s\r\n Date/time: %s\r\n Battery: %.2fV %d%%\r\n\r\n",
NAME,VERSION,up/3600,(up%3600)/60,up%60,
ESP.getFreeHeap(),ESP.getFlashChipSize(),ESP.getCpuFreqMHz(),
rtcValid?"valid":"cold boot",
rtcModule.GetIsRunning()?"running":"stopped",
dv?formatDateTime(now).c_str():"not set",v,batteryPercent(v));
}
// ── Banner / help ─────────────────────────────────────────────────────────────
void printBanner(NetworkClient &cl) {
cl.printf("\r\n +---------------------------------+\r\n"
" | %s v%s |\r\n"
" | ESP32-C3 BME280 Sensor Shell |\r\n",NAME,VERSION);
if (apMode) cl.print(" | *** SETUP MODE *** |\r\n");
cl.print(" +---------------------------------+\r\n Type 'help' for commands.\r\n\r\n");
}
void printHelp(NetworkClient &cl) {
if (apMode) {
cl.print(" ifconfig wifi <ssid> <pass> — configure WiFi\r\n"
" ifconfig wifi <ssid> open — open WiFi\r\n"
" (quotes for spaces: \"My WiFi\" \"pass\")\r\n"
" help\r\n");
} else {
cl.print(" Sensor\r\n"
" read — all sensors\r\n"
" read temp|hum|pressure|battery\r\n"
" watch — live (q+Enter to stop)\r\n"
" battery — detail + runtime\r\n"
" Time\r\n"
" date\r\n"
" date set <YYYY-MM-DD HH:MM:SS>\r\n"
" Files\r\n"
" ls\r\n"
" vi <file> — line editor (type h inside for help)\r\n"
" cat <file>\r\n"
" rm <file>\r\n"
" Network\r\n"
" ping <host> [count]\r\n"
" curl <url>\r\n"
" ifconfig\r\n"
" ifconfig wifi <ssid> <pass>\r\n"
" ifconfig ip dhcp\r\n"
" ifconfig ip static <ip> <gw> <subnet>\r\n"
" System\r\n"
" info\r\n"
" help quit sleep\r\n");
}
}
// ── Button ────────────────────────────────────────────────────────────────────
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;
}
}
// ── Telnet negotiation ────────────────────────────────────────────────────────
void negotiateTelnet(NetworkClient &client) {
const uint8_t neg[] = {
255,251,1, // IAC WILL ECHO
255,251,3, // IAC WILL SUPPRESS-GO-AHEAD
255,253,3, // IAC DO SUPPRESS-GO-AHEAD
255,254,34, // IAC DONT LINEMODE
};
client.write(neg,sizeof(neg));
delay(100);
}
// ── Setup ─────────────────────────────────────────────────────────────────────
void setup() {
bootTime=millis();
pinMode(WAKE_PIN,INPUT_PULLUP);
pinMode(RUN_PIN,OUTPUT);
pinMode(BATT_PIN,INPUT);
analogReadResolution(12);
ledOff();
LittleFS.begin(true);
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();
}
// ── Loop ──────────────────────────────────────────────────────────────────────
void loop() {
blinkTick();
checkButton();
NetworkClient client=server.accept();
if (!client) return;
ts.begin(client);
negotiateTelnet(client);
ts.flushNegotiation();
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") cmdRead(client,"");
else if (cmd.startsWith("read ")) cmdRead(client,cmd.substring(5));
else if (cmd=="watch") {
client.print("q + Enter to stop.\r\n");
while (client.connected()) {
blinkTick();
client.printf("\rT: %.1fC H: %.0f%% P: %.0fhPa Batt: %s ",
bme.temp(),bme.hum(),bme.pres(),batteryInline().c_str());
bool quit=false;
unsigned long wt=millis(); String wbuf="";
while (millis()-wt<1000&&!quit) {
blinkTick(); int k=ts.readKey();
if (k==KEY_NONE) { yield(); continue; }
if (k==KEY_ENTER) { wbuf.trim(); if(wbuf=="q") quit=true; wbuf=""; }
else if (k>=' '&&k<0x7F) wbuf+=(char)k;
}
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=="ls") cmdLs(client);
else if (cmd.startsWith("vi ")) {
String fn=cmd.substring(3); fn.trim();
if (!fn.startsWith("/")) fn="/"+fn;
ed.init(fn.c_str()); ed.run(client);
}
else if (cmd.startsWith("cat ")) cmdCat(client,cmd.substring(4));
else if (cmd.startsWith("rm ")) cmdRm(client,cmd.substring(3));
else if (cmd.startsWith("ping ")) {
String rest=cmd.substring(5); rest.trim();
String parts[2]; int n=parseArgs(rest,parts,2);
int count=(n>=2)?parts[1].toInt():4;
if (count<1||count>20) count=4;
cmdPing(client,parts[0],count);
}
else if (cmd.startsWith("curl ")) cmdCurl(client,cmd.substring(5));
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()) client.print("Unknown command. Type 'help'.\r\n");
} else if (cmd.length()) {
client.print("Unknown command. Use 'ifconfig wifi' or 'help'.\r\n");
}
if (client.connected()) printPrompt(client);
}
}