Created
February 4, 2026 08:27
-
-
Save pral2a/2919ad436d2d370d3428dcb2200f9f77 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| LLUM 2026 # Underlight Light Controller | |
| ESP32-WROOM + HC-SR04 (x6) + Relays (x6) + Web UI + Persistent config + Debug logger | |
| */ | |
| #include <Arduino.h> | |
| #include <WiFi.h> | |
| #include <WebServer.h> | |
| #include <HCSR04.h> | |
| #include <Preferences.h> | |
| struct SensorFilter; | |
| // ============================================================ | |
| // Debug Logger | |
| // ============================================================ | |
| class DebugLog { | |
| public: | |
| enum Level : uint8_t { ERROR=0, WARN=1, INFO=2, DEBUG=3, VERBOSE=4 }; | |
| void begin(HardwareSerial &s, Level lvl) { serial=&s; level=lvl; } | |
| void setLevel(Level lvl) { level=lvl; } | |
| Level getLevel() const { return level; } | |
| void error(const String &m){ log(ERROR,"ERR",m); } | |
| void warn(const String &m){ log(WARN,"WRN",m); } | |
| void info(const String &m){ log(INFO,"INF",m); } | |
| void debug(const String &m){ log(DEBUG,"DBG",m); } | |
| void verbose(const String &m){ log(VERBOSE,"VRB",m); } | |
| private: | |
| HardwareSerial *serial=nullptr; | |
| Level level=INFO; | |
| void log(Level l,const char* tag,const String &msg){ | |
| if(!serial) return; | |
| if(l>level) return; | |
| serial->print("["); serial->print(millis()); serial->print("] "); | |
| serial->print(tag); serial->print(": "); | |
| serial->println(msg); | |
| } | |
| }; | |
| DebugLog LOG; | |
| // ============================================================ | |
| // Wi-Fi config (your provided values) | |
| // ============================================================ | |
| static const char* WIFI_SSID = "underlight"; | |
| static const char* WIFI_PASS = "stationiris"; | |
| static IPAddress LOCAL_IP(192, 168, 1, 50); | |
| static IPAddress GATEWAY(192, 168, 1, 1); | |
| static IPAddress SUBNET(255, 255, 255, 0); | |
| static IPAddress DNS1(1, 1, 1, 1); | |
| static IPAddress DNS2(8, 8, 8, 8); | |
| // ============================================================ | |
| // System sizing | |
| // ============================================================ | |
| static const uint8_t MAX_CHANNELS = 6; | |
| static const uint8_t NUM_SENSORS = 6; // set 1..6 | |
| static const uint8_t NUM_RELAYS = 6; // set 1..6 (can be <= NUM_SENSORS) | |
| // ============================================================ | |
| // HC-SR04 pins (your provided values) | |
| // ============================================================ | |
| static const int TRIG_PIN = 13; | |
| static int ECHO_PINS[MAX_CHANNELS] = { 14, 27, 26, 25, 33, 32 }; | |
| // ============================================================ | |
| // Relay pins (your provided values) | |
| // ============================================================ | |
| static const bool RELAY_ACTIVE_HIGH = true; | |
| static const int RELAY_PINS[MAX_CHANNELS] = { 15, 2, 4, 16, 17, 5 }; | |
| // ============================================================ | |
| // Animation pattern (10 steps => 1s total @100ms) | |
| // ============================================================ | |
| static const uint16_t ANIM_STEP_MS = 100; | |
| static const bool ANIM_PATTERN[10] = { 1,0,1,0,1,1,0,1,1,1 }; | |
| // ============================================================ | |
| // Timing | |
| // ============================================================ | |
| static const uint16_t SENSOR_CYCLE_DEFAULT_MS = 500; // >=60ms recommended | |
| static const uint16_t WIFI_CHECK_MS = 2000; | |
| // ============================================================ | |
| // Persistence (EEPROM-like) via Preferences/NVS | |
| // ============================================================ | |
| Preferences prefs; | |
| static const char* PREFS_NS = "barrier"; | |
| static const uint32_t CFG_MAGIC = 0xBADC0DE1; | |
| struct Config { | |
| uint32_t magic; | |
| // Sensing window | |
| float minCm; | |
| float maxCm; | |
| float hystCm; | |
| // Filtering | |
| uint8_t avgWindow; // 1..12 (we cap) | |
| uint8_t confirmSamples; // 1..10 (we cap) | |
| // Behaviour | |
| bool autoMode; | |
| bool animationsEnabled; | |
| // Sampling cadence | |
| uint16_t sensorCycleMs; // 60..300 typically | |
| // Debug | |
| uint8_t logLevel; // 0..4 | |
| }; | |
| Config cfg; | |
| static void setDefaultConfig() { | |
| cfg.magic = CFG_MAGIC; | |
| cfg.minCm = 1.0f; | |
| cfg.maxCm = 16.0f; | |
| cfg.hystCm = 3.0f; | |
| cfg.avgWindow = 8; | |
| cfg.confirmSamples = 3; | |
| cfg.autoMode = true; | |
| cfg.animationsEnabled = true; | |
| cfg.sensorCycleMs = SENSOR_CYCLE_DEFAULT_MS; | |
| cfg.logLevel = DebugLog::INFO; | |
| } | |
| static void clampConfig() { | |
| // Keep values sane for a robust installation | |
| if (cfg.minCm < 1.0f) cfg.minCm = 1.0f; | |
| if (cfg.maxCm < cfg.minCm + 1.0f) cfg.maxCm = cfg.minCm + 1.0f; | |
| if (cfg.hystCm < 0.0f) cfg.hystCm = 0.0f; | |
| if (cfg.hystCm > 30.0f) cfg.hystCm = 30.0f; | |
| if (cfg.avgWindow < 1) cfg.avgWindow = 1; | |
| if (cfg.avgWindow > 12) cfg.avgWindow = 12; | |
| if (cfg.confirmSamples < 1) cfg.confirmSamples = 1; | |
| if (cfg.confirmSamples > 10) cfg.confirmSamples = 10; | |
| if (cfg.sensorCycleMs < 60) cfg.sensorCycleMs = 60; | |
| if (cfg.sensorCycleMs > 300) cfg.sensorCycleMs = 300; | |
| if (cfg.logLevel > DebugLog::VERBOSE) cfg.logLevel = DebugLog::INFO; | |
| } | |
| static bool loadConfig() { | |
| prefs.begin(PREFS_NS, true); | |
| size_t len = prefs.getBytesLength("cfg"); | |
| if (len != sizeof(Config)) { | |
| prefs.end(); | |
| return false; | |
| } | |
| Config tmp; | |
| size_t got = prefs.getBytes("cfg", &tmp, sizeof(Config)); | |
| prefs.end(); | |
| if (got != sizeof(Config)) return false; | |
| if (tmp.magic != CFG_MAGIC) return false; | |
| cfg = tmp; | |
| clampConfig(); | |
| return true; | |
| } | |
| static bool saveConfig() { | |
| clampConfig(); | |
| prefs.begin(PREFS_NS, false); | |
| size_t put = prefs.putBytes("cfg", &cfg, sizeof(Config)); | |
| prefs.end(); | |
| return (put == sizeof(Config)); | |
| } | |
| static void factoryResetConfig() { | |
| prefs.begin(PREFS_NS, false); | |
| prefs.clear(); | |
| prefs.end(); | |
| } | |
| // ============================================================ | |
| // Globals | |
| // ============================================================ | |
| WebServer server(80); | |
| HCSR04 hc(TRIG_PIN, ECHO_PINS, NUM_SENSORS); | |
| static bool relayTarget[MAX_CHANNELS] = {0}; | |
| static bool relayActual[MAX_CHANNELS] = {0}; | |
| struct RelayAnim { | |
| bool animating=false; | |
| uint8_t step=0; | |
| int8_t dir=1; | |
| uint32_t nextTickMs=0; | |
| }; | |
| static RelayAnim rAnim[MAX_CHANNELS]; | |
| static uint32_t lastSensorSampleMs = 0; | |
| static uint32_t lastWifiCheckMs = 0; | |
| static wl_status_t lastWifiStatus = WL_IDLE_STATUS; | |
| // ============================================================ | |
| // Sensor filtering state (rolling average implemented with fixed max buffer) | |
| // We store up to 12 samples per sensor; cfg.avgWindow chooses how many are used. | |
| // ============================================================ | |
| static const uint8_t AVG_WINDOW_MAX = 12; | |
| struct SensorFilter { | |
| float buf[AVG_WINDOW_MAX]; | |
| uint8_t idx=0; | |
| uint8_t count=0; | |
| float sum=0.0f; | |
| float avgCm=0.0f; | |
| bool state=false; | |
| uint8_t confirm=0; | |
| }; | |
| static SensorFilter sFilt[MAX_CHANNELS]; | |
| // ============================================================ | |
| // Helpers | |
| // ============================================================ | |
| static inline bool isValidReading(float cm) { return (cm > 0.0f); } | |
| static float updateRollingAverage(SensorFilter &sf, float sample) { | |
| uint8_t W = cfg.avgWindow; | |
| if (W < 1) W = 1; | |
| if (W > AVG_WINDOW_MAX) W = AVG_WINDOW_MAX; | |
| // If buffer isn't full yet, we grow until count==W | |
| if (sf.count < W) { | |
| sf.buf[sf.idx] = sample; | |
| sf.sum += sample; | |
| sf.count++; | |
| } else { | |
| // Full window: subtract the element we overwrite | |
| sf.sum -= sf.buf[sf.idx]; | |
| sf.buf[sf.idx] = sample; | |
| sf.sum += sample; | |
| } | |
| sf.idx = (sf.idx + 1) % W; | |
| sf.avgCm = sf.sum / (float)sf.count; | |
| return sf.avgCm; | |
| } | |
| static bool desiredStateFromAvg(bool currentState, float avgCm) { | |
| // Hysteresis around [min..max] | |
| if (!currentState) { | |
| return (avgCm >= (cfg.minCm + cfg.hystCm) && avgCm <= (cfg.maxCm - cfg.hystCm)); | |
| } else { | |
| return (avgCm >= (cfg.minCm - cfg.hystCm) && avgCm <= (cfg.maxCm + cfg.hystCm)); | |
| } | |
| } | |
| static void updateStableSensorState(struct SensorFilter &sf, bool desired) { | |
| if (desired == sf.state) { | |
| sf.confirm = 0; | |
| return; | |
| } | |
| sf.confirm++; | |
| if (sf.confirm >= cfg.confirmSamples) { | |
| sf.state = desired; | |
| sf.confirm = 0; | |
| } | |
| } | |
| static void applyRelayPin(uint8_t i, bool logicalOn) { | |
| bool pinLevel = RELAY_ACTIVE_HIGH ? logicalOn : !logicalOn; | |
| digitalWrite(RELAY_PINS[i], pinLevel ? LOW : HIGH); | |
| if (relayActual[i] != logicalOn) { | |
| relayActual[i] = logicalOn; | |
| LOG.info(String("Relay ") + String(i + 1) + (logicalOn ? " ON" : " OFF")); | |
| } | |
| } | |
| static void startRelayAnimation(uint8_t i, bool fromState, bool toState) { | |
| if (fromState == toState) return; | |
| rAnim[i].animating = true; | |
| rAnim[i].step = toState ? 0 : 9; | |
| rAnim[i].dir = toState ? +1 : -1; | |
| rAnim[i].nextTickMs = millis(); | |
| LOG.debug(String("Relay ") + String(i + 1) + " animation start"); | |
| } | |
| static void updateRelayAnimations() { | |
| uint32_t now = millis(); | |
| for (uint8_t i = 0; i < NUM_RELAYS; i++) { | |
| if (!rAnim[i].animating) continue; | |
| if ((int32_t)(now - rAnim[i].nextTickMs) < 0) continue; | |
| applyRelayPin(i, ANIM_PATTERN[rAnim[i].step]); | |
| int next = (int)rAnim[i].step + (int)rAnim[i].dir; | |
| if (next < 0 || next > 9) { | |
| rAnim[i].animating = false; | |
| applyRelayPin(i, relayTarget[i]); | |
| LOG.debug(String("Relay ") + String(i + 1) + " animation end"); | |
| } else { | |
| rAnim[i].step = (uint8_t)next; | |
| rAnim[i].nextTickMs = now + ANIM_STEP_MS; | |
| } | |
| } | |
| } | |
| static void updateRelaysNonBlocking() { | |
| for (uint8_t i = 0; i < NUM_RELAYS; i++) { | |
| if (rAnim[i].animating) continue; | |
| if (relayActual[i] != relayTarget[i]) { | |
| if (cfg.animationsEnabled) startRelayAnimation(i, relayActual[i], relayTarget[i]); | |
| else applyRelayPin(i, relayTarget[i]); | |
| } | |
| } | |
| } | |
| static void sampleSensors() { | |
| for (uint8_t i = 0; i < NUM_SENSORS; i++) { | |
| float cm = hc.dist(i); | |
| delay(cfg.sensorCycleMs/NUM_SENSORS); | |
| if (isValidReading(cm)) { | |
| float avg = updateRollingAverage(sFilt[i], cm); | |
| bool desired = desiredStateFromAvg(sFilt[i].state, avg); | |
| bool before = sFilt[i].state; | |
| updateStableSensorState(sFilt[i], desired); | |
| if (before != sFilt[i].state) { | |
| LOG.debug(String("Sensor ") + String(i + 1) + | |
| " -> " + (sFilt[i].state ? "ON" : "OFF") + | |
| " (avg " + String(sFilt[i].avgCm, 1) + "cm)"); | |
| } | |
| } else { | |
| LOG.verbose(String("Sensor ") + String(i + 1) + " invalid"); | |
| } | |
| } | |
| } | |
| static void mapSensorsToRelaysIfAuto() { | |
| if (!cfg.autoMode) return; | |
| for (uint8_t i = 0; i < NUM_RELAYS; i++) { | |
| relayTarget[i] = (i < NUM_SENSORS) ? sFilt[i].state : false; | |
| } | |
| } | |
| // ============================================================ | |
| // Wi-Fi robustness + logging | |
| // ============================================================ | |
| static const char* wifiStatusStr(wl_status_t st) { | |
| switch (st) { | |
| case WL_CONNECTED: return "CONNECTED"; | |
| case WL_DISCONNECTED: return "DISCONNECTED"; | |
| case WL_CONNECT_FAILED: return "CONNECT_FAILED"; | |
| case WL_CONNECTION_LOST: return "CONNECTION_LOST"; | |
| case WL_NO_SSID_AVAIL: return "NO_SSID"; | |
| default: return "OTHER"; | |
| } | |
| } | |
| static void ensureWifi() { | |
| uint32_t now = millis(); | |
| if (now - lastWifiCheckMs < WIFI_CHECK_MS) return; | |
| lastWifiCheckMs = now; | |
| wl_status_t st = WiFi.status(); | |
| if (st != lastWifiStatus) { | |
| lastWifiStatus = st; | |
| String msg = String("WiFi status: ") + wifiStatusStr(st); | |
| if (st == WL_CONNECTED) { | |
| msg += " | IP " + WiFi.localIP().toString(); | |
| msg += " | RSSI " + String(WiFi.RSSI()); | |
| } | |
| LOG.info(msg); | |
| } | |
| if (st == WL_CONNECTED) return; | |
| LOG.warn("WiFi reconnect..."); | |
| WiFi.disconnect(false); | |
| WiFi.begin(WIFI_SSID, WIFI_PASS); | |
| } | |
| // ============================================================ | |
| // Web UI helpers | |
| // ============================================================ | |
| static String checked(bool v) { return v ? "checked" : ""; } | |
| static String selected(bool v) { return v ? "selected" : ""; } | |
| static String renderPage() { | |
| String page; | |
| page.reserve(9000); | |
| page += F("<!doctype html><html><head><meta charset='utf-8'>"); | |
| page += F("<meta name='viewport' content='width=device-width,initial-scale=1'>"); | |
| page += F("<title>LLUM26 # Underlight Light Control</title>"); | |
| page += F("<style>"); | |
| page += F("body{font-family:monospace;margin:16px;max-width:980px}"); | |
| page += F("table{border-collapse:collapse;width:100%;margin:10px 0}"); | |
| page += F("td,th{border:1px solid #ccc;padding:6px;text-align:center}"); | |
| page += F(".muted{color:#666}"); | |
| page += F("input[type=number]{width:90px}"); | |
| page += F("fieldset{border:1px solid #ccc;padding:10px;margin:10px 0}"); | |
| page += F("legend{padding:0 6px}"); | |
| page += F("</style></head><body>"); | |
| page += F("<h3>LLUM26 # Underlight Light Control</h3>"); | |
| page += F("<div class='muted'>IP: "); | |
| page += WiFi.localIP().toString(); | |
| page += F(" | Wi-Fi: "); | |
| page += (WiFi.status() == WL_CONNECTED) ? F("CONNECTED") : F("DISCONNECTED"); | |
| page += F(" | Mode: "); | |
| page += cfg.autoMode ? F("AUTO") : F("MANUAL"); | |
| page += F(" | Anim: "); | |
| page += cfg.animationsEnabled ? F("ON") : F("OFF"); | |
| page += F("</div>"); | |
| // Inputs table (auto-refreshed via JS) | |
| page += F("<h4>Inputs (Sensors)</h4>"); | |
| page += F("<table id='sTable'><tr><th>#</th>"); | |
| for (uint8_t i=0; i<NUM_SENSORS; i++) { page += "<th>" + String(i+1) + "</th>"; } | |
| page += F("</tr><tr><th>Status</th>"); | |
| for (uint8_t i=0; i<NUM_SENSORS; i++) { page += "<td id='ss" + String(i) + "'>...</td>"; } | |
| page += F("</tr><tr><th>Avg cm</th>"); | |
| for (uint8_t i=0; i<NUM_SENSORS; i++) { page += "<td id='sc" + String(i) + "'>...</td>"; } | |
| page += F("</tr></table>"); | |
| // Outputs: manual toggles (still useful for testing) | |
| page += F("<h4>Outputs (Relays)</h4>"); | |
| page += F("<form method='GET' action='/set'>"); | |
| page += F("<fieldset><legend>Mode</legend>"); | |
| page += F("<label><input type='checkbox' name='auto' value='1' "); | |
| page += checked(cfg.autoMode); | |
| page += F("> Automatic (unchecked = Manual)</label><br>"); | |
| page += F("<label><input type='checkbox' name='anim' value='1' "); | |
| page += checked(cfg.animationsEnabled); | |
| page += F("> Enable transitions</label>"); | |
| page += F("</fieldset>"); | |
| page += F("<table><tr><th>#</th>"); | |
| for (uint8_t i=0; i<NUM_RELAYS; i++) page += "<th>" + String(i+1) + "</th>"; | |
| page += F("</tr><tr><th>Target</th>"); | |
| for (uint8_t i=0; i<NUM_RELAYS; i++) { | |
| page += F("<td><input type='checkbox' name='r"); | |
| page += String(i+1); | |
| page += F("' value='1' "); | |
| if (relayTarget[i]) page += F("checked "); | |
| if (cfg.autoMode) page += F("disabled "); | |
| page += F("></td>"); | |
| } | |
| page += F("</tr><tr><th>Actual</th>"); | |
| for (uint8_t i=0; i<NUM_RELAYS; i++) { | |
| page += F("<td>"); | |
| page += relayActual[i] ? F("ON") : F("OFF"); | |
| page += F("</td>"); | |
| } | |
| page += F("</tr></table>"); | |
| page += F("<button type='submit'>Apply</button>"); | |
| page += F("</form>"); | |
| // Config form (persisted) | |
| page += F("<h4>Configuration</h4>"); | |
| page += F("<form method='POST' action='/config'>"); | |
| page += F("<fieldset><legend>Window (cm)</legend>"); | |
| page += F("MIN <input type='number' step='0.1' name='min' value='"); page += String(cfg.minCm,1); page += F("'> "); | |
| page += F("MAX <input type='number' step='0.1' name='max' value='"); page += String(cfg.maxCm,1); page += F("'> "); | |
| page += F("HYST <input type='number' step='0.1' name='hyst' value='"); page += String(cfg.hystCm,1); page += F("'> "); | |
| page += F("</fieldset>"); | |
| page += F("<fieldset><legend>Filtering</legend>"); | |
| page += F("Avg window <input type='number' name='avg' min='1' max='12' value='"); page += String(cfg.avgWindow); page += F("'> "); | |
| page += F("Confirm samples <input type='number' name='conf' min='1' max='10' value='"); page += String(cfg.confirmSamples); page += F("'> "); | |
| page += F("</fieldset>"); | |
| page += F("<fieldset><legend>Timing</legend>"); | |
| page += F("Sensor cycle ms <input type='number' name='cycle' min='60' max='300' value='"); page += String(cfg.sensorCycleMs); page += F("'> "); | |
| page += F("</fieldset>"); | |
| page += F("<fieldset><legend>Debug</legend>"); | |
| page += F("Log level <select name='log'>"); | |
| page += "<option value='0' " + String(cfg.logLevel==0?"selected":"") + ">ERROR</option>"; | |
| page += "<option value='1' " + String(cfg.logLevel==1?"selected":"") + ">WARN</option>"; | |
| page += "<option value='2' " + String(cfg.logLevel==2?"selected":"") + ">INFO</option>"; | |
| page += "<option value='3' " + String(cfg.logLevel==3?"selected":"") + ">DEBUG</option>"; | |
| page += "<option value='4' " + String(cfg.logLevel==4?"selected":"") + ">VERBOSE</option>"; | |
| page += F("</select>"); | |
| page += F("</fieldset>"); | |
| page += F("<button type='submit'>Save configuration</button>"); | |
| page += F("</form>"); | |
| // Factory reset | |
| page += F("<h4>Factory reset</h4>"); | |
| page += F("<form method='POST' action='/reset' onsubmit=\"return confirm('Factory reset will clear saved settings and reboot. Continue?');\">"); | |
| page += F("<button type='submit'>Factory reset</button>"); | |
| page += F("</form>"); | |
| // Tiny JS refresh: fetch /status every 0.5s and update cells | |
| page += F("<script>"); | |
| page += F("async function tick(){"); | |
| page += F(" try{"); | |
| page += F(" const r=await fetch('/status',{cache:'no-store'});"); | |
| page += F(" if(!r.ok) return;"); | |
| page += F(" const j=await r.json();"); | |
| page += F(" for(let i=0;i<j.n;i++){"); | |
| page += F(" const s=document.getElementById('ss'+i);"); | |
| page += F(" const c=document.getElementById('sc'+i);"); | |
| page += F(" if(s) s.textContent=j.s[i]?'ON':'OFF';"); | |
| page += F(" if(c) c.textContent=j.c[i].toFixed(1);"); | |
| page += F(" }"); | |
| page += F(" }catch(e){}"); | |
| page += F("}"); | |
| page += F("setInterval(tick,500); tick();"); | |
| page += F("</script>"); | |
| page += F("</body></html>"); | |
| return page; | |
| } | |
| // ============================================================ | |
| // HTTP handlers | |
| // ============================================================ | |
| static void handleRoot() { | |
| LOG.verbose("HTTP GET /"); | |
| server.send(200, "text/html; charset=utf-8", renderPage()); | |
| } | |
| static void handleStatus() { | |
| // Minimal JSON: {n:6, s:[0/1..], c:[avg..]} | |
| String json; | |
| json.reserve(200); | |
| json += F("{\"n\":"); | |
| json += String(NUM_SENSORS); | |
| json += F(",\"s\":["); | |
| for (uint8_t i=0; i<NUM_SENSORS; i++) { | |
| json += sFilt[i].state ? "1" : "0"; | |
| if (i < NUM_SENSORS-1) json += ","; | |
| } | |
| json += F("],\"c\":["); | |
| for (uint8_t i=0; i<NUM_SENSORS; i++) { | |
| json += String(sFilt[i].avgCm, 1); | |
| if (i < NUM_SENSORS-1) json += ","; | |
| } | |
| json += F("]}"); | |
| server.send(200, "application/json; charset=utf-8", json); | |
| } | |
| static void handleSet() { | |
| LOG.verbose("HTTP GET /set"); | |
| bool newAuto = server.hasArg("auto"); | |
| bool newAnim = server.hasArg("anim"); | |
| if (newAuto != cfg.autoMode) { | |
| cfg.autoMode = newAuto; | |
| LOG.info(String("Mode -> ") + (cfg.autoMode ? "AUTO" : "MANUAL")); | |
| } else { | |
| cfg.autoMode = newAuto; | |
| } | |
| if (newAnim != cfg.animationsEnabled) { | |
| cfg.animationsEnabled = newAnim; | |
| LOG.info(String("Animations -> ") + (cfg.animationsEnabled ? "ON" : "OFF")); | |
| } else { | |
| cfg.animationsEnabled = newAnim; | |
| } | |
| // Manual: read relay targets from checkboxes | |
| if (!cfg.autoMode) { | |
| for (uint8_t i=0; i<NUM_RELAYS; i++) { | |
| String key = "r" + String(i+1); | |
| bool t = server.hasArg(key); | |
| if (relayTarget[i] != t) { | |
| relayTarget[i] = t; | |
| LOG.debug(String("Manual target relay ") + String(i+1) + " -> " + (t?"ON":"OFF")); | |
| } | |
| } | |
| } | |
| // Persist these behaviour toggles too (so power cycles keep the mode) | |
| saveConfig(); | |
| server.sendHeader("Location", "/"); | |
| server.send(302, "text/plain", "OK"); | |
| } | |
| static float argToFloat(const String &s, float fallback) { | |
| if (s.length() == 0) return fallback; | |
| return s.toFloat(); | |
| } | |
| static int argToInt(const String &s, int fallback) { | |
| if (s.length() == 0) return fallback; | |
| return s.toInt(); | |
| } | |
| static void handleConfig() { | |
| // POST /config | |
| LOG.info("HTTP POST /config"); | |
| if (server.hasArg("min")) cfg.minCm = argToFloat(server.arg("min"), cfg.minCm); | |
| if (server.hasArg("max")) cfg.maxCm = argToFloat(server.arg("max"), cfg.maxCm); | |
| if (server.hasArg("hyst")) cfg.hystCm = argToFloat(server.arg("hyst"), cfg.hystCm); | |
| if (server.hasArg("avg")) cfg.avgWindow = (uint8_t) argToInt(server.arg("avg"), cfg.avgWindow); | |
| if (server.hasArg("conf")) cfg.confirmSamples = (uint8_t) argToInt(server.arg("conf"), cfg.confirmSamples); | |
| if (server.hasArg("cycle")) cfg.sensorCycleMs = (uint16_t) argToInt(server.arg("cycle"), cfg.sensorCycleMs); | |
| if (server.hasArg("log")) { | |
| cfg.logLevel = (uint8_t) argToInt(server.arg("log"), cfg.logLevel); | |
| } | |
| clampConfig(); | |
| bool ok = saveConfig(); | |
| LOG.info(String("Config saved: ") + (ok ? "OK" : "FAIL")); | |
| LOG.info("Window " + String(cfg.minCm,1) + ".." + String(cfg.maxCm,1) + " hyst " + String(cfg.hystCm,1)); | |
| LOG.info("Filter avg=" + String(cfg.avgWindow) + " confirm=" + String(cfg.confirmSamples) + " cycle=" + String(cfg.sensorCycleMs)); | |
| LOG.info("LogLevel=" + String(cfg.logLevel)); | |
| // Apply log level immediately | |
| LOG.setLevel((DebugLog::Level)cfg.logLevel); | |
| // Redirect back | |
| server.sendHeader("Location", "/"); | |
| server.send(302, "text/plain", ok ? "OK" : "FAIL"); | |
| } | |
| static void handleReset() { | |
| // POST /reset | |
| LOG.warn("HTTP POST /reset -> factory reset"); | |
| factoryResetConfig(); | |
| delay(50); | |
| server.send(200, "text/plain; charset=utf-8", "Factory reset OK. Rebooting..."); | |
| delay(200); | |
| ESP.restart(); | |
| } | |
| // ============================================================ | |
| // Setup / Loop | |
| // ============================================================ | |
| void setup() { | |
| Serial.begin(115200); | |
| delay(200); | |
| // Load persisted config or defaults | |
| if (!loadConfig()) { | |
| setDefaultConfig(); | |
| saveConfig(); | |
| } | |
| clampConfig(); | |
| LOG.begin(Serial, (DebugLog::Level)cfg.logLevel); | |
| LOG.info("Boot"); | |
| LOG.info(String("Loaded config: window ") + String(cfg.minCm,1) + ".." + String(cfg.maxCm,1) + | |
| " hyst " + String(cfg.hystCm,1) + " avg " + String(cfg.avgWindow) + | |
| " conf " + String(cfg.confirmSamples) + " cycle " + String(cfg.sensorCycleMs)); | |
| LOG.info(String("Mode=") + (cfg.autoMode?"AUTO":"MANUAL") + " Anim=" + (cfg.animationsEnabled?"ON":"OFF")); | |
| // Relay init | |
| for (uint8_t i=0; i<NUM_RELAYS; i++) { | |
| pinMode(RELAY_PINS[i], OUTPUT); | |
| relayTarget[i] = false; | |
| relayActual[i] = false; | |
| applyRelayPin(i, false); | |
| } | |
| // Wi-Fi init | |
| WiFi.mode(WIFI_STA); | |
| WiFi.setAutoReconnect(true); | |
| WiFi.persistent(false); | |
| if (!WiFi.config(LOCAL_IP, GATEWAY, SUBNET, DNS1, DNS2)) { | |
| LOG.warn("WiFi.config failed (static IP), falling back to DHCP"); | |
| } else { | |
| LOG.info(String("Static IP set: ") + LOCAL_IP.toString()); | |
| } | |
| WiFi.begin(WIFI_SSID, WIFI_PASS); | |
| LOG.info(String("WiFi begin: SSID=") + WIFI_SSID); | |
| lastWifiStatus = WiFi.status(); | |
| LOG.info(String("WiFi status: ") + wifiStatusStr(lastWifiStatus)); | |
| // Web routes | |
| server.on("/", HTTP_GET, handleRoot); | |
| server.on("/status", HTTP_GET, handleStatus); | |
| server.on("/set", HTTP_GET, handleSet); | |
| server.on("/config", HTTP_POST, handleConfig); | |
| server.on("/reset", HTTP_POST, handleReset); | |
| server.begin(); | |
| LOG.info("HTTP server started on port 80"); | |
| } | |
| void loop() { | |
| server.handleClient(); | |
| ensureWifi(); | |
| // Sensor sampling cycle (blocking, acceptable; everything else non-blocking) | |
| uint32_t now = millis(); | |
| if (now - lastSensorSampleMs >= cfg.sensorCycleMs) { | |
| lastSensorSampleMs = now; | |
| sampleSensors(); | |
| mapSensorsToRelaysIfAuto(); | |
| } | |
| // Relay updates | |
| updateRelayAnimations(); | |
| updateRelaysNonBlocking(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment