1391 lines
65 KiB
C++
1391 lines
65 KiB
C++
/**
|
|
* ESP32-C6 Home Assistant Integration
|
|
* Arduino IDE Project
|
|
*
|
|
* Board: ESP32C6 Dev Module
|
|
* Flash Size: 4MB
|
|
* USB CDC On Boot: Enabled (REQUIRED for serial output!)
|
|
*
|
|
* Provides REST API for Home Assistant integration
|
|
*/
|
|
// 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) → GPO5
|
|
// PN532 module wiring note: set HSU mode — DIP1 = 0, DIP2 = 0
|
|
#include <PN532_HSU.h>
|
|
#include <PN532.h>
|
|
|
|
#define NFC_TX_PIN 4 // UEXT1 pin 3 — ESP32 transmits to PN532
|
|
#define NFC_RX_PIN 5 // UEXT1 pin 4 — ESP32 receives from PN532
|
|
#define NFC_POLL_MS 500 // idle detection interval (ms)
|
|
|
|
// ── Credentials & shared secrets ─────────────────────────────────────────────
|
|
// Loaded from secrets.h which is NOT committed to version control.
|
|
// Copy secrets.h.example → secrets.h, fill in your values, then flash.
|
|
#include "secrets.h"
|
|
#include "mbedtls/md.h" // HMAC-SHA256 — API request authentication
|
|
#include <time.h> // time() — NTP-based timestamp validation
|
|
|
|
HardwareSerial nfcSerial(1); // UART1
|
|
PN532_HSU pn532hsu(nfcSerial);
|
|
PN532 nfc(pn532hsu);
|
|
|
|
bool nfc_initialized = false;
|
|
String nfc_last_uid = "";
|
|
unsigned long nfc_last_poll_ms = 0;
|
|
int nfc_miss_count = 0; // consecutive polls with no card detected
|
|
// NFC Access Control
|
|
char nfc_auth_uid[32] = ""; // authorized card UID; empty = any card triggers
|
|
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
|
|
const char* ssid = "BUON GUSTO PARTER";
|
|
const char* password = "arleta13";
|
|
|
|
// Web server on port 80
|
|
WebServer server(80);
|
|
|
|
// GPIO pins - Olimex ESP32-C6-EVB board configuration
|
|
const int LED_PIN = 8; // Onboard LED
|
|
const int BUT_PIN = 9; // Onboard button
|
|
const int RELAY_1_PIN = 10; // Relay 1
|
|
const int RELAY_2_PIN = 11; // Relay 2
|
|
const int RELAY_3_PIN = 22; // Relay 3
|
|
const int RELAY_4_PIN = 23; // Relay 4
|
|
const int DIN1_PIN = 1; // Digital Input 1
|
|
const int DIN2_PIN = 2; // Digital Input 2
|
|
const int DIN3_PIN = 3; // Digital Input 3
|
|
const int DIN4_PIN = 15; // Digital Input 4
|
|
|
|
// State tracking
|
|
bool relay1_state = false;
|
|
bool relay2_state = false;
|
|
bool relay3_state = false;
|
|
bool relay4_state = false;
|
|
bool led_state = false;
|
|
|
|
// Input state tracking - for change detection
|
|
bool input1_state = true; // HIGH when not pressed (pull-up)
|
|
bool input2_state = true;
|
|
bool input3_state = true;
|
|
bool input4_state = true;
|
|
bool last_input1_state = true;
|
|
bool last_input2_state = true;
|
|
bool last_input3_state = true;
|
|
bool last_input4_state = true;
|
|
unsigned long last_input_check = 0;
|
|
|
|
// Home Assistant callback configuration
|
|
char ha_callback_url[256] = ""; // URL to POST input events to
|
|
bool ha_registered = false;
|
|
|
|
// Temperature simulation
|
|
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);
|
|
delay(2000); // Give time for USB CDC to initialize
|
|
|
|
// Wait for serial port to be ready (up to 5 seconds)
|
|
for (int i = 0; i < 10 && !Serial; i++) {
|
|
delay(500);
|
|
}
|
|
|
|
Serial.println("\n\n=================================");
|
|
Serial.println("ESP32-C6 Home Assistant Device");
|
|
Serial.println("Arduino Framework");
|
|
Serial.println("=================================");
|
|
|
|
// Initialize GPIO - Outputs
|
|
pinMode(LED_PIN, OUTPUT);
|
|
pinMode(RELAY_1_PIN, OUTPUT);
|
|
pinMode(RELAY_2_PIN, OUTPUT);
|
|
pinMode(RELAY_3_PIN, OUTPUT);
|
|
pinMode(RELAY_4_PIN, OUTPUT);
|
|
// Initialize GPIO - Inputs with pull-up
|
|
pinMode(BUT_PIN, INPUT_PULLUP);
|
|
pinMode(DIN1_PIN, INPUT_PULLUP);
|
|
pinMode(DIN2_PIN, INPUT_PULLUP);
|
|
pinMode(DIN3_PIN, INPUT_PULLUP);
|
|
pinMode(DIN4_PIN, INPUT_PULLUP);
|
|
// Set all outputs to LOW
|
|
digitalWrite(LED_PIN, LOW);
|
|
digitalWrite(RELAY_1_PIN, LOW);
|
|
digitalWrite(RELAY_2_PIN, LOW);
|
|
digitalWrite(RELAY_3_PIN, LOW);
|
|
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
|
|
// allow up to 3 full connection attempts (60 s total) before giving up.
|
|
Serial.println("\n--- WiFi Configuration ---");
|
|
WiFi.disconnect(true); // reset radio & erase any stale state
|
|
delay(200);
|
|
WiFi.mode(WIFI_STA);
|
|
WiFi.setAutoReconnect(true);
|
|
|
|
// Static IP
|
|
IPAddress staticIP(192, 168, 0, 240);
|
|
IPAddress gateway(192, 168, 0, 1);
|
|
IPAddress subnet(255, 255, 255, 0);
|
|
WiFi.config(staticIP, gateway, subnet);
|
|
|
|
bool wifi_ok = false;
|
|
for (int pass = 1; pass <= 3 && !wifi_ok; pass++) {
|
|
Serial.printf("Connecting to WiFi: %s (attempt %d/3)\n", ssid, pass);
|
|
WiFi.begin(ssid, password);
|
|
for (int t = 0; t < 40 && WiFi.status() != WL_CONNECTED; t++) {
|
|
delay(500);
|
|
Serial.print(".");
|
|
}
|
|
Serial.println();
|
|
if (WiFi.status() == WL_CONNECTED) wifi_ok = true;
|
|
else if (pass < 3) {
|
|
Serial.println(" Not connected yet, retrying...");
|
|
WiFi.disconnect(true);
|
|
delay(1000);
|
|
WiFi.mode(WIFI_STA);
|
|
WiFi.config(staticIP, gateway, subnet);
|
|
}
|
|
}
|
|
|
|
if (wifi_ok) {
|
|
Serial.println("\n\u2713 WiFi connected!");
|
|
Serial.print(" IP : "); Serial.println(WiFi.localIP());
|
|
Serial.print(" RSSI : "); Serial.print(WiFi.RSSI()); Serial.println(" dBm");
|
|
Serial.print(" MAC : "); Serial.println(WiFi.macAddress());
|
|
} else {
|
|
Serial.println("\n\u2717 WiFi connection failed after 3 attempts.");
|
|
Serial.print(" Status code: "); Serial.println(WiFi.status());
|
|
Serial.println(" Check: correct SSID/password, 2.4 GHz band, board in range.");
|
|
Serial.println(" HTTP server will start anyway \u2014 accessible once WiFi reconnects.");
|
|
}
|
|
|
|
// ── NTP time sync ────────────────────────────────────────────────────────────
|
|
// Required for HMAC timestamp validation in verifyAPIRequest().
|
|
// Syncs wall-clock time; if no internet the timestamp check is skipped but
|
|
// HMAC verification still runs (proving knowledge of API_SECRET).
|
|
configTime(0, 0, "pool.ntp.org", "time.google.com");
|
|
if (wifi_ok) {
|
|
Serial.print("NTP sync ");
|
|
time_t _ntp_now = time(nullptr);
|
|
for (int _i = 0; _i < 20 && _ntp_now < 1577836800L; _i++) {
|
|
delay(500); Serial.print("."); _ntp_now = time(nullptr);
|
|
}
|
|
Serial.println();
|
|
if (_ntp_now > 1577836800L) {
|
|
char _tbuf[32];
|
|
strftime(_tbuf, sizeof(_tbuf), "%Y-%m-%d %H:%M:%S UTC", gmtime(&_ntp_now));
|
|
Serial.printf("\u2713 NTP synced: %s\n", _tbuf);
|
|
} else {
|
|
Serial.println("\u26a0 NTP sync failed \u2014 timestamp check disabled");
|
|
}
|
|
}
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Setup API endpoints
|
|
server.on("/", handleRoot);
|
|
server.on("/api/status", HTTP_GET, handleStatus);
|
|
// Relay endpoints
|
|
server.on("/relay/on", HTTP_POST, handleRelayOn);
|
|
server.on("/relay/off", HTTP_POST, handleRelayOff);
|
|
server.on("/relay/status", HTTP_GET, handleRelayStatus);
|
|
// Input endpoints
|
|
server.on("/input/status", HTTP_GET, handleInputStatus);
|
|
// Home Assistant webhook registration
|
|
server.on("/register", HTTP_POST, handleRegister);
|
|
// LED endpoints
|
|
server.on("/led/on", HTTP_POST, handleLEDOn);
|
|
server.on("/led/off", HTTP_POST, handleLEDOff);
|
|
server.onNotFound(handleNotFound);
|
|
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() ─────────────────────────
|
|
const char* _api_hdrs[] = {"X-Request-Time", "X-Request-Sig"};
|
|
server.collectHeaders(_api_hdrs, 2);
|
|
|
|
// Start server
|
|
server.begin();
|
|
Serial.println("\n✓ HTTP server started on port 80");
|
|
Serial.println("\n=================================");
|
|
Serial.println("Ready! Try these endpoints:");
|
|
Serial.print(" http://");
|
|
Serial.print(WiFi.localIP());
|
|
Serial.println("/api/status");
|
|
Serial.println("=================================\n");
|
|
|
|
// ── NFC (PN532 HSU) initialisation — multi-baud auto-detect ─────────────
|
|
// The PN532 default HSU baud is 115200, but some modules ship at 9600.
|
|
// We try both, plus RX/TX swapped, so the board will find the module
|
|
// regardless of those two variables.
|
|
Serial.println("--- NFC (PN532 HSU) Initialization ---");
|
|
|
|
// Baud rates to try, in order
|
|
const long NFC_BAUDS[] = {115200, 9600, 57600, 38400};
|
|
const int NFC_NBAUDS = 4;
|
|
// Pin pairs to try: {RX, TX}. Second pair = swapped.
|
|
const int NFC_PINS[2][2] = {{NFC_RX_PIN, NFC_TX_PIN},
|
|
{NFC_TX_PIN, NFC_RX_PIN}};
|
|
|
|
uint32_t versiondata = 0;
|
|
long found_baud = 0;
|
|
int found_rx = NFC_RX_PIN;
|
|
int found_tx = NFC_TX_PIN;
|
|
|
|
for (int pi = 0; pi < 2 && !versiondata; pi++) {
|
|
int rx = NFC_PINS[pi][0];
|
|
int tx = NFC_PINS[pi][1];
|
|
for (int bi = 0; bi < NFC_NBAUDS && !versiondata; bi++) {
|
|
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);
|
|
// 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) {
|
|
unsigned long _t = millis(); while (millis() - _t < 200) { server.handleClient(); yield(); }
|
|
} else { found_baud = baud; found_rx = rx; found_tx = tx; }
|
|
}
|
|
}
|
|
|
|
if (!versiondata) {
|
|
Serial.println("\u2717 PN532 not detected with any baud/pin combination.");
|
|
Serial.println(" Hardware checklist:");
|
|
Serial.println(" 1. DIP/solder-jumpers on PN532 board: BOTH = 0 (HSU mode)");
|
|
Serial.println(" Some boards label them SEL0/SEL1 or I0/I1 — both must be LOW.");
|
|
Serial.println(" 2. Power: UEXT1 pin 1 = 3V3, pin 2 = GND. Measure with multimeter.");
|
|
Serial.println(" 3. Wiring: UEXT1 pin 3 (GPIO4) ↔ PN532 RXD");
|
|
Serial.println(" UEXT1 pin 4 (GPIO5) ↔ PN532 TXD");
|
|
Serial.println(" 4. Some PN532 breakouts need a 100 ohm series resistor on TX line.");
|
|
} else {
|
|
Serial.printf("\u2713 PN532 found! baud=%ld RX=GPIO%d TX=GPIO%d\n",
|
|
found_baud, found_rx, found_tx);
|
|
Serial.print(" Chip: PN5");
|
|
Serial.println((versiondata >> 24) & 0xFF, HEX);
|
|
Serial.printf(" Firmware: %d.%d\n",
|
|
(versiondata >> 16) & 0xFF, (versiondata >> 8) & 0xFF);
|
|
// Re-init serial with confirmed settings
|
|
nfcSerial.begin(found_baud, SERIAL_8N1, found_rx, found_tx);
|
|
nfc.SAMConfig();
|
|
nfc_initialized = true;
|
|
Serial.println("\u2713 NFC ready \u2014 waiting for ISO14443A / Mifare cards");
|
|
}
|
|
// ──────────────────────────────────────────────────────────────────────────
|
|
}
|
|
|
|
void loop() {
|
|
server.handleClient();
|
|
|
|
// Simulate temperature reading — update every 5 s
|
|
if (millis() - last_temp_update >= 5000) {
|
|
last_temp_update = millis();
|
|
temperature = 25.0 + (random(-20, 20) / 10.0);
|
|
}
|
|
|
|
// Check for input state changes every 50ms
|
|
if (millis() - last_input_check > 50) {
|
|
last_input_check = millis();
|
|
checkInputChanges();
|
|
}
|
|
|
|
// ── NFC: two-phase polling ────────────────────────────────────────────────
|
|
// IDLE : fast poll every NFC_POLL_MS (500 ms), 50 ms RF timeout
|
|
// GRANTED/DENIED: slow presence-check every nfc_pulse_ms, 500 ms RF timeout
|
|
// 2 consecutive misses required to confirm card is gone
|
|
{
|
|
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_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};
|
|
uint8_t uidLen = 0;
|
|
bool found = nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLen, rf_timeout);
|
|
if (found && uidLen > 0) {
|
|
nfc.inRelease(1); // deselect → card returns to ISO14443A IDLE state for next poll
|
|
nfc_miss_count = 0;
|
|
String uid_str = "";
|
|
for (uint8_t i = 0; i < uidLen; i++) {
|
|
if (uid[i] < 0x10) uid_str += "0";
|
|
uid_str += String(uid[i], HEX);
|
|
if (i < uidLen - 1) uid_str += ":";
|
|
}
|
|
uid_str.toUpperCase();
|
|
if (strcmp(nfc_access_state, "granted") == 0) {
|
|
// Presence check passed — card still on reader
|
|
Serial.printf("NFC: card present UID=%s\n", uid_str.c_str());
|
|
} else if (strcmp(nfc_access_state, "denied") == 0 && uid_str == nfc_last_uid) {
|
|
// Same card that was already denied is still on reader — do nothing
|
|
Serial.printf("NFC: denied card still present UID=%s\n", uid_str.c_str());
|
|
} else {
|
|
// New card — authenticate
|
|
nfc_last_uid = uid_str;
|
|
Serial.printf("NFC: card UID=%s\n", uid_str.c_str());
|
|
postNFCEvent(uid_str);
|
|
// Require an explicit authorized UID — empty = no card is authorized yet
|
|
if (strlen(nfc_auth_uid) == 0) {
|
|
strcpy(nfc_access_state, "denied");
|
|
Serial.printf("NFC: ACCESS DENIED — no authorized UID configured. Set one in the web UI.\n");
|
|
} else if (uid_str == String(nfc_auth_uid)) {
|
|
strcpy(nfc_access_state, "granted");
|
|
int gpin = nfcRelayPin(nfc_relay_num);
|
|
if (gpin >= 0) {
|
|
digitalWrite(gpin, HIGH);
|
|
switch (nfc_relay_num) {
|
|
case 1: relay1_state = true; break;
|
|
case 2: relay2_state = true; break;
|
|
case 3: relay3_state = true; break;
|
|
case 4: relay4_state = true; break;
|
|
}
|
|
}
|
|
Serial.printf("NFC: ACCESS GRANTED relay=%d (presence-check every %lums)\n",
|
|
nfc_relay_num, nfc_pulse_ms);
|
|
} else {
|
|
strcpy(nfc_access_state, "denied");
|
|
Serial.printf("NFC: ACCESS DENIED UID=%s\n", uid_str.c_str());
|
|
}
|
|
}
|
|
} else {
|
|
// No card this poll
|
|
if (strcmp(nfc_access_state, "granted") == 0) {
|
|
nfc_miss_count++;
|
|
if (nfc_miss_count >= 2) { // 2 consecutive presence-check failures = card gone
|
|
nfc_miss_count = 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 = "";
|
|
Serial.printf("NFC: card removed — relay %d closed\n", nfc_relay_num);
|
|
} else {
|
|
Serial.printf("NFC: presence miss %d/2 — retrying\n", nfc_miss_count);
|
|
}
|
|
} else if (strcmp(nfc_access_state, "denied") == 0) {
|
|
nfc_miss_count++;
|
|
if (nfc_miss_count >= 2) {
|
|
Serial.printf("NFC: denied card removed\n");
|
|
strcpy(nfc_access_state, "idle");
|
|
nfc_last_uid = "";
|
|
nfc_miss_count = 0;
|
|
}
|
|
}
|
|
// idle: keep fast-polling, no action needed
|
|
}
|
|
}
|
|
}
|
|
|
|
delay(10);
|
|
}
|
|
|
|
// ============================================
|
|
// WiFi Debug Function
|
|
// ============================================
|
|
|
|
void scanWiFiNetworks() {
|
|
Serial.println("\n--- Available WiFi Networks ---");
|
|
int n = WiFi.scanNetworks();
|
|
if (n == 0) {
|
|
Serial.println("No networks found!");
|
|
} else {
|
|
Serial.print("Found ");
|
|
Serial.print(n);
|
|
Serial.println(" networks:");
|
|
for (int i = 0; i < n; i++) {
|
|
Serial.print(i + 1);
|
|
Serial.print(". ");
|
|
Serial.print(WiFi.SSID(i));
|
|
Serial.print(" (");
|
|
Serial.print(WiFi.RSSI(i));
|
|
Serial.print(" dBm) ");
|
|
Serial.println(WiFi.encryptionType(i) == WIFI_AUTH_OPEN ? "Open" : "Secured");
|
|
}
|
|
}
|
|
Serial.println("-------------------------------\n");
|
|
}
|
|
|
|
// ============================================
|
|
// API Handlers
|
|
// ============================================
|
|
|
|
void handleRoot() {
|
|
if (!requireAuth()) return;
|
|
|
|
// Read all inputs first to get current state
|
|
input1_state = digitalRead(DIN1_PIN);
|
|
input2_state = digitalRead(DIN2_PIN);
|
|
input3_state = digitalRead(DIN3_PIN);
|
|
input4_state = digitalRead(DIN4_PIN);
|
|
|
|
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>";
|
|
// ── 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}";
|
|
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: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>";
|
|
// 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);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);});}";
|
|
// 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++){";
|
|
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';";
|
|
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 += "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');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;";
|
|
html += "nac.textContent=as==='granted'?'ACCESS GRANTED':as==='denied'?'ACCESS DENIED':'Waiting for card';}";
|
|
html += "var ef=document.getElementById('nfc-auth-field');";
|
|
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='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);});}";
|
|
// 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 += "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!\\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></head>";
|
|
// ── Body ───────────────────────────────────────────────────────────────
|
|
html += "<body>";
|
|
// Topbar
|
|
html += "<div class='topbar'>";
|
|
html += "<h1>🔌 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'>";
|
|
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) + " °C</span>";
|
|
html += "<span><b>Uptime</b> " + String(millis() / 1000) + " s</span>";
|
|
html += "</div></div>";
|
|
|
|
// Inputs + Relays side by side
|
|
html += "<div class='card-row'>";
|
|
// 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];
|
|
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'>IN " + String(i) + "</div>";
|
|
html += "<div class='inp-state' id='is" + String(i) + "'>" + String(pressed ? "PRESSED" : "OPEN") + "</div></div>";
|
|
html += "</div>";
|
|
}
|
|
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) + ")'>Relay " + String(i) + ": " + String(on ? "ON" : "OFF") + "</button>";
|
|
}
|
|
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 += " <span style='color:var(--text2);font-size:14px'>Status: <strong>" + String(led_state ? "ON" : "OFF") + "</strong></span></div>";
|
|
|
|
// 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 — UEXT1)</h2>";
|
|
// 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 ? "✓ Module Enabled" : "✗ 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;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") + ">↓ Use as authorized</button>";
|
|
html += "</div>";
|
|
html += "<div id='nfc-access' class='nfc-state " + ac_class + "'>" + ac_text + "</div>";
|
|
// 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 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()'>✕ Remove</button></td>";
|
|
} else {
|
|
html += "<td colspan='2' style='color:var(--wh-err-c)' id='nfc-auth-display'>None — no card authorized yet</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>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 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:var(--wh-err-c);font-size:13px;margin-top:10px'>✗ PN532 not detected — 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-top:10px'>⚠ No authorized UID — present a card, click “Use as authorized” then Save.</p>";
|
|
}
|
|
html += "</div>"; // close NFC card
|
|
|
|
// HA Webhook
|
|
html += "<div class='card'><h2>Home Assistant Webhook</h2>";
|
|
if (ha_registered && strlen(ha_callback_url) > 0) {
|
|
html += "<div class='wh-ok'>✓ Connected — " + String(ha_callback_url) + "</div>";
|
|
} else {
|
|
html += "<div class='wh-err'>✗ Not registered — waiting for Home Assistant...</div>";
|
|
}
|
|
html += "</div>";
|
|
|
|
// API reference
|
|
html += "<div class='card'><h2>API Endpoints</h2><div class='api-list'>";
|
|
html += "GET /api/status • POST /relay/on?relay=1-4 • POST /relay/off?relay=1-4<br>";
|
|
html += "GET /input/status?input=1-4 • POST /led/on • POST /led/off<br>";
|
|
html += "GET /nfc/status • GET /nfc/config • POST /nfc/config?auth_uid=&relay=&pulse_ms=<br>";
|
|
html += "POST /nfc/enable?state=0|1 • POST /register?callback_url=...";
|
|
html += "</div></div>";
|
|
|
|
html += "</div></body></html>"; // .page
|
|
|
|
server.send(200, "text/html", html);
|
|
}
|
|
|
|
// ── Web UI authentication helper ────────────────────────────────────────
|
|
bool requireAuth() {
|
|
if (!server.authenticate(WEB_USER, WEB_PASSWORD)) {
|
|
server.requestAuthentication(BASIC_AUTH, "ESP32-C6 Control Panel",
|
|
"Login required");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── HMAC-SHA256 API request verification ─────────────────────────────────────
|
|
// Returns true if the request carries a valid X-Request-Sig header (or if
|
|
// API_SECRET is empty, meaning auth is disabled).
|
|
// Message format: "METHOD:path:unix_timestamp" e.g. "GET:/status:1710512345"
|
|
// Timestamp must be within ±60 s of board time (requires NTP sync).
|
|
bool verifyAPIRequest() {
|
|
// ── Option 1: browser UI — valid Digest Auth credentials ─────────────────
|
|
// The browser cannot generate HMAC headers, so we accept Digest Auth as an
|
|
// alternative. requireAuth() is still the sole gate on the HTML pages.
|
|
if (server.authenticate(WEB_USER, WEB_PASSWORD)) return true;
|
|
|
|
// ── Option 2: driver API — valid HMAC-SHA256 signature ───────────────────
|
|
// If no secret configured, allow all requests (backward compat / first boot)
|
|
if (strlen(API_SECRET) == 0) return true;
|
|
|
|
String ts_str = server.header("X-Request-Time");
|
|
String sig_str = server.header("X-Request-Sig");
|
|
|
|
if (ts_str.length() == 0 || sig_str.length() == 0) {
|
|
server.send(401, "application/json", "{\"error\":\"missing auth headers\"}");
|
|
return false;
|
|
}
|
|
|
|
// Replay prevention: only validate timestamp when NTP is synced
|
|
time_t now = time(nullptr);
|
|
if (now > 1000000000L) { // NTP synced (year > 2001)
|
|
long ts = ts_str.toInt();
|
|
if (abs((long)now - ts) > 60) {
|
|
server.send(401, "application/json", "{\"error\":\"timestamp out of window\"}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Build expected message: "METHOD:path:timestamp"
|
|
String method = (server.method() == HTTP_POST) ? "POST" : "GET";
|
|
String msg = method + ":" + server.uri() + ":" + ts_str;
|
|
|
|
// Compute HMAC-SHA256
|
|
uint8_t hmac_out[32];
|
|
mbedtls_md_context_t ctx;
|
|
mbedtls_md_init(&ctx);
|
|
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1);
|
|
mbedtls_md_hmac_starts(&ctx,
|
|
(const unsigned char *)API_SECRET, strlen(API_SECRET));
|
|
mbedtls_md_hmac_update(&ctx,
|
|
(const unsigned char *)msg.c_str(), msg.length());
|
|
mbedtls_md_hmac_finish(&ctx, hmac_out);
|
|
mbedtls_md_free(&ctx);
|
|
|
|
// Hex-encode result
|
|
char expected[65];
|
|
for (int i = 0; i < 32; i++)
|
|
sprintf(expected + i * 2, "%02x", hmac_out[i]);
|
|
expected[64] = '\0';
|
|
|
|
// Constant-time comparison
|
|
if (sig_str.length() != 64) {
|
|
server.send(401, "application/json", "{\"error\":\"invalid signature\"}");
|
|
return false;
|
|
}
|
|
uint8_t diff = 0;
|
|
for (int i = 0; i < 64; i++)
|
|
diff |= (uint8_t)sig_str[i] ^ (uint8_t)expected[i];
|
|
|
|
if (diff != 0) {
|
|
server.send(401, "application/json", "{\"error\":\"invalid signature\"}");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Status request monitoring
|
|
static unsigned long last_status_log = 0;
|
|
static int status_request_count = 0;
|
|
|
|
void handleStatus() {
|
|
if (!verifyAPIRequest()) return;
|
|
status_request_count++;
|
|
|
|
// Read fresh input states
|
|
input1_state = digitalRead(DIN1_PIN);
|
|
input2_state = digitalRead(DIN2_PIN);
|
|
input3_state = digitalRead(DIN3_PIN);
|
|
input4_state = digitalRead(DIN4_PIN);
|
|
|
|
String json = "{";
|
|
json += "\"input1\":" + String(input1_state ? "true" : "false") + ",";
|
|
json += "\"input2\":" + String(input2_state ? "true" : "false") + ",";
|
|
json += "\"input3\":" + String(input3_state ? "true" : "false") + ",";
|
|
json += "\"input4\":" + String(input4_state ? "true" : "false") + ",";
|
|
json += "\"relay1\":" + String(relay1_state ? "true" : "false") + ",";
|
|
json += "\"relay2\":" + String(relay2_state ? "true" : "false") + ",";
|
|
json += "\"relay3\":" + String(relay3_state ? "true" : "false") + ",";
|
|
json += "\"relay4\":" + String(relay4_state ? "true" : "false") + ",";
|
|
json += "\"led\":" + String(led_state ? "true" : "false") + ",";
|
|
bool nfc_present = nfc_initialized && (strcmp(nfc_access_state, "granted") == 0);
|
|
json += "\"nfc_initialized\":" + String(nfc_initialized ? "true" : "false") + ",";
|
|
json += "\"nfc_card_present\":" + String(nfc_present ? "true" : "false") + ",";
|
|
json += "\"nfc_last_uid\":\"" + nfc_last_uid + "\",";
|
|
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_enabled\":" + String(nfc_enabled ? "true" : "false");
|
|
json += "}";
|
|
|
|
server.send(200, "application/json", json);
|
|
|
|
// Log status every 10 seconds
|
|
if (millis() - last_status_log > 10000) {
|
|
last_status_log = millis();
|
|
Serial.printf("API: %d requests/10sec, Free heap: %d bytes, Uptime: %lus\n", status_request_count, ESP.getFreeHeap(), millis() / 1000);
|
|
status_request_count = 0;
|
|
}
|
|
}
|
|
|
|
void handleRelayOn() {
|
|
if (!verifyAPIRequest()) return;
|
|
if (!server.hasArg("relay")) {
|
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
|
return;
|
|
}
|
|
|
|
int relay_num = server.arg("relay").toInt();
|
|
int pin = -1;
|
|
bool *state_ptr = nullptr;
|
|
|
|
switch(relay_num) {
|
|
case 1: pin = RELAY_1_PIN; state_ptr = &relay1_state; break;
|
|
case 2: pin = RELAY_2_PIN; state_ptr = &relay2_state; break;
|
|
case 3: pin = RELAY_3_PIN; state_ptr = &relay3_state; break;
|
|
case 4: pin = RELAY_4_PIN; state_ptr = &relay4_state; break;
|
|
default:
|
|
server.send(400, "application/json", "{\"error\":\"Invalid relay number\"}");
|
|
return;
|
|
}
|
|
|
|
digitalWrite(pin, HIGH);
|
|
*state_ptr = true;
|
|
server.send(200, "application/json", "{\"status\":\"ok\",\"state\":true}");
|
|
Serial.printf("Relay %d ON\n", relay_num);
|
|
}
|
|
|
|
void handleRelayOff() {
|
|
if (!verifyAPIRequest()) return;
|
|
if (!server.hasArg("relay")) {
|
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
|
return;
|
|
}
|
|
|
|
int relay_num = server.arg("relay").toInt();
|
|
int pin = -1;
|
|
bool *state_ptr = nullptr;
|
|
|
|
switch(relay_num) {
|
|
case 1: pin = RELAY_1_PIN; state_ptr = &relay1_state; break;
|
|
case 2: pin = RELAY_2_PIN; state_ptr = &relay2_state; break;
|
|
case 3: pin = RELAY_3_PIN; state_ptr = &relay3_state; break;
|
|
case 4: pin = RELAY_4_PIN; state_ptr = &relay4_state; break;
|
|
default:
|
|
server.send(400, "application/json", "{\"error\":\"Invalid relay number\"}");
|
|
return;
|
|
}
|
|
|
|
digitalWrite(pin, LOW);
|
|
*state_ptr = false;
|
|
server.send(200, "application/json", "{\"status\":\"ok\",\"state\":false}");
|
|
Serial.printf("Relay %d OFF\n", relay_num);
|
|
}
|
|
|
|
void handleRelayStatus() {
|
|
if (!verifyAPIRequest()) return;
|
|
if (!server.hasArg("relay")) {
|
|
server.send(400, "application/json", "{\"error\":\"Missing relay parameter\"}");
|
|
return;
|
|
}
|
|
|
|
int relay_num = server.arg("relay").toInt();
|
|
bool state = false;
|
|
|
|
switch(relay_num) {
|
|
case 1: state = relay1_state; break;
|
|
case 2: state = relay2_state; break;
|
|
case 3: state = relay3_state; break;
|
|
case 4: state = relay4_state; break;
|
|
default:
|
|
server.send(400, "application/json", "{\"error\":\"Invalid relay number\"}");
|
|
return;
|
|
}
|
|
|
|
String json = "{\"state\":" + String(state ? "true" : "false") + "}";
|
|
server.send(200, "application/json", json);
|
|
}
|
|
|
|
void handleInputStatus() {
|
|
if (!verifyAPIRequest()) return;
|
|
if (!server.hasArg("input")) {
|
|
server.send(400, "application/json", "{\"error\":\"Missing input parameter\"}");
|
|
return;
|
|
}
|
|
|
|
int input_num = server.arg("input").toInt();
|
|
int pin = -1;
|
|
|
|
switch(input_num) {
|
|
case 1: pin = DIN1_PIN; break;
|
|
case 2: pin = DIN2_PIN; break;
|
|
case 3: pin = DIN3_PIN; break;
|
|
case 4: pin = DIN4_PIN; break;
|
|
default:
|
|
server.send(400, "application/json", "{\"error\":\"Invalid input number\"}");
|
|
return;
|
|
}
|
|
|
|
int level = digitalRead(pin);
|
|
String json = "{\"state\":" + String(level ? "true" : "false") + "}";
|
|
server.send(200, "application/json", json);
|
|
Serial.printf("Input %d status: %d\n", input_num, level);
|
|
}
|
|
|
|
void handleLEDOn() {
|
|
if (!verifyAPIRequest()) return;
|
|
digitalWrite(LED_PIN, LOW); // LED is active-low
|
|
led_state = true;
|
|
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
|
Serial.println("LED ON");
|
|
}
|
|
|
|
void handleLEDOff() {
|
|
if (!verifyAPIRequest()) return;
|
|
digitalWrite(LED_PIN, HIGH); // LED is active-low
|
|
led_state = false;
|
|
server.send(200, "application/json", "{\"status\":\"ok\"}");
|
|
Serial.println("LED OFF");
|
|
}
|
|
|
|
void handleNotFound() {
|
|
String message = "404: Not Found\n\n";
|
|
message += "URI: " + server.uri() + "\n";
|
|
message += "Method: " + String((server.method() == HTTP_GET) ? "GET" : "POST") + "\n";
|
|
server.send(404, "text/plain", message);
|
|
}
|
|
|
|
// ============================================
|
|
// Home Assistant Integration
|
|
// ============================================
|
|
|
|
void handleRegister() {
|
|
// NOTE: /register is the bootstrap handshake — it intentionally has no HMAC
|
|
// guard because the api_secret cannot be exchanged until after registration.
|
|
// All other API endpoints require verifyAPIRequest().
|
|
if (!server.hasArg("callback_url")) {
|
|
server.send(400, "application/json", "{\"error\":\"Missing callback_url parameter\"}");
|
|
return;
|
|
}
|
|
|
|
String url = server.arg("callback_url");
|
|
if (url.length() > 255) {
|
|
server.send(400, "application/json", "{\"error\":\"URL too long (max 255 chars)\"}");
|
|
return;
|
|
}
|
|
|
|
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\"}");
|
|
}
|
|
|
|
void checkInputChanges() {
|
|
if (!ha_registered) return; // Only check if HA is registered
|
|
|
|
// Read all input states
|
|
bool curr_input1 = digitalRead(DIN1_PIN);
|
|
bool curr_input2 = digitalRead(DIN2_PIN);
|
|
bool curr_input3 = digitalRead(DIN3_PIN);
|
|
bool curr_input4 = digitalRead(DIN4_PIN);
|
|
|
|
// Check for changes and POST events
|
|
if (curr_input1 != last_input1_state) {
|
|
last_input1_state = curr_input1;
|
|
postInputEvent(1, curr_input1);
|
|
}
|
|
if (curr_input2 != last_input2_state) {
|
|
last_input2_state = curr_input2;
|
|
postInputEvent(2, curr_input2);
|
|
}
|
|
if (curr_input3 != last_input3_state) {
|
|
last_input3_state = curr_input3;
|
|
postInputEvent(3, curr_input3);
|
|
}
|
|
if (curr_input4 != last_input4_state) {
|
|
last_input4_state = curr_input4;
|
|
postInputEvent(4, curr_input4);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Shared HTTP POST helper — parses ha_callback_url and POSTs JSON.
|
|
// Uses a 3-second timeout so an unreachable HA server never blocks the loop.
|
|
// ============================================
|
|
|
|
bool postJsonToHA(const String& json) {
|
|
if (!ha_registered || strlen(ha_callback_url) == 0) return false;
|
|
|
|
String url_str = String(ha_callback_url);
|
|
int protocol_end = url_str.indexOf("://");
|
|
if (protocol_end < 0) return false;
|
|
|
|
int host_start = protocol_end + 3;
|
|
int port_sep = url_str.indexOf(":", host_start);
|
|
int path_start = url_str.indexOf("/", host_start);
|
|
if (path_start < 0) path_start = url_str.length();
|
|
if (port_sep < 0 || port_sep > path_start) port_sep = -1;
|
|
|
|
String host = url_str.substring(host_start, (port_sep >= 0) ? port_sep : path_start);
|
|
int port = 80;
|
|
if (port_sep >= 0) {
|
|
int colon_end = url_str.indexOf("/", port_sep);
|
|
if (colon_end < 0) colon_end = url_str.length();
|
|
port = url_str.substring(port_sep + 1, colon_end).toInt();
|
|
}
|
|
String path = url_str.substring(path_start);
|
|
|
|
WiFiClient client;
|
|
client.setTimeout(3000); // 3-second connect/read timeout
|
|
if (!client.connect(host.c_str(), port)) {
|
|
Serial.printf("HA POST: failed to connect to %s:%d\n", host.c_str(), port);
|
|
return false;
|
|
}
|
|
client.println("POST " + path + " HTTP/1.1");
|
|
client.println("Host: " + host);
|
|
client.println("Content-Type: application/json");
|
|
client.println("Content-Length: " + String(json.length()));
|
|
client.println("Connection: close");
|
|
client.println();
|
|
client.print(json);
|
|
|
|
unsigned long deadline = millis() + 3000;
|
|
while (client.connected() && millis() < deadline) {
|
|
if (client.available()) client.read();
|
|
else delay(1);
|
|
}
|
|
client.stop();
|
|
return true;
|
|
}
|
|
|
|
void postInputEvent(int input_num, bool state) {
|
|
if (!ha_registered || strlen(ha_callback_url) == 0) return;
|
|
bool pressed = !state;
|
|
Serial.printf("Input %d event: %s (raw_state=%d) - POSTing to HA\n",
|
|
input_num, pressed ? "input_on" : "input_off", state);
|
|
String json = "{\"input\":" + String(input_num) +
|
|
",\"state\":" + (pressed ? "true" : "false") + "}";
|
|
if (postJsonToHA(json))
|
|
Serial.printf("Input %d event posted successfully\n", input_num);
|
|
}
|
|
|
|
// ============================================
|
|
// 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 && 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) + "\",";
|
|
json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\",";
|
|
json += "\"relay_num\":" + String(nfc_relay_num) + ",";
|
|
json += "\"pulse_ms\":" + String(nfc_pulse_ms);
|
|
json += "}";
|
|
server.send(200, "application/json", json);
|
|
}
|
|
|
|
// ============================================
|
|
// NFC Webhook — POST card event to Home Assistant
|
|
// ============================================
|
|
|
|
void postNFCEvent(const String& uid) {
|
|
if (!ha_registered || strlen(ha_callback_url) == 0) return;
|
|
Serial.printf("NFC: posting UID %s to HA\n", uid.c_str());
|
|
String json = "{\"type\":\"nfc_card\",\"uid\":\"" + uid +
|
|
"\",\"uptime\":" + String(millis() / 1000) + "}";
|
|
if (postJsonToHA(json))
|
|
Serial.printf("NFC: event posted for UID %s\n", uid.c_str());
|
|
}
|
|
|
|
// ============================================
|
|
// NFC Helper: resolve relay number to GPIO pin
|
|
// ============================================
|
|
|
|
int nfcRelayPin(int rnum) {
|
|
switch (rnum) {
|
|
case 1: return RELAY_1_PIN;
|
|
case 2: return RELAY_2_PIN;
|
|
case 3: return RELAY_3_PIN;
|
|
case 4: return RELAY_4_PIN;
|
|
default: return -1;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// NFC Config API GET /nfc/config
|
|
// POST /nfc/config
|
|
// ============================================
|
|
|
|
void handleNFCConfigGet() {
|
|
if (!verifyAPIRequest()) return;
|
|
String json = "{";
|
|
json += "\"auth_uid\":\"" + String(nfc_auth_uid) + "\",";
|
|
json += "\"relay_num\":" + String(nfc_relay_num) + ",";
|
|
json += "\"pulse_ms\":" + String(nfc_pulse_ms) + ",";
|
|
json += "\"nfc_enabled\":" + String(nfc_enabled ? "true" : "false");
|
|
json += "}";
|
|
server.send(200, "application/json", json);
|
|
}
|
|
|
|
void handleNFCConfigSet() {
|
|
if (!verifyAPIRequest()) return;
|
|
if (server.hasArg("auth_uid")) {
|
|
String u = server.arg("auth_uid");
|
|
u.trim();
|
|
u.toUpperCase();
|
|
if (u.length() < sizeof(nfc_auth_uid)) {
|
|
u.toCharArray(nfc_auth_uid, sizeof(nfc_auth_uid));
|
|
} else {
|
|
server.send(400, "application/json", "{\"error\":\"auth_uid too long (max 31 chars)\"}");
|
|
return;
|
|
}
|
|
}
|
|
if (server.hasArg("relay")) {
|
|
int r = server.arg("relay").toInt();
|
|
if (r >= 1 && r <= 4) {
|
|
nfc_relay_num = r;
|
|
} else {
|
|
server.send(400, "application/json", "{\"error\":\"relay must be 1-4\"}");
|
|
return;
|
|
}
|
|
}
|
|
if (server.hasArg("pulse_ms")) {
|
|
long p = server.arg("pulse_ms").toInt();
|
|
if (p >= 100 && p <= 60000) {
|
|
nfc_pulse_ms = (unsigned long)p;
|
|
} else {
|
|
server.send(400, "application/json", "{\"error\":\"pulse_ms range: 100-60000\"}");
|
|
return;
|
|
}
|
|
}
|
|
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) + ","
|
|
"\"pulse_ms\":" + String(nfc_pulse_ms) + "}";
|
|
server.send(200, "application/json", json);
|
|
}
|
|
|
|
// ============================================
|
|
// Debug endpoint GET /debug
|
|
// Returns plain-text system info for diagnosing
|
|
// connectivity without needing a browser.
|
|
// ============================================
|
|
|
|
void handleDebug() {
|
|
if (!requireAuth()) return;
|
|
|
|
String out = "=== ESP32-C6 Debug ===\n";
|
|
out += "Uptime: " + String(millis() / 1000) + " s\n";
|
|
out += "Free heap: " + String(ESP.getFreeHeap()) + " bytes\n";
|
|
out += "WiFi status: ";
|
|
switch (WiFi.status()) {
|
|
case WL_CONNECTED: out += "CONNECTED\n"; break;
|
|
case WL_NO_SSID_AVAIL: out += "NO SSID\n"; break;
|
|
case WL_CONNECT_FAILED: out += "FAILED\n"; break;
|
|
case WL_DISCONNECTED: out += "DISCONNECTED\n"; break;
|
|
default: out += String(WiFi.status()) + "\n";
|
|
}
|
|
out += "IP: " + WiFi.localIP().toString() + "\n";
|
|
out += "SSID: " + WiFi.SSID() + "\n";
|
|
out += "RSSI: " + String(WiFi.RSSI()) + " dBm\n";
|
|
out += "MAC: " + WiFi.macAddress() + "\n";
|
|
out += "\n";
|
|
out += "NFC init: " + String(nfc_initialized ? "YES" : "NO") + "\n";
|
|
out += "NFC last UID:" + String(nfc_last_uid.length() > 0 ? nfc_last_uid : "(none)") + "\n";
|
|
out += "NFC state: " + String(nfc_access_state) + "\n";
|
|
out += "NFC relay: " + String(nfc_relay_num) + "\n";
|
|
out += "NFC auth: " + String(strlen(nfc_auth_uid) > 0 ? nfc_auth_uid : "(any)") + "\n";
|
|
out += "\n";
|
|
out += "Relay states: R1=" + String(relay1_state) + " R2=" + String(relay2_state)
|
|
+ " R3=" + String(relay3_state) + " R4=" + String(relay4_state) + "\n";
|
|
server.send(200, "text/plain", out);
|
|
}
|