Add NVS persistence, NFC enable/disable, responsive UI, dark mode, Basic Auth

This commit is contained in:
2026-04-13 21:34:53 +03:00
parent 039a60848d
commit 10f161f479

View File

@@ -8,14 +8,17 @@
*
* Provides REST API for Home Assistant integration
*/
// version 2.1 Initial release
// version 2.2.1 Persistent NFC config via NVS
#include <WiFi.h>
#include <WebServer.h>
#include <WiFiClient.h>
#include <Preferences.h>
Preferences prefs;
// ── NFC: PN532 over UART (HSU mode) via UEXT1 ───────────────────────────────
// UEXT1 pin 3 = TXD (ESP32 → PN532 RXD) → GPIO4
// UEXT1 pin 4 = RXD (PN532 TXD → ESP32) → GPIO5
// UEXT1 pin 4 = RXD (PN532 TXD → ESP32) → GPO5
// PN532 module wiring note: set HSU mode — DIP1 = 0, DIP2 = 0
#include <PN532_HSU.h>
#include <PN532.h>
@@ -44,6 +47,7 @@ char nfc_auth_uid[32] = ""; // authorized card UID; empty = any
int nfc_relay_num = 1; // relay to open on match (1-4)
unsigned long nfc_pulse_ms = 5000; // absence timeout: relay closes after this many ms of no card
char nfc_access_state[8] = "idle"; // "idle" | "granted" | "denied"
bool nfc_enabled = false; // access control module on/off; off by default
// ────────────────────────────────────────────────────────────────────────────
// WiFi credentials
@@ -91,6 +95,33 @@ bool ha_registered = false;
float temperature = 25.0;
unsigned long last_temp_update = 0;
// ── Forward declarations ──────────────────────────────────────────────────────
// Required because setup()/loop() reference handlers defined later in the file.
void handleRoot();
void handleStatus();
void handleRelayOn();
void handleRelayOff();
void handleRelayStatus();
void handleInputStatus();
void handleRegister();
void handleLEDOn();
void handleLEDOff();
void handleNotFound();
void handleNFCStatus();
void handleNFCConfigGet();
void handleNFCConfigSet();
void handleNFCEnable();
void handleDebug();
void postNFCEvent(const String& uid);
void postInputEvent(int input_num, bool state);
int nfcRelayPin(int rnum);
bool postJsonToHA(const String& json);
bool verifyAPIRequest();
bool requireAuth();
void checkInputChanges();
void scanWiFiNetworks();
// ─────────────────────────────────────────────────────────────────────────────
void setup() {
// Initialize USB CDC serial
Serial.begin(115200);
@@ -126,7 +157,36 @@ void setup() {
digitalWrite(RELAY_4_PIN, LOW);
Serial.println("GPIO initialized");
// ── Load persistent NFC config from NVS ──────────────────────────────────
prefs.begin("nfc_cfg", true); // read-only namespace
String saved_uid = prefs.getString("auth_uid", "");
if (saved_uid.length() > 0 && saved_uid.length() < sizeof(nfc_auth_uid)) {
saved_uid.toCharArray(nfc_auth_uid, sizeof(nfc_auth_uid));
}
nfc_relay_num = prefs.getInt("relay_num", 1);
nfc_pulse_ms = prefs.getULong("pulse_ms", 5000);
nfc_enabled = prefs.getBool("enabled", false);
prefs.end();
Serial.printf("NFC config loaded: auth='%s' relay=%d pulse=%lu ms\n",
nfc_auth_uid, nfc_relay_num, nfc_pulse_ms);
// ─────────────────────────────────────────────────────────────────────────
// ── Load persistent HA registration from NVS ─────────────────────────────
prefs.begin("ha_cfg", true); // read-only namespace
String saved_url = prefs.getString("callback_url", "");
if (saved_url.length() > 0 && saved_url.length() < sizeof(ha_callback_url)) {
saved_url.toCharArray(ha_callback_url, sizeof(ha_callback_url));
ha_registered = true;
}
prefs.end();
if (ha_registered) {
Serial.printf("HA registration loaded: %s\n", ha_callback_url);
} else {
Serial.println("HA registration: not registered");
}
// ─────────────────────────────────────────────────────────────────────────
// Configure WiFi
// ESP32-C6 note: the radio sometimes needs a full reset cycle before it can
// associate. We do WiFi.disconnect(true) → mode() → config() → begin() and
@@ -138,7 +198,7 @@ void setup() {
WiFi.setAutoReconnect(true);
// Static IP
IPAddress staticIP(192, 168, 0, 181);
IPAddress staticIP(192, 168, 0, 240);
IPAddress gateway(192, 168, 0, 1);
IPAddress subnet(255, 255, 255, 0);
WiFi.config(staticIP, gateway, subnet);
@@ -214,6 +274,7 @@ void setup() {
server.on("/nfc/status", HTTP_GET, handleNFCStatus);
server.on("/nfc/config", HTTP_GET, handleNFCConfigGet);
server.on("/nfc/config", HTTP_POST, handleNFCConfigSet);
server.on("/nfc/enable", HTTP_POST, handleNFCEnable);
server.on("/debug", HTTP_GET, handleDebug);
// ── Collect HMAC auth headers for verifyAPIRequest() ─────────────────────────
@@ -255,11 +316,13 @@ void setup() {
long baud = NFC_BAUDS[bi];
Serial.printf(" Trying baud=%-7ld RX=GPIO%d TX=GPIO%d ...\n", baud, rx, tx);
nfcSerial.begin(baud, SERIAL_8N1, rx, tx);
delay(500); // PN532 boot / line-settle time
// Serve HTTP and feed watchdog during probe delay instead of blocking
{ unsigned long _t = millis(); while (millis() - _t < 500) { server.handleClient(); yield(); } }
nfc.begin(); // PN532_HSU::begin() is a no-op — pins already set
versiondata = nfc.getFirmwareVersion();
if (!versiondata) delay(200);
else { found_baud = baud; found_rx = rx; found_tx = tx; }
if (!versiondata) {
unsigned long _t = millis(); while (millis() - _t < 200) { server.handleClient(); yield(); }
} else { found_baud = baud; found_rx = rx; found_tx = tx; }
}
}
@@ -311,7 +374,7 @@ void loop() {
bool is_active_state = (strcmp(nfc_access_state, "granted") == 0 ||
strcmp(nfc_access_state, "denied") == 0);
unsigned long nfc_interval = is_active_state ? nfc_pulse_ms : (unsigned long)NFC_POLL_MS;
if (nfc_initialized && millis() - nfc_last_poll_ms >= nfc_interval) {
if (nfc_enabled && nfc_initialized && millis() - nfc_last_poll_ms >= nfc_interval) {
nfc_last_poll_ms = millis();
uint16_t rf_timeout = is_active_state ? 500 : 50;
uint8_t uid[7] = {0};
@@ -439,69 +502,155 @@ void handleRoot() {
input3_state = digitalRead(DIN3_PIN);
input4_state = digitalRead(DIN4_PIN);
String html = "<html><head><meta charset='UTF-8'><title>ESP32-C6 Device</title>";
html += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
String html = "<!DOCTYPE html><html lang='en'><head>";
html += "<meta charset='UTF-8'><title>ESP32-C6 Device</title>";
html += "<meta name='viewport' content='width=device-width,initial-scale=1,maximum-scale=1'>";
html += "<style>";
html += "body{font-family:Arial;margin:20px;background:#f0f0f0}";
html += ".card{background:white;padding:20px;margin:10px 0;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,0.1)}";
html += "h1{color:#333}h2{color:#555;font-size:17px;margin-top:0;margin-bottom:12px}";
// ── CSS custom properties (light defaults) ─────────────────────────────
html += ":root{";
html += "--bg:#f0f2f5;--card:#ffffff;--card-border:rgba(0,0,0,0.06);";
html += "--text:#1a1a1a;--text2:#555;--text3:#888;";
html += "--inp-bg:#f7f7f7;--inp-border:#e0e0e0;";
html += "--hr:#eeeeee;";
html += "--relay-off:#9e9e9e;--relay-off-h:#757575;";
html += "--wh-ok-bg:#c8e6c9;--wh-ok-c:#1b5e20;";
html += "--wh-err-bg:#ffcdd2;--wh-err-c:#b71c1c;";
html += "--nfc-idle-bg:#f5f5f5;--nfc-idle-c:#757575;";
html += "--nfc-granted-bg:#c8e6c9;--nfc-granted-c:#1b5e20;";
html += "--nfc-denied-bg:#ffcdd2;--nfc-denied-c:#b71c1c;";
html += "--input-field:#fff;--input-field-b:#ccc;--select-bg:#fff;";
html += "--topbar:#1565c0;--topbar-text:#fff;";
html += "}";
// ── Dark mode overrides ────────────────────────────────────────────────
html += "body.dark{";
html += "--bg:#121212;--card:#1e1e1e;--card-border:rgba(255,255,255,0.08);";
html += "--text:#e8e8e8;--text2:#aaa;--text3:#777;";
html += "--inp-bg:#2a2a2a;--inp-border:#333;";
html += "--hr:#333;";
html += "--relay-off:#555;--relay-off-h:#666;";
html += "--wh-ok-bg:#1b3a20;--wh-ok-c:#a5d6a7;";
html += "--wh-err-bg:#3e1a1a;--wh-err-c:#ef9a9a;";
html += "--nfc-idle-bg:#2a2a2a;--nfc-idle-c:#aaa;";
html += "--nfc-granted-bg:#1b3a20;--nfc-granted-c:#a5d6a7;";
html += "--nfc-denied-bg:#3e1a1a;--nfc-denied-c:#ef9a9a;";
html += "--input-field:#2a2a2a;--input-field-b:#444;--select-bg:#2a2a2a;";
html += "--topbar:#0d47a1;--topbar-text:#fff;";
html += "}";
// ── Base ───────────────────────────────────────────────────────────────
html += "*{box-sizing:border-box;margin:0;padding:0}";
html += "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;";
html += "background:var(--bg);color:var(--text);transition:background .2s,color .2s}";
// ── Topbar ─────────────────────────────────────────────────────────────
html += ".topbar{background:var(--topbar);color:var(--topbar-text);padding:12px 16px;";
html += "display:flex;align-items:center;justify-content:space-between;";
html += "position:sticky;top:0;z-index:50;box-shadow:0 2px 6px rgba(0,0,0,.3)}";
html += ".topbar h1{font-size:18px;font-weight:700;letter-spacing:.3px}";
html += ".topbar-right{display:flex;align-items:center;gap:10px}";
html += ".dark-btn{background:rgba(255,255,255,.15);border:1px solid rgba(255,255,255,.3);";
html += "color:#fff;border-radius:20px;padding:6px 14px;cursor:pointer;font-size:13px;";
html += "font-weight:600;white-space:nowrap;transition:background .2s}";
html += ".dark-btn:hover{background:rgba(255,255,255,.25)}";
// ── Layout ─────────────────────────────────────────────────────────────
html += ".page{max-width:820px;margin:0 auto;padding:14px 12px 28px}";
html += ".card{background:var(--card);padding:18px;margin:10px 0;";
html += "border-radius:12px;border:1px solid var(--card-border);";
html += "box-shadow:0 1px 4px rgba(0,0,0,.07)}";
html += "h2{color:var(--text2);font-size:15px;font-weight:700;margin-bottom:12px;text-transform:uppercase;letter-spacing:.5px}";
html += "h3{font-size:13px;color:var(--text2);margin:16px 0 8px}";
// ── 2-col grid (collapses on mobile) ───────────────────────────────────
html += ".grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}";
// Input item with round LED indicator
html += ".inp-item{display:flex;align-items:center;gap:12px;padding:10px 14px;background:#f7f7f7;border-radius:8px;border:1px solid #e0e0e0}";
html += ".led{width:22px;height:22px;border-radius:50%;flex-shrink:0;transition:background 0.3s,box-shadow 0.3s}";
html += ".card-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0}";
html += ".card-row>.card{margin:0}";
html += "@media(max-width:540px){.grid2{grid-template-columns:1fr 1fr}";
html += ".card-row{grid-template-columns:1fr}}";
// ── Input items ────────────────────────────────────────────────────────
html += ".inp-item{display:flex;align-items:center;gap:10px;padding:10px 12px;";
html += "background:var(--inp-bg);border-radius:8px;border:1px solid var(--inp-border)}";
html += ".led{width:20px;height:20px;border-radius:50%;flex-shrink:0;transition:background .3s,box-shadow .3s}";
html += ".led-on{background:#4CAF50;box-shadow:0 0 8px #4CAF5099}";
html += ".led-off{background:#f44336;box-shadow:0 0 8px #f4433666}";
html += ".inp-name{font-weight:bold;font-size:14px;color:#333}";
html += ".inp-state{font-size:11px;color:#888;margin-top:2px}";
// Relay toggle buttons
html += ".relay-btn{width:100%;padding:14px 8px;border:none;border-radius:8px;cursor:pointer;font-size:14px;font-weight:bold;transition:background 0.2s,transform 0.1s}";
html += ".relay-btn:active{transform:scale(0.97)}";
html += ".relay-on{background:#4CAF50;color:white}";
html += ".relay-off{background:#9e9e9e;color:white}";
html += ".relay-on:hover{background:#43A047}.relay-off:hover{background:#757575}";
// LED control & webhook
html += ".card-row{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:10px 0}"; // two big columns
html += ".card-row .card{margin:0}"; // reset per-card margin inside row
html += ".btn{padding:8px 16px;margin:4px;border:none;border-radius:4px;cursor:pointer;font-size:14px}";
html += ".btn-on{background:#4CAF50;color:white}.btn-off{background:#f44336;color:white}";
html += ".wh-ok{background:#c8e6c9;color:#1b5e20;padding:10px;border-radius:4px}";
html += ".wh-err{background:#ffcdd2;color:#b71c1c;padding:10px;border-radius:4px}";
html += ".nfc-state{padding:10px 14px;border-radius:6px;font-weight:bold;font-size:15px;margin-top:4px}";
html += ".nfc-idle{background:#f5f5f5;color:#757575}";
html += ".nfc-granted{background:#c8e6c9;color:#1b5e20}";
html += ".nfc-denied{background:#ffcdd2;color:#b71c1c}";
html += ".inp-name{font-weight:700;font-size:13px;color:var(--text)}";
html += ".inp-state{font-size:11px;color:var(--text3);margin-top:2px}";
// ── Relay buttons ──────────────────────────────────────────────────────
html += ".relay-btn{width:100%;padding:16px 8px;border:none;border-radius:10px;";
html += "cursor:pointer;font-size:14px;font-weight:700;";
html += "transition:background .2s,transform .1s;touch-action:manipulation}";
html += ".relay-btn:active{transform:scale(0.96)}";
html += ".relay-on{background:#4CAF50;color:#fff}.relay-on:hover{background:#43A047}";
html += ".relay-off{background:var(--relay-off);color:#fff}.relay-off:hover{background:var(--relay-off-h)}";
// ── Generic buttons ────────────────────────────────────────────────────
html += ".btn{padding:9px 18px;margin:3px;border:none;border-radius:6px;";
html += "cursor:pointer;font-size:14px;font-weight:600;touch-action:manipulation}";
html += ".btn-on{background:#4CAF50;color:#fff}.btn-off{background:#f44336;color:#fff}";
html += ".btn-on:hover{background:#43A047}.btn-off:hover{background:#e53935}";
// ── Status banners ─────────────────────────────────────────────────────
html += ".wh-ok{background:var(--wh-ok-bg);color:var(--wh-ok-c);padding:10px 12px;border-radius:6px}";
html += ".wh-err{background:var(--wh-err-bg);color:var(--wh-err-c);padding:10px 12px;border-radius:6px}";
// ── NFC ────────────────────────────────────────────────────────────────
html += ".nfc-state{padding:10px 14px;border-radius:8px;font-weight:700;font-size:15px;margin-top:6px}";
html += ".nfc-idle{background:var(--nfc-idle-bg);color:var(--nfc-idle-c)}";
html += ".nfc-granted{background:var(--nfc-granted-bg);color:var(--nfc-granted-c)}";
html += ".nfc-denied{background:var(--nfc-denied-bg);color:var(--nfc-denied-c)}";
html += ".nfc-module-on{background:#4CAF50;color:#fff}";
html += ".nfc-module-off{background:var(--relay-off);color:#fff}";
// ── Form inputs ────────────────────────────────────────────────────────
html += "input[type=text],input[type=number],select{";
html += "background:var(--input-field);color:var(--text);border:1px solid var(--input-field-b);";
html += "border-radius:6px;padding:9px 10px;font-size:14px;width:100%}";
html += "input:focus,select:focus{outline:2px solid #1565c0;outline-offset:1px}";
html += ".field-label{font-size:12px;color:var(--text2);margin-bottom:4px}";
// ── NFC edit grid — stacks on mobile ──────────────────────────────────
html += ".nfc-edit{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:8px;align-items:end}";
html += "@media(max-width:540px){.nfc-edit{grid-template-columns:1fr 1fr}";
html += ".nfc-edit .nfc-save-btn{grid-column:1/-1}}";
// ── Device info row ────────────────────────────────────────────────────
html += ".dev-info{display:flex;flex-wrap:wrap;gap:8px 18px;font-size:13px;color:var(--text2)}";
html += ".dev-info b{color:var(--text)}";
// ── NFC uid ────────────────────────────────────────────────────────────
html += ".uid-row{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}";
html += ".uid-val{font-size:18px;font-weight:700;font-family:monospace;letter-spacing:2px;flex:1;min-width:0;word-break:break-all}";
html += ".nfc-mod-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0 14px;border-bottom:1px solid var(--hr);margin-bottom:14px;flex-wrap:wrap;gap:8px}";
html += "hr.div{border:none;border-top:1px solid var(--hr);margin:14px 0}";
html += "table.settings{width:100%;border-collapse:collapse;font-size:13px}";
html += "table.settings td{padding:8px 6px;color:var(--text2)}";
html += "table.settings tr{border-bottom:1px solid var(--hr)}";
html += "table.settings tr:last-child{border-bottom:none}";
html += "table.settings .val{font-weight:700;color:var(--text)}";
html += "table.settings .mono{font-family:monospace;color:#1b5e20}";
html += ".api-list{font-size:12px;color:var(--text2);line-height:2}";
html += "</style>";
// ── JavaScript ─────────────────────────────────────────────────────────
html += "<script>";
// Relay toggle: read current state from data-state attribute, POST, update DOM
// Dark mode
html += "(function(){var d=localStorage.getItem('dm');if(d==='1')document.documentElement.classList.add('preload');})();";
html += "function applyDark(on){";
html += "document.body.classList.toggle('dark',on);localStorage.setItem('dm',on?'1':'0');";
html += "var b=document.getElementById('dm-btn');if(b)b.textContent=on?'\u2600 Light':'🌙 Dark';}";
html += "function toggleDark(){applyDark(!document.body.classList.contains('dark'));}";
html += "window.addEventListener('DOMContentLoaded',function(){applyDark(localStorage.getItem('dm')==='1');});";
// Relay toggle
html += "function toggleRelay(n){";
html += "var b=document.getElementById('r'+n);";
html += "var on=b.dataset.state==='1';";
html += "var b=document.getElementById('r'+n);var on=b.dataset.state==='1';";
html += "fetch((on?'/relay/off':'/relay/on')+'?relay='+n,{method:'POST'})";
html += ".then(function(){var ns=!on;b.dataset.state=ns?'1':'0';";
html += "b.className='relay-btn '+(ns?'relay-on':'relay-off');";
html += "b.textContent='Relay '+n+': '+(ns?'ON':'OFF');})";
html += ".catch(function(e){console.error(e);});";
html += "}";
// Polling: update LED indicators and relay button states
html += ".catch(function(e){console.error(e);});}";
// Status polling
html += "function updateStatus(){fetch('/api/status').then(function(r){return r.json();}).then(function(d){";
html += "for(var i=1;i<=4;i++){";
// input: raw HIGH=not pressed, so pressed = !d['input'+i]
html += "var p=!d['input'+i];";
html += "document.getElementById('led'+i).className='led '+(p?'led-on':'led-off');";
html += "document.getElementById('is'+i).textContent=p?'PRESSED':'NOT PRESSED';";
// relay: update toggle button
html += "var b=document.getElementById('r'+i);var on=d['relay'+i];";
html += "b.dataset.state=on?'1':'0';";
html += "b.className='relay-btn '+(on?'relay-on':'relay-off');";
html += "b.textContent='Relay '+i+': '+(on?'ON':'OFF');";
html += "}";
// Update NFC section from polled status
html += "b.textContent='Relay '+i+': '+(on?'ON':'OFF');}";
html += "var nled=document.getElementById('nfc-led');";
html += "if(nled)nled.className='led '+(d.nfc_card_present?'led-on':'led-off');";
html += "var nuid=document.getElementById('nfc-uid');";
html += "if(nuid)nuid.textContent=d.nfc_last_uid||'No card inserted';";
html += "var ncb=document.getElementById('nfc-copy-btn');";
html += "if(ncb)ncb.disabled=!d.nfc_card_present;";
html += "var ncb=document.getElementById('nfc-copy-btn');if(ncb)ncb.disabled=!d.nfc_card_present;";
html += "var nac=document.getElementById('nfc-access');";
html += "if(nac){var as=d.nfc_access_state||'idle';";
html += "nac.className='nfc-state nfc-'+as;";
@@ -510,148 +659,158 @@ void handleRoot() {
html += "if(ef&&document.activeElement!==ef)ef.value=d.nfc_auth_uid||'';";
html += "var ad=document.getElementById('nfc-auth-display');";
html += "if(ad){if(d.nfc_auth_uid){ad.style.color='#1b5e20';ad.textContent=d.nfc_auth_uid;}";
html += "else{ad.style.color='#b71c1c';ad.textContent='None \u2014 no card authorized yet';}}";
html += "var rs=document.getElementById('nfc-relay-sel');";
html += "if(rs&&document.activeElement!==rs)rs.value=d.nfc_relay_num||1;";
html += "var pf=document.getElementById('nfc-pulse-field');";
html += "if(pf&&document.activeElement!==pf)pf.value=d.nfc_pulse_ms||3000;";
html += "else{ad.style.color='var(--wh-err-c)';ad.textContent='None \u2014 no card authorized yet';}}";
html += "var rs=document.getElementById('nfc-relay-sel');if(rs&&document.activeElement!==rs)rs.value=d.nfc_relay_num||1;";
html += "var pf=document.getElementById('nfc-pulse-field');if(pf&&document.activeElement!==pf)pf.value=d.nfc_pulse_ms||3000;";
html += "var nmb=document.getElementById('nfc-module-btn');";
html += "if(nmb){var ne=!!d.nfc_enabled;nmb.dataset.enabled=ne?'1':'0';";
html += "nmb.className='btn '+(ne?'nfc-module-on':'nfc-module-off');";
html += "nmb.textContent=ne?'\u2713 Module Enabled':'\u2717 Module Disabled';";
html += "var ns=document.getElementById('nfc-module-status');";
html += "if(ns){ns.textContent=ne?'Active':'Disabled';ns.style.color=ne?'#4CAF50':'#f44336';}}";
html += "}).catch(function(e){console.error(e);});}";
html += "function clearNFCAuth(){";
html += "if(!confirm('Remove the authorized card?'))return;";
html += "fetch('/nfc/config?auth_uid=&relay='+document.getElementById('nfc-relay-sel').value+'&pulse_ms='+document.getElementById('nfc-pulse-field').value,{method:'POST'})";
html += ".then(function(r){return r.json();})";
// NFC module toggle
html += "function toggleNFCModule(){";
html += "var btn=document.getElementById('nfc-module-btn');var en=btn.dataset.enabled==='1';";
html += "fetch('/nfc/enable?state='+(en?'0':'1'),{method:'POST'})";
html += ".then(function(r){return r.json();})";
html += ".then(function(d){if(d.status==='ok'){var ne=!!d.nfc_enabled;";
html += "btn.dataset.enabled=ne?'1':'0';btn.className='btn '+(ne?'nfc-module-on':'nfc-module-off');";
html += "btn.textContent=ne?'\u2713 Module Enabled':'\u2717 Module Disabled';";
html += "var ns=document.getElementById('nfc-module-status');";
html += "if(ns){ns.textContent=ne?'Active':'Disabled';ns.style.color=ne?'#4CAF50':'#f44336';}}";
html += "else{alert('Error: '+d.error);}}).catch(function(e){alert('Network error');});}";
// NFC helpers
html += "function clearNFCAuth(){if(!confirm('Remove the authorized card?'))return;";
html += "fetch('/nfc/config?auth_uid=&relay='+document.getElementById('nfc-relay-sel').value+'&pulse_ms='+document.getElementById('nfc-pulse-field').value,{method:'POST'})";
html += ".then(function(r){return r.json();})";
html += ".then(function(d){if(d.status==='ok'){alert('Authorized card cleared.');}else{alert('Error: '+d.error);}})";
html += ".catch(function(e){alert('Network error');});}";
html += ".catch(function(e){alert('Network error');});}";
html += "function saveNFCConfig(){";
html += "var uid=document.getElementById('nfc-auth-field').value.trim().toUpperCase();";
html += "var relay=document.getElementById('nfc-relay-sel').value;";
html += "var pulse=document.getElementById('nfc-pulse-field').value;";
html += "fetch('/nfc/config?auth_uid='+encodeURIComponent(uid)+'&relay='+relay+'&pulse_ms='+pulse,{method:'POST'})";
html += ".then(function(r){return r.json();})";
html += ".then(function(d){if(d.status==='ok'){alert('Saved!\\nAuthorized UID: '+(d.auth_uid||'(none)')+'\\nRelay: '+d.relay_num+'\\nAbsent timeout: '+d.pulse_ms+'ms');}else{alert('Error: '+d.error);}})";
html += ".catch(function(e){alert('Network error');console.error(e);});}";
html += "function copyUID(){";
html += "var u=document.getElementById('nfc-uid').textContent;";
html += ".then(function(d){if(d.status==='ok'){alert('Saved!\\nUID: '+(d.auth_uid||'(none)')+'\\nRelay: '+d.relay_num+'\\nTimeout: '+d.pulse_ms+'ms');}else{alert('Error: '+d.error);}})";
html += ".catch(function(e){alert('Network error');});}";
html += "function copyUID(){var u=document.getElementById('nfc-uid').textContent;";
html += "if(u&&u!=='No card inserted'){var f=document.getElementById('nfc-auth-field');f.value=u;";
html += "var b=document.getElementById('nfc-copy-btn');b.textContent='\u2713 Copied!';";
html += "setTimeout(function(){b.textContent='\u2193 Use as authorized';},1500);}}";
html += "window.addEventListener('load',function(){updateStatus();setInterval(updateStatus,2000);});";
html += "</script>";
html += "</head><body>";
html += "<h1>ESP32-C6 Control Panel</h1>";
html += "window.addEventListener('load',function(){updateStatus();setInterval(updateStatus,2000);});";
html += "</script></head>";
// ── Body ───────────────────────────────────────────────────────────────
html += "<body>";
// Topbar
html += "<div class='topbar'>";
html += "<h1>&#128268; ESP32-C6</h1>";
html += "<div class='topbar-right'>";
html += "<button id='dm-btn' class='dark-btn' onclick='toggleDark()'>🌙 Dark</button>";
html += "</div></div>";
html += "<div class='page'>";
// Device Info
html += "<div class='card'><h2>Device Info</h2>";
html += "IP: " + WiFi.localIP().toString() + " &nbsp; ";
html += "RSSI: " + String(WiFi.RSSI()) + " dBm &nbsp; ";
html += "Temp: " + String(temperature, 1) + "°C &nbsp; ";
html += "Uptime: " + String(millis() / 1000) + "s</div>";
html += "<div class='card'>";
html += "<div class='dev-info'>";
html += "<span><b>IP</b> " + WiFi.localIP().toString() + "</span>";
html += "<span><b>RSSI</b> " + String(WiFi.RSSI()) + " dBm</span>";
html += "<span><b>Temp</b> " + String(temperature, 1) + " &deg;C</span>";
html += "<span><b>Uptime</b> " + String(millis() / 1000) + " s</span>";
html += "</div></div>";
// Inputs + Relays side by side in a 2-column row
// Inputs + Relays side by side
html += "<div class='card-row'>";
// Inputs — 2-column inner grid, round LED indicator
// Inputs
html += "<div class='card'><h2>Inputs</h2><div class='grid2'>";
bool inputStates[5] = {false, input1_state, input2_state, input3_state, input4_state};
for (int i = 1; i <= 4; i++) {
bool pressed = !inputStates[i]; // pull-up: LOW=pressed
bool pressed = !inputStates[i];
html += "<div class='inp-item'>";
html += "<div class='led " + String(pressed ? "led-on" : "led-off") + "' id='led" + String(i) + "'></div>";
html += "<div><div class='inp-name'>Input " + String(i) + "</div>";
html += "<div class='inp-state' id='is" + String(i) + "'>" + String(pressed ? "PRESSED" : "NOT PRESSED") + "</div></div>";
html += "<div><div class='inp-name'>IN " + String(i) + "</div>";
html += "<div class='inp-state' id='is" + String(i) + "'>" + String(pressed ? "PRESSED" : "OPEN") + "</div></div>";
html += "</div>";
}
html += "</div></div>"; // close Inputs card
// Relays — 2-column inner grid, toggle buttons
html += "</div></div>";
// Relays
html += "<div class='card'><h2>Relays</h2><div class='grid2'>";
bool relayStates[5] = {false, relay1_state, relay2_state, relay3_state, relay4_state};
for (int i = 1; i <= 4; i++) {
bool on = relayStates[i];
html += "<button class='relay-btn " + String(on ? "relay-on" : "relay-off") + "' ";
html += "id='r" + String(i) + "' data-state='" + String(on ? "1" : "0") + "' ";
html += "onclick='toggleRelay(" + String(i) + ")'>";
html += "Relay " + String(i) + ": " + String(on ? "ON" : "OFF");
html += "</button>";
html += "onclick='toggleRelay(" + String(i) + ")'>Relay " + String(i) + ": " + String(on ? "ON" : "OFF") + "</button>";
}
html += "</div></div>"; // close Relays card
html += "</div>"; // close .card-row
html += "</div></div>";
html += "</div>"; // .card-row
// LED Control
html += "<div class='card'><h2>LED Control</h2>";
html += "<button class='btn btn-on' onclick='fetch(\"/led/on\",{method:\"POST\"}).then(()=>location.reload())'>LED ON</button>";
html += "<button class='btn btn-off' onclick='fetch(\"/led/off\",{method:\"POST\"}).then(()=>location.reload())'>LED OFF</button>";
html += " &nbsp; Status: <strong>" + String(led_state ? "ON" : "OFF") + "</strong></div>";
html += " &nbsp; <span style='color:var(--text2);font-size:14px'>Status: <strong>" + String(led_state ? "ON" : "OFF") + "</strong></span></div>";
// ── NFC Access Control card ───────────────────────────────────────────────
bool nfc_present_now = nfc_initialized && (strcmp(nfc_access_state, "granted") == 0);
// NFC Access Control
bool nfc_present_now = nfc_initialized && nfc_enabled && (strcmp(nfc_access_state, "granted") == 0);
String ac_class = String(nfc_access_state) == "granted" ? "nfc-granted" :
String(nfc_access_state) == "denied" ? "nfc-denied" : "nfc-idle";
String ac_text = String(nfc_access_state) == "granted" ? "ACCESS GRANTED" :
String(nfc_access_state) == "denied" ? "ACCESS DENIED" : "Waiting for card";
html += "<div class='card'><h2>NFC Access Control (PN532 &mdash; UEXT1)</h2>";
// ── Live status row ────────────────────────────────────────────────────
html += "<div style='display:flex;align-items:center;gap:14px;margin-bottom:12px'>";
// Module enable row
html += "<div class='nfc-mod-row'>";
html += "<span style='font-size:13px;color:var(--text2)'>Module: <strong id='nfc-module-status' style='color:" + String(nfc_enabled ? "#4CAF50" : "#f44336") + "'>" + String(nfc_enabled ? "Active" : "Disabled") + "</strong></span>";
html += "<button id='nfc-module-btn' class='btn " + String(nfc_enabled ? "nfc-module-on" : "nfc-module-off") + "' data-enabled='" + String(nfc_enabled ? "1" : "0") + "' onclick='toggleNFCModule()'>" + String(nfc_enabled ? "&#10003; Module Enabled" : "&#10007; Module Disabled") + "</button>";
html += "</div>";
// Live UID row
html += "<div class='uid-row'>";
html += "<div class='led " + String(nfc_present_now ? "led-on" : "led-off") + "' id='nfc-led'></div>";
html += "<div style='flex:1'>";
html += "<div style='font-size:11px;color:#888;margin-bottom:4px'>Detected card UID</div>";
html += "<div id='nfc-uid' style='font-size:20px;font-weight:bold;font-family:monospace;letter-spacing:2px'>";
html += (nfc_last_uid.length() > 0 ? nfc_last_uid : "No card inserted");
html += "</div></div>";
html += "<button id='nfc-copy-btn' class='btn' onclick='copyUID()' style='white-space:nowrap'" + String(nfc_present_now ? "" : " disabled") + ">&#8595; Use as authorized</button>";
html += "<div style='flex:1;min-width:0'>";
html += "<div style='font-size:11px;color:var(--text3);margin-bottom:3px'>Detected card UID</div>";
html += "<div id='nfc-uid' class='uid-val'>" + (nfc_last_uid.length() > 0 ? nfc_last_uid : "No card inserted") + "</div>";
html += "</div>";
html += "<button id='nfc-copy-btn' class='btn btn-on' onclick='copyUID()' style='white-space:nowrap;font-size:13px'" + String(nfc_present_now ? "" : " disabled") + ">&#8595; Use as authorized</button>";
html += "</div>";
html += "<div id='nfc-access' class='nfc-state " + ac_class + "'>" + ac_text + "</div>";
// ── Current Settings (read-only) ───────────────────────────────────────
html += "<h3 style='font-size:14px;color:#555;margin:18px 0 8px'>Current Settings</h3>";
html += "<table style='width:100%;border-collapse:collapse;font-size:13px'>";
// Authorized card row
html += "<tr style='border-bottom:1px solid #eee'>";
html += "<td style='padding:8px 6px;color:#666;width:160px'>Authorized card</td>";
// Current settings table
html += "<h3>Current Settings</h3>";
html += "<table class='settings'>";
html += "<tr><td style='width:150px'>Authorized card</td>";
if (strlen(nfc_auth_uid) > 0) {
html += "<td style='padding:8px 6px;font-family:monospace;font-weight:bold;color:#1b5e20' id='nfc-auth-display'>" + String(nfc_auth_uid) + "</td>";
html += "<td style='padding:8px 6px;text-align:right'><button class='btn btn-off' style='font-size:12px;padding:4px 10px' onclick='clearNFCAuth()'>&#10005; Remove</button></td>";
html += "<td class='val mono' id='nfc-auth-display'>" + String(nfc_auth_uid) + "</td>";
html += "<td style='text-align:right'><button class='btn btn-off' style='font-size:12px;padding:5px 10px' onclick='clearNFCAuth()'>&#10005; Remove</button></td>";
} else {
html += "<td colspan='2' style='padding:8px 6px;color:#b71c1c' id='nfc-auth-display'>None &mdash; no card authorized yet</td>";
html += "<td colspan='2' style='color:var(--wh-err-c)' id='nfc-auth-display'>None &mdash; no card authorized yet</td>";
}
html += "</tr>";
// Relay row
html += "<tr style='border-bottom:1px solid #eee'>";
html += "<td style='padding:8px 6px;color:#666'>Trigger relay</td>";
html += "<td colspan='2' style='padding:8px 6px;font-weight:bold'>Relay " + String(nfc_relay_num) + "</td>";
html += "</tr>";
// Timeout row
html += "<tr>";
html += "<td style='padding:8px 6px;color:#666'>Absent timeout</td>";
html += "<td colspan='2' style='padding:8px 6px;font-weight:bold'>" + String(nfc_pulse_ms) + " ms</td>";
html += "</tr>";
html += "<tr><td>Trigger relay</td><td colspan='2' class='val'>Relay " + String(nfc_relay_num) + "</td></tr>";
html += "<tr><td>Absent timeout</td><td colspan='2' class='val'>" + String(nfc_pulse_ms) + " ms</td></tr>";
html += "</table>";
// ── Edit Settings form ─────────────────────────────────────────────────
html += "<h3 style='font-size:14px;color:#555;margin:18px 0 8px'>Edit Settings</h3>";
html += "<div style='display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:8px;align-items:end'>";
html += "<div><div style='font-size:12px;color:#666;margin-bottom:4px'>Authorized UID</div>";
html += "<input id='nfc-auth-field' style='width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;font-family:monospace;box-sizing:border-box' placeholder='e.g. 04:AB:CD:EF' value='" + String(nfc_auth_uid) + "'></div>";
html += "<div><div style='font-size:12px;color:#666;margin-bottom:4px'>Trigger relay</div>";
html += "<select id='nfc-relay-sel' style='width:100%;padding:8px;border:1px solid #ccc;border-radius:4px'>";
// Edit settings form
html += "<h3>Edit Settings</h3>";
html += "<div class='nfc-edit'>";
html += "<div><div class='field-label'>Authorized UID</div>";
html += "<input type='text' id='nfc-auth-field' placeholder='e.g. 04:AB:CD:EF' value='" + String(nfc_auth_uid) + "' style='font-family:monospace'></div>";
html += "<div><div class='field-label'>Trigger relay</div>";
html += "<select id='nfc-relay-sel'>";
for (int r = 1; r <= 4; r++) {
html += "<option value='" + String(r) + "'" + String(nfc_relay_num == r ? " selected" : "") + ">Relay " + String(r) + "</option>";
}
html += "</select></div>";
html += "<div><div style='font-size:12px;color:#666;margin-bottom:4px'>Absent timeout (ms)</div>";
html += "<input id='nfc-pulse-field' type='number' min='100' max='60000' style='width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box' value='" + String(nfc_pulse_ms) + "'></div>";
html += "<button class='btn btn-on' onclick='saveNFCConfig()' style='padding:8px 18px'>Save</button>";
html += "<div><div class='field-label'>Absent timeout (ms)</div>";
html += "<input type='number' id='nfc-pulse-field' min='100' max='60000' value='" + String(nfc_pulse_ms) + "'></div>";
html += "<button class='btn btn-on nfc-save-btn' onclick='saveNFCConfig()' style='padding:9px 18px'>Save</button>";
html += "</div>";
if (!nfc_initialized) {
html += "<p style='color:#b71c1c;font-size:13px;margin:8px 0 0'>&#10007; PN532 not detected &mdash; check UEXT1 wiring (TX=GPIO4, RX=GPIO5)</p>";
html += "<p style='color:var(--wh-err-c);font-size:13px;margin-top:10px'>&#10007; PN532 not detected &mdash; check UEXT1 wiring (TX=GPIO4, RX=GPIO5)</p>";
}
if (nfc_initialized && strlen(nfc_auth_uid) == 0) {
html += "<p style='color:#e65100;font-size:13px;margin:8px 0 0'>&#9888; No authorized UID saved &mdash; present a card, click &ldquo;Use as authorized&rdquo; then Save.</p>";
html += "<p style='color:#e65100;font-size:13px;margin-top:10px'>&#9888; No authorized UID &mdash; present a card, click &ldquo;Use as authorized&rdquo; then Save.</p>";
}
html += "</div>";
html += "</div>"; // close NFC card
// Home Assistant Webhook Status
// HA Webhook
html += "<div class='card'><h2>Home Assistant Webhook</h2>";
if (ha_registered && strlen(ha_callback_url) > 0) {
html += "<div class='wh-ok'>&#10003; Connected &mdash; " + String(ha_callback_url) + "</div>";
@@ -660,12 +819,15 @@ void handleRoot() {
}
html += "</div>";
html += "<div class='card'><h2>API Endpoints</h2>";
html += "GET /api/status &nbsp; POST /relay/on?relay=1-4 &nbsp; POST /relay/off?relay=1-4<br>";
html += "GET /input/status?input=1-4 &nbsp; POST /led/on &nbsp; POST /led/off<br>";
html += "GET /nfc/status &nbsp; GET /nfc/config &nbsp; POST /nfc/config?auth_uid=&amp;relay=&amp;pulse_ms= &nbsp; POST /register?callback_url=...</div>";
// API reference
html += "<div class='card'><h2>API Endpoints</h2><div class='api-list'>";
html += "GET /api/status &nbsp;&bull;&nbsp; POST /relay/on?relay=1-4 &nbsp;&bull;&nbsp; POST /relay/off?relay=1-4<br>";
html += "GET /input/status?input=1-4 &nbsp;&bull;&nbsp; POST /led/on &nbsp;&bull;&nbsp; POST /led/off<br>";
html += "GET /nfc/status &nbsp;&bull;&nbsp; GET /nfc/config &nbsp;&bull;&nbsp; POST /nfc/config?auth_uid=&amp;relay=&amp;pulse_ms=<br>";
html += "POST /nfc/enable?state=0|1 &nbsp;&bull;&nbsp; POST /register?callback_url=...";
html += "</div></div>";
html += "</body></html>";
html += "</div></body></html>"; // .page
server.send(200, "text/html", html);
}
@@ -673,7 +835,7 @@ void handleRoot() {
// ── Web UI authentication helper ────────────────────────────────────────
bool requireAuth() {
if (!server.authenticate(WEB_USER, WEB_PASSWORD)) {
server.requestAuthentication(DIGEST_AUTH, "ESP32-C6 Control Panel",
server.requestAuthentication(BASIC_AUTH, "ESP32-C6 Control Panel",
"Login required");
return false;
}
@@ -782,7 +944,8 @@ void handleStatus() {
json += "\"nfc_access_state\":\"" + String(nfc_access_state) + "\",";
json += "\"nfc_auth_uid\":\"" + String(nfc_auth_uid) + "\",";
json += "\"nfc_relay_num\":" + String(nfc_relay_num) + ",";
json += "\"nfc_pulse_ms\":" + String(nfc_pulse_ms);
json += "\"nfc_pulse_ms\":" + String(nfc_pulse_ms) + ",";
json += "\"nfc_enabled\":" + String(nfc_enabled ? "true" : "false");
json += "}";
server.send(200, "application/json", json);
@@ -943,7 +1106,13 @@ void handleRegister() {
url.toCharArray(ha_callback_url, 256);
ha_registered = true;
// ── Persist HA registration to NVS ───────────────────────────────────────
prefs.begin("ha_cfg", false); // read-write namespace
prefs.putString("callback_url", ha_callback_url);
prefs.end();
// ─────────────────────────────────────────────────────────────────────────
Serial.printf("Home Assistant webhook registered: %s\n", ha_callback_url);
server.send(200, "application/json", "{\"status\":\"ok\",\"message\":\"Webhook registered\"}");
}
@@ -1041,11 +1210,49 @@ void postInputEvent(int input_num, bool state) {
// NFC Status API
// ============================================
// ============================================
// NFC Enable/Disable POST /nfc/enable?state=0|1
// ============================================
void handleNFCEnable() {
if (!verifyAPIRequest()) return;
if (!server.hasArg("state")) {
server.send(400, "application/json", "{\"error\":\"Missing state (0 or 1)\"}");
return;
}
nfc_enabled = (server.arg("state").toInt() != 0);
// When disabling while a card is granted, close the relay and reset state
if (!nfc_enabled) {
if (strcmp(nfc_access_state, "granted") == 0) {
int pin = nfcRelayPin(nfc_relay_num);
if (pin >= 0) {
digitalWrite(pin, LOW);
switch (nfc_relay_num) {
case 1: relay1_state = false; break;
case 2: relay2_state = false; break;
case 3: relay3_state = false; break;
case 4: relay4_state = false; break;
}
}
}
strcpy(nfc_access_state, "idle");
nfc_last_uid = "";
nfc_miss_count = 0;
}
prefs.begin("nfc_cfg", false);
prefs.putBool("enabled", nfc_enabled);
prefs.end();
Serial.printf("NFC access control: %s\n", nfc_enabled ? "ENABLED" : "DISABLED");
server.send(200, "application/json",
String("{\"status\":\"ok\",\"nfc_enabled\":") + (nfc_enabled ? "true" : "false") + "}");
}
void handleNFCStatus() {
if (!verifyAPIRequest()) return;
bool present = nfc_initialized && (strcmp(nfc_access_state, "granted") == 0);
bool present = nfc_initialized && nfc_enabled && (strcmp(nfc_access_state, "granted") == 0);
String json = "{";
json += "\"initialized\":" + String(nfc_initialized ? "true" : "false") + ",";
json += "\"nfc_enabled\":" + String(nfc_enabled ? "true" : "false") + ",";
json += "\"card_present\":" + String(present ? "true" : "false") + ",";
json += "\"last_uid\":\"" + nfc_last_uid + "\",";
json += "\"access_state\":\"" + String(nfc_access_state) + "\",";
@@ -1093,7 +1300,8 @@ void handleNFCConfigGet() {
String json = "{";
json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\",";
json += "\"relay_num\":" + String(nfc_relay_num) + ",";
json += "\"pulse_ms\":" + String(nfc_pulse_ms);
json += "\"pulse_ms\":" + String(nfc_pulse_ms) + ",";
json += "\"nfc_enabled\":" + String(nfc_enabled ? "true" : "false");
json += "}";
server.send(200, "application/json", json);
}
@@ -1131,6 +1339,13 @@ void handleNFCConfigSet() {
}
Serial.printf("NFC config: auth='%s' relay=%d pulse=%lu ms\n",
nfc_auth_uid, nfc_relay_num, nfc_pulse_ms);
// ── Persist to NVS so settings survive power cycles ──────────────────────
prefs.begin("nfc_cfg", false); // read-write namespace
prefs.putString("auth_uid", nfc_auth_uid);
prefs.putInt("relay_num", nfc_relay_num);
prefs.putULong("pulse_ms", nfc_pulse_ms);
prefs.end();
// ─────────────────────────────────────────────────────────────────────────
String json = "{\"status\":\"ok\","
"\"auth_uid\":\"" + String(nfc_auth_uid) + "\","
"\"relay_num\":" + String(nfc_relay_num) + ","