1178 lines
46 KiB
C++
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);
|
|
}
|
|
}
|