Skip to content

Instantly share code, notes, and snippets.

@pral2a
Last active February 7, 2026 17:14
Show Gist options
  • Select an option

  • Save pral2a/2919ad436d2d370d3428dcb2200f9f77 to your computer and use it in GitHub Desktop.

Select an option

Save pral2a/2919ad436d2d370d3428dcb2200f9f77 to your computer and use it in GitHub Desktop.
/*
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