Last active
February 7, 2026 17:14
-
-
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
| /* | |
| ESP32-WROOM + HC-SR04 (x6) + Relays (x6) + Web UI + Persistent config + Debug logger | |
| Additions in this version: | |
| - Sensor->Relay mapping is configurable (one-to-one, one-to-many, many-to-one) | |
| - Mapping editor in Web UI (checkbox matrix), persisted in NVS (Preferences) | |
| - AUTO mode uses mapping: relayTarget[r] = OR( sensorState[s] for all mapped sensors->r ) | |
| - /status includes sensors + relays + heartbeat (millis), UI refresh is 0.5s | |
| IMPORTANT ELECTRICAL NOTE: | |
| HC-SR04 ECHO is typically 5V logic. ESP32 GPIOs are NOT 5V tolerant. | |
| Use a divider or level shifter on each ECHO pin. | |
| */ | |
| #include <Arduino.h> | |
| #include <WiFi.h> | |
| #include <WebServer.h> | |
| #include <HCSR04.h> | |
| #include <Preferences.h> | |
| // Forward declaration to avoid Arduino auto-prototype issues | |
| 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 (from your pasted code) | |
| // ============================================================ | |
| 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; // 1..6 | |
| static const uint8_t NUM_RELAYS = 6; // 1..6 (<=6) | |
| // ============================================================ | |
| // HC-SR04 pins | |
| // ============================================================ | |
| static const int TRIG_PIN = 13; | |
| static int ECHO_PINS[MAX_CHANNELS] = { 14, 27, 26, 25, 33, 32 }; | |
| // ============================================================ | |
| // Relay pins | |
| // ============================================================ | |
| static const bool RELAY_ACTIVE_HIGH = true; | |
| static const int RELAY_PINS[MAX_CHANNELS] = { 16, 17, 18, 21, 22, 23 }; | |
| // ============================================================ | |
| // Animation pattern | |
| // ============================================================ | |
| static const uint16_t ANIM_STEP_MS = 200; | |
| static const uint8_t ANIM_STEPS = 6; | |
| static const bool ANIM_PATTERN[ANIM_STEPS] = { | |
| 0,1,0,1,1,0 | |
| }; | |
| // ============================================================ | |
| // Timing | |
| // ============================================================ | |
| static const uint16_t SENSOR_CYCLE_DEFAULT_MS = 300; // overall sampling cadence | |
| static const uint16_t WIFI_CHECK_MS = 2000; | |
| // UI refresh: 0.5s (handled in page JS) | |
| // ============================================================ | |
| // Persistence (EEPROM-like) via Preferences/NVS | |
| // ============================================================ | |
| Preferences prefs; | |
| static const char* PREFS_NS = "barrier"; | |
| static const uint32_t CFG_MAGIC = 0xBADC0DE1; | |
| // Mapping representation: | |
| // sensorToRelayMask[s] bit r => sensor s controls relay r | |
| // supports one-to-many and many-to-one | |
| static inline uint8_t relayBit(uint8_t r) { return (uint8_t)(1u << r); } | |
| struct Config { | |
| uint32_t magic; | |
| // Sensing window | |
| float minCm; | |
| float maxCm; | |
| float hystCm; | |
| // Filtering | |
| uint8_t avgWindow; // 1..12 | |
| uint8_t confirmSamples; // 1..10 | |
| // Behaviour | |
| bool autoMode; | |
| bool animationsEnabled; | |
| // Sampling cadence | |
| uint16_t sensorCycleMs; // 60..500 | |
| // Debug | |
| uint8_t logLevel; // 0..4 | |
| // Mapping: per sensor bitmask of relays | |
| uint8_t sensorToRelayMask[MAX_CHANNELS]; | |
| }; | |
| Config cfg; | |
| static void setDefaultConfig() { | |
| cfg.magic = CFG_MAGIC; | |
| cfg.minCm = 1.0f; | |
| cfg.maxCm = 15.0f; | |
| cfg.hystCm = 1.0f; | |
| cfg.avgWindow = 8; | |
| cfg.confirmSamples = 1; | |
| cfg.autoMode = true; | |
| cfg.animationsEnabled = false; | |
| cfg.sensorCycleMs = SENSOR_CYCLE_DEFAULT_MS; | |
| cfg.logLevel = DebugLog::INFO; | |
| // Default mapping: 1->1, 2->2, ... up to min(NUM_SENSORS,NUM_RELAYS) | |
| for (uint8_t s = 0; s < MAX_CHANNELS; s++) cfg.sensorToRelayMask[s] = 0; | |
| uint8_t n = (NUM_SENSORS < NUM_RELAYS) ? NUM_SENSORS : NUM_RELAYS; | |
| for (uint8_t i = 0; i < n; i++) cfg.sensorToRelayMask[i] = relayBit(i); | |
| } | |
| static void clampConfig() { | |
| 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 > 500) cfg.sensorCycleMs = 500; | |
| if (cfg.logLevel > DebugLog::VERBOSE) cfg.logLevel = DebugLog::INFO; | |
| // Mask out relays beyond NUM_RELAYS, and sensors beyond NUM_SENSORS | |
| uint8_t allowedRelayMask = 0; | |
| for (uint8_t r = 0; r < NUM_RELAYS; r++) allowedRelayMask |= relayBit(r); | |
| for (uint8_t s = 0; s < MAX_CHANNELS; s++) { | |
| if (s >= NUM_SENSORS) cfg.sensorToRelayMask[s] = 0; | |
| else cfg.sensorToRelayMask[s] &= allowedRelayMask; | |
| } | |
| } | |
| 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 | |
| // ============================================================ | |
| 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 (sf.count < W) { | |
| sf.buf[sf.idx] = sample; | |
| sf.sum += sample; | |
| sf.count++; | |
| } else { | |
| 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) { | |
| 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(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 ? HIGH : LOW); | |
| 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 : (ANIM_STEPS - 1); | |
| 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 >= (int)ANIM_STEPS) { | |
| 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() { | |
| // Avoid crosstalk: keep >=60ms between triggers | |
| uint16_t perSensorDelay = cfg.sensorCycleMs / (NUM_SENSORS ? NUM_SENSORS : 1); | |
| if (perSensorDelay < 60) perSensorDelay = 60; | |
| for (uint8_t i = 0; i < NUM_SENSORS; i++) { | |
| float cm = hc.dist(i); | |
| delay(perSensorDelay); | |
| 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; | |
| // Clear all relay targets | |
| for (uint8_t r = 0; r < NUM_RELAYS; r++) relayTarget[r] = false; | |
| // Apply mapping: relayTarget[r] becomes OR of mapped sensor states | |
| for (uint8_t s = 0; s < NUM_SENSORS; s++) { | |
| if (!sFilt[s].state) continue; | |
| uint8_t mask = cfg.sensorToRelayMask[s]; | |
| for (uint8_t r = 0; r < NUM_RELAYS; r++) { | |
| if (mask & relayBit(r)) relayTarget[r] = true; | |
| } | |
| } | |
| } | |
| // ============================================================ | |
| // 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 renderMappingTable() { | |
| // Sensor rows, Relay columns, checkbox matrix | |
| String h; | |
| h.reserve(2500); | |
| h += F("<fieldset><legend>Sensor -> Relay mapping (AUTO mode)</legend>"); | |
| h += F("<form method='POST' action='/mapping'>"); | |
| h += F("<table><tr><th>Sensor \\ Relay</th>"); | |
| for (uint8_t r = 0; r < NUM_RELAYS; r++) { | |
| h += "<th>R" + String(r + 1) + "</th>"; | |
| } | |
| h += F("</tr>"); | |
| for (uint8_t s = 0; s < NUM_SENSORS; s++) { | |
| h += "<tr><th>S" + String(s + 1) + "</th>"; | |
| for (uint8_t r = 0; r < NUM_RELAYS; r++) { | |
| bool on = (cfg.sensorToRelayMask[s] & relayBit(r)); | |
| // Name format: m_s{S}_r{R} | |
| h += "<td><input type='checkbox' name='m_"; | |
| h += String(s); | |
| h += "_"; | |
| h += String(r); | |
| h += "' value='1' "; | |
| if (on) h += "checked"; | |
| h += "></td>"; | |
| } | |
| h += F("</tr>"); | |
| } | |
| h += F("</table>"); | |
| h += F("<button type='submit'>Save mapping</button>"); | |
| h += F("</form>"); | |
| h += F("<div class='muted'>Tip: one sensor can drive multiple relays, and a relay can be driven by multiple sensors (OR logic).</div>"); | |
| h += F("</fieldset>"); | |
| return h; | |
| } | |
| static String renderPage() { | |
| String page; | |
| page.reserve(12000); | |
| 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(" | Heartbeat: <span id='hb'>...</span>"); | |
| page += F("</div>"); | |
| // Inputs table (auto-refreshed via JS) | |
| page += F("<h4>Inputs (Sensors)</h4>"); | |
| page += F("<table><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 | |
| 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>"); | |
| // Target row with IDs (rt0..) | |
| page += F("<tr><th>Target</th>"); | |
| for (uint8_t i=0; i<NUM_RELAYS; i++) { | |
| page += F("<td id='rt"); | |
| page += String(i); | |
| page += F("'>"); | |
| page += F("<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(">"); | |
| page += F("</td>"); | |
| } | |
| page += F("</tr>"); | |
| // Actual row with IDs (ra0..) | |
| page += F("<tr><th>Actual</th>"); | |
| for (uint8_t i=0; i<NUM_RELAYS; i++) { | |
| page += F("<td id='ra"); | |
| page += String(i); | |
| page += F("'>"); | |
| page += relayActual[i] ? F("ON") : F("OFF"); | |
| page += F("</td>"); | |
| } | |
| page += F("</tr></table>"); | |
| page += F("<button type='submit'>Apply</button>"); | |
| page += F("</form>"); | |
| // Mapping editor | |
| page += F("<h4>Mapping</h4>"); | |
| page += renderMappingTable(); | |
| // Configuration | |
| 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='500' 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>"); | |
| // JS refresh: fetch /status every 500ms | |
| 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(" const hb=document.getElementById('hb');"); | |
| page += F(" if(hb) hb.textContent=j.hb + ' seconds';"); | |
| 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=Number(j.c[i]).toFixed(1);"); | |
| page += F(" }"); | |
| // page += F(" for(let i=0;i<j.rn;i++){"); | |
| // page += F(" const rt=document.getElementById('rt'+i);"); | |
| // page += F(" const ra=document.getElementById('ra'+i);"); | |
| // page += F(" if(rt){"); | |
| // page += F(" const cb=rt.querySelector('input[type=checkbox]');"); | |
| // page += F(" if(cb) cb.checked=!!j.rt[i];"); | |
| // page += F(" }"); | |
| // page += F(" if(ra) ra.textContent=j.ra[i]?'ON':'OFF';"); | |
| // 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() { | |
| // JSON: | |
| // { | |
| // "hb": <millis>, | |
| // "n":6, "s":[..], "c":[..], | |
| // "rn":6, "rt":[..], "ra":[..] | |
| // } | |
| String json; | |
| json.reserve(520); | |
| json += F("{\"hb\":"); | |
| json += String(millis()/1000); | |
| 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("],\"rn\":"); | |
| json += String(NUM_RELAYS); | |
| json += F(",\"rt\":["); | |
| for (uint8_t i=0; i<NUM_RELAYS; i++) { | |
| json += relayTarget[i] ? '1' : '0'; | |
| if (i < NUM_RELAYS-1) json += ','; | |
| } | |
| json += F("],\"ra\":["); | |
| for (uint8_t i=0; i<NUM_RELAYS; i++) { | |
| json += relayActual[i] ? '1' : '0'; | |
| if (i < NUM_RELAYS-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 targets only when NOT in auto mode | |
| if (!cfg.autoMode) { | |
| for (uint8_t i=0; i<NUM_RELAYS; i++) { | |
| String key = "r" + String(i+1); | |
| bool t = server.hasArg(key); | |
| relayTarget[i] = t; | |
| } | |
| } | |
| 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() { | |
| 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.setLevel((DebugLog::Level)cfg.logLevel); | |
| server.sendHeader("Location", "/"); | |
| server.send(302, "text/plain", ok ? "OK" : "FAIL"); | |
| } | |
| static void handleMapping() { | |
| // POST /mapping | |
| LOG.info("HTTP POST /mapping"); | |
| // rebuild masks from checkbox matrix | |
| for (uint8_t s = 0; s < NUM_SENSORS; s++) { | |
| uint8_t mask = 0; | |
| for (uint8_t r = 0; r < NUM_RELAYS; r++) { | |
| String key = "m_" + String(s) + "_" + String(r); | |
| if (server.hasArg(key)) mask |= relayBit(r); | |
| } | |
| cfg.sensorToRelayMask[s] = mask; | |
| } | |
| // Ensure sensors > NUM_SENSORS remain zeroed | |
| for (uint8_t s = NUM_SENSORS; s < MAX_CHANNELS; s++) cfg.sensorToRelayMask[s] = 0; | |
| bool ok = saveConfig(); | |
| LOG.info(String("Mapping saved: ") + (ok ? "OK" : "FAIL")); | |
| server.sendHeader("Location", "/"); | |
| server.send(302, "text/plain", ok ? "OK" : "FAIL"); | |
| } | |
| static void handleReset() { | |
| 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); | |
| if (!loadConfig()) { | |
| setDefaultConfig(); | |
| saveConfig(); | |
| } else { | |
| // If config was from an older firmware (smaller struct), loadConfig() will fail. | |
| // In that case defaults are applied above. | |
| } | |
| clampConfig(); | |
| LOG.begin(Serial, (DebugLog::Level)cfg.logLevel); | |
| LOG.info("Boot"); | |
| // 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("/mapping", HTTP_POST, handleMapping); | |
| 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 is acceptable here) | |
| 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