687 lines
32 KiB
Arduino
687 lines
32 KiB
Arduino
/*
|
||
* WiFiSense ESP32-S3 — OLED + WiFiManager Edition
|
||
* ─────────────────────────────────────────────────
|
||
* OLED : 0.96" SSD1306 128×64 I2C SDA→GPIO6 SCL→GPIO7
|
||
* LED : GPIO 2
|
||
*
|
||
* Thư viện cần cài qua Library Manager:
|
||
* • Adafruit SSD1306 (by Adafruit)
|
||
* • Adafruit GFX Library (by Adafruit)
|
||
* • Preferences (built-in ESP32 core)
|
||
* • DNSServer (built-in ESP32 core)
|
||
* • WebServer (built-in ESP32 core)
|
||
*
|
||
* Luồng WiFi:
|
||
* 1. Khởi động → đọc credentials từ NVS
|
||
* 2. Thử kết nối (30 s timeout)
|
||
* 3. Nếu thất bại → mở AP "WiFiSense-Setup" (không mật khẩu)
|
||
* OLED hiển thị IP captive portal (192.168.4.1)
|
||
* 4. Người dùng kết nối AP, mở trình duyệt → trang cấu hình:
|
||
* quét danh sách SSID, chọn mạng, nhập mật khẩu, Submit
|
||
* 5. Credentials được lưu NVS → ESP32 restart → kết nối thành công
|
||
* 6. Trong loop(): nếu mất kết nối → tự reconnect, sau 3 lần thất bại
|
||
* mở lại captive portal.
|
||
*/
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Includes
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
#include <WiFi.h>
|
||
#include <WebServer.h>
|
||
#include <DNSServer.h>
|
||
#include <Preferences.h>
|
||
#include <Wire.h>
|
||
#include <Adafruit_GFX.h>
|
||
#include <Adafruit_SSD1306.h>
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Hằng số cấu hình
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
// ─── OLED ────────────────────────────────────────────────────────────────────
|
||
#define OLED_WIDTH 128
|
||
#define OLED_HEIGHT 64
|
||
#define OLED_RESET -1
|
||
#define OLED_ADDRESS 0x3C
|
||
#define OLED_SDA_PIN 6
|
||
#define OLED_SCL_PIN 7
|
||
|
||
// ─── LED ────────────────────────────────────────────────────────────────────
|
||
constexpr int LED_PIN = 2;
|
||
|
||
// ─── WiFiManager AP ──────────────────────────────────────────────────────────
|
||
#define WIFI_MANAGER_SSID "WiFiSense-Setup"
|
||
#define WIFI_MANAGER_PASS "" // để trống = không mật khẩu
|
||
#define DNS_PORT 53
|
||
#define HTTP_PORT 80
|
||
#define AP_IP_STR "192.168.4.1"
|
||
constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 30'000;
|
||
constexpr uint8_t MAX_RECONNECT_ATTEMPTS = 3;
|
||
|
||
// ─── Cảm biến ────────────────────────────────────────────────────────────────
|
||
constexpr size_t WINDOW_SIZE = 40;
|
||
constexpr size_t LONG_WINDOW = 100;
|
||
constexpr uint16_t SAMPLE_INTERVAL_MS = 500;
|
||
constexpr uint16_t CALIBRATION_SAMPLES = 200;
|
||
|
||
constexpr float PROCESS_NOISE = 0.15f;
|
||
constexpr float MEASUREMENT_NOISE = 0.3f;
|
||
constexpr float SMOOTH_ALPHA = 0.15f;
|
||
constexpr float ADAPTIVE_ALPHA = 0.008f;
|
||
constexpr float ADAPTIVE_BETA = 0.005f;
|
||
constexpr float SLOW_MOVEMENT_THRESHOLD = 1.8f;
|
||
constexpr float FAST_MOVEMENT_THRESHOLD = 3.5f;
|
||
constexpr float Z_SCORE_THRESHOLD = 2.5f;
|
||
constexpr float PEAK_THRESHOLD = 2.0f;
|
||
constexpr uint8_t PERSISTENCE_REQUIRED = 3;
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Đối tượng toàn cục
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, OLED_RESET);
|
||
Preferences prefs;
|
||
WebServer webServer(HTTP_PORT);
|
||
DNSServer dnsServer;
|
||
|
||
// ─── Biến sensing ────────────────────────────────────────────────────────────
|
||
float rssiWindow[WINDOW_SIZE];
|
||
float rssiLongWindow[LONG_WINDOW];
|
||
size_t windowIndex = 0;
|
||
size_t longWindowIndex = 0;
|
||
size_t sampleCount = 0;
|
||
|
||
float kalmanEstimate = -55.0f;
|
||
float kalmanError = 1.0f;
|
||
float smoothedRssi = -55.0f;
|
||
float baseline = -55.0f;
|
||
float baselineVariance = 1.0f;
|
||
|
||
uint8_t disturbanceCounter = 0;
|
||
uint32_t totalDetections = 0;
|
||
|
||
// ─── Biến WiFiManager ────────────────────────────────────────────────────────
|
||
bool apMode = false;
|
||
uint8_t reconnectAttempts = 0;
|
||
String savedSsid;
|
||
String savedPass;
|
||
|
||
// ─── Log OLED cuộn ───────────────────────────────────────────────────────────
|
||
#define LOG_LINES 8
|
||
char logBuf[LOG_LINES][22];
|
||
uint8_t logHead = 0;
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Tiện ích chung
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
float clampValue(float v, float lo, float hi) {
|
||
return v < lo ? lo : (v > hi ? hi : v);
|
||
}
|
||
size_t latestWindowIndex(size_t offset) {
|
||
return (windowIndex + WINDOW_SIZE - offset) % WINDOW_SIZE;
|
||
}
|
||
void setDetectionLed(bool on) {
|
||
digitalWrite(LED_PIN, on ? HIGH : LOW);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// OLED helpers
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
// ── Log cuộn (dùng khi connect / calibrate / AP mode) ────────────────────────
|
||
void logPush(const char* line) {
|
||
strncpy(logBuf[logHead], line, 21);
|
||
logBuf[logHead][21] = '\0';
|
||
logHead = (logHead + 1) % LOG_LINES;
|
||
}
|
||
void logRender() {
|
||
display.clearDisplay();
|
||
display.setTextSize(1);
|
||
display.setTextColor(WHITE);
|
||
for (int i = 0; i < LOG_LINES; ++i) {
|
||
display.setCursor(0, i * 8);
|
||
display.print(logBuf[(logHead + i) % LOG_LINES]);
|
||
}
|
||
display.display();
|
||
}
|
||
|
||
// ── Splash ────────────────────────────────────────────────────────────────────
|
||
void oledSplash() {
|
||
display.clearDisplay();
|
||
display.setTextSize(1);
|
||
display.setTextColor(WHITE);
|
||
display.setCursor(16, 8); display.print("WiFiSense v3");
|
||
display.setCursor(10, 20); display.print("ESP32-S3 + OLED");
|
||
display.setCursor(22, 32); display.print("WiFiManager");
|
||
display.setCursor(22, 44); display.print("orangepi.vn");
|
||
display.display();
|
||
delay(1500);
|
||
}
|
||
|
||
// ── Màn hình AP (Captive Portal) ─────────────────────────────────────────────
|
||
/*
|
||
* ┌──────────────────────────────┐
|
||
* │ ** SETUP MODE ** │
|
||
* │ Connect to WiFi: │
|
||
* │ WiFiSense-Setup │
|
||
* │ Open browser: │
|
||
* │ 192.168.4.1 │
|
||
* └──────────────────────────────┘
|
||
*/
|
||
void oledShowApMode() {
|
||
display.clearDisplay();
|
||
display.setTextSize(1);
|
||
display.setTextColor(WHITE);
|
||
|
||
display.setCursor(14, 0); display.print("** SETUP MODE **");
|
||
|
||
display.drawFastHLine(0, 10, 128, WHITE);
|
||
|
||
display.setCursor(0, 14); display.print("1. Ket noi WiFi:");
|
||
display.setCursor(4, 24); display.print(WIFI_MANAGER_SSID);
|
||
|
||
display.setCursor(0, 36); display.print("2. Mo trinh duyet:");
|
||
display.setCursor(4, 46); display.print(AP_IP_STR);
|
||
|
||
// Nhấp nháy dấu chấm cuối để cho thấy đang chờ
|
||
static bool blink = false;
|
||
blink = !blink;
|
||
if (blink) {
|
||
display.setCursor(120, 56);
|
||
display.print(".");
|
||
}
|
||
display.display();
|
||
}
|
||
|
||
// ── Thanh ngang ──────────────────────────────────────────────────────────────
|
||
void drawBar(int x, int y, int maxW, int h, int val) {
|
||
display.drawRect(x, y, maxW, h, WHITE);
|
||
int fill = (int)((float)val / 100.0f * (float)(maxW - 2));
|
||
if (fill > 0) display.fillRect(x + 1, y + 1, fill, h - 2, WHITE);
|
||
}
|
||
|
||
// ── Trang chính (duy nhất) ────────────────────────────────────────────────────
|
||
/*
|
||
* ┌──────────────────────────────┐ y=0
|
||
* │ WiFiSense [ MOTION ] │ tiêu đề + badge
|
||
* ├──────────────────────────────┤ y=9
|
||
* │ RSSI: -67 dBm Conf: 82% │ y=11
|
||
* │ Conf [████████████░░░░░░░░] │ y=22
|
||
* │ Qual [█████████░░░░░░░░░░░] │ y=32
|
||
* ├──────────────────────────────┤
|
||
* │ Base:-66.1 Var:2.3 │ y=43
|
||
* │ Detect:#15 WALKING │ y=54
|
||
* └──────────────────────────────┘
|
||
*/
|
||
void drawPageMain(int rawRssi, int confidence, int quality,
|
||
float variance, float rateOfChange, bool detected) {
|
||
display.clearDisplay();
|
||
display.setTextSize(1);
|
||
display.setTextColor(WHITE);
|
||
|
||
// ── Dòng 1: tiêu đề + badge trạng thái ──────────────────────────────────
|
||
display.setCursor(0, 0);
|
||
display.print("WiFiSense");
|
||
|
||
const char* badge = detected ? " MOTION " : " CLEAR ";
|
||
int badgeLen = (int)strlen(badge);
|
||
int bx = 128 - badgeLen * 6;
|
||
if (detected) {
|
||
display.fillRect(bx - 1, 0, 128 - bx + 1, 8, WHITE);
|
||
display.setTextColor(BLACK);
|
||
}
|
||
display.setCursor(bx, 0);
|
||
display.print(badge);
|
||
display.setTextColor(WHITE);
|
||
|
||
// ── Đường kẻ ngang ───────────────────────────────────────────────────────
|
||
display.drawFastHLine(0, 9, 128, WHITE);
|
||
|
||
// ── Dòng 2: RSSI + Confidence số ─────────────────────────────────────────
|
||
display.setCursor(0, 11);
|
||
display.printf("RSSI:%4ddBm", rawRssi);
|
||
display.setCursor(78, 11);
|
||
display.printf("C:%3d%%", confidence);
|
||
|
||
// ── Thanh Confidence ─────────────────────────────────────────────────────
|
||
display.setCursor(0, 22);
|
||
display.print("Conf");
|
||
drawBar(26, 22, 102, 7, confidence);
|
||
|
||
// ── Thanh Quality ────────────────────────────────────────────────────────
|
||
display.setCursor(0, 32);
|
||
display.print("Qual");
|
||
drawBar(26, 32, 102, 7, quality);
|
||
|
||
// ── Đường kẻ ngang ───────────────────────────────────────────────────────
|
||
display.drawFastHLine(0, 41, 128, WHITE);
|
||
|
||
// ── Dòng 4: Baseline + Variance ──────────────────────────────────────────
|
||
display.setCursor(0, 43);
|
||
display.printf("Base:%.1f Var:%.1f", baseline, variance);
|
||
|
||
// ── Dòng 5: Số lần phát hiện + cường độ chuyển động ─────────────────────
|
||
display.setCursor(0, 54);
|
||
display.printf("#%lu", (unsigned long)totalDetections);
|
||
|
||
// Nhãn cường độ bên phải
|
||
const char* intensity = "CALM";
|
||
if (variance > FAST_MOVEMENT_THRESHOLD && rateOfChange > 2.5f) intensity = "SPRINT";
|
||
else if (variance > FAST_MOVEMENT_THRESHOLD) intensity = "FAST";
|
||
else if (variance > SLOW_MOVEMENT_THRESHOLD && rateOfChange > 1.0f) intensity = "WALKING";
|
||
else if (variance > SLOW_MOVEMENT_THRESHOLD) intensity = "SLOW";
|
||
|
||
int lx = 128 - (int)strlen(intensity) * 6;
|
||
display.setCursor(lx, 54);
|
||
display.print(intensity);
|
||
|
||
display.display();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Thuật toán WiFi sensing
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
float kalmanFilter(float m) {
|
||
const float pe = kalmanError + PROCESS_NOISE;
|
||
const float gain = pe / (pe + MEASUREMENT_NOISE);
|
||
kalmanEstimate += gain * (m - kalmanEstimate);
|
||
kalmanError = (1.0f - gain) * pe;
|
||
return kalmanEstimate;
|
||
}
|
||
float exponentialSmoothing(float m) {
|
||
smoothedRssi = SMOOTH_ALPHA * m + (1.0f - SMOOTH_ALPHA) * smoothedRssi;
|
||
return smoothedRssi;
|
||
}
|
||
float standardDeviation(const float* v, size_t n) {
|
||
if (n == 0) return 0.0f;
|
||
float mean = 0.0f;
|
||
for (size_t i = 0; i < n; ++i) mean += v[i];
|
||
mean /= (float)n;
|
||
float var = 0.0f;
|
||
for (size_t i = 0; i < n; ++i) { float d = v[i] - mean; var += d * d; }
|
||
return sqrtf(var / (float)n);
|
||
}
|
||
float analyzeWindowVariance() { return standardDeviation(rssiWindow, WINDOW_SIZE); }
|
||
float analyzeLongTermVariance() { return standardDeviation(rssiLongWindow, LONG_WINDOW); }
|
||
|
||
float detectRateOfChange() {
|
||
if (sampleCount < 10) return 0.0f;
|
||
float r = 0.0f, o = 0.0f;
|
||
for (size_t i = 1; i <= 5; ++i) {
|
||
r += rssiWindow[latestWindowIndex(i)];
|
||
o += rssiWindow[latestWindowIndex(i + 5)];
|
||
}
|
||
return fabsf((r - o) / 5.0f);
|
||
}
|
||
float detectPeak() {
|
||
if (sampleCount < 3) return 0.0f;
|
||
float cur = rssiWindow[latestWindowIndex(1)];
|
||
float prv = rssiWindow[latestWindowIndex(2)];
|
||
float old = rssiWindow[latestWindowIndex(3)];
|
||
float cd = fabsf(cur - prv), pd = fabsf(prv - old);
|
||
return cd > pd + 1.0f ? cd : 0.0f;
|
||
}
|
||
float calculateZScore() {
|
||
if (sampleCount < WINDOW_SIZE) return 0.0f;
|
||
float mean = 0.0f;
|
||
for (size_t i = 0; i < WINDOW_SIZE; ++i) mean += rssiWindow[i];
|
||
mean /= (float)WINDOW_SIZE;
|
||
float sd = fmaxf(analyzeWindowVariance(), 0.1f);
|
||
return fabsf((rssiWindow[latestWindowIndex(1)] - mean) / sd);
|
||
}
|
||
int calculateConfidence(float var, float roc, float peak, float z) {
|
||
float c = var > FAST_MOVEMENT_THRESHOLD ? 75.0f
|
||
: var > SLOW_MOVEMENT_THRESHOLD ? 50.0f : 15.0f;
|
||
if (roc > 2.0f) c += 15.0f;
|
||
if (peak > PEAK_THRESHOLD) c += 10.0f;
|
||
if (z > Z_SCORE_THRESHOLD) c += 10.0f;
|
||
return (int)clampValue(c, 0.0f, 100.0f);
|
||
}
|
||
void initializeRssiWindows(float v) {
|
||
for (int i = 0; i < (int)WINDOW_SIZE; ++i) rssiWindow[i] = v;
|
||
for (int i = 0; i < (int)LONG_WINDOW; ++i) rssiLongWindow[i] = v;
|
||
windowIndex = longWindowIndex = 0;
|
||
sampleCount = WINDOW_SIZE;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// NVS helpers
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
void loadCredentials() {
|
||
prefs.begin("wifisense", true); // read-only
|
||
savedSsid = prefs.getString("ssid", "");
|
||
savedPass = prefs.getString("pass", "");
|
||
prefs.end();
|
||
Serial.printf("[NVS] ssid='%s'\n", savedSsid.c_str());
|
||
}
|
||
void saveCredentials(const String& ssid, const String& pass) {
|
||
prefs.begin("wifisense", false);
|
||
prefs.putString("ssid", ssid);
|
||
prefs.putString("pass", pass);
|
||
prefs.end();
|
||
Serial.printf("[NVS] Saved ssid='%s'\n", ssid.c_str());
|
||
}
|
||
void clearCredentials() {
|
||
prefs.begin("wifisense", false);
|
||
prefs.clear();
|
||
prefs.end();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Captive Portal (WiFiManager tự cài)
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
// ── HTML trang cấu hình ──────────────────────────────────────────────────────
|
||
// Được sinh động: quét SSID rồi điền vào <select>
|
||
static String buildHtml(int networkCount) {
|
||
String html = R"rawhtml(<!DOCTYPE html>
|
||
<html lang='vi'>
|
||
<head>
|
||
<meta charset='UTF-8'>
|
||
<meta name='viewport' content='width=device-width,initial-scale=1'>
|
||
<title>WiFiSense Setup</title>
|
||
<style>
|
||
body{font-family:sans-serif;background:#111;color:#eee;display:flex;
|
||
justify-content:center;align-items:center;min-height:100vh;margin:0}
|
||
.card{background:#1e1e1e;border:1px solid #333;border-radius:12px;
|
||
padding:28px 24px;width:320px;box-shadow:0 4px 24px #0008}
|
||
h2{margin:0 0 6px;font-size:1.3rem;color:#4fc3f7}
|
||
p{margin:0 0 20px;font-size:.85rem;color:#888}
|
||
label{font-size:.85rem;color:#aaa;display:block;margin-bottom:4px}
|
||
select,input{width:100%;box-sizing:border-box;padding:9px 10px;
|
||
border-radius:6px;border:1px solid #444;background:#2a2a2a;
|
||
color:#eee;font-size:.95rem;margin-bottom:16px}
|
||
button{width:100%;padding:11px;border:none;border-radius:6px;
|
||
background:#4fc3f7;color:#111;font-size:1rem;font-weight:700;cursor:pointer}
|
||
button:active{background:#0288d1}
|
||
.note{margin-top:14px;font-size:.75rem;color:#555;text-align:center}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class='card'>
|
||
<h2>WiFiSense Setup</h2>
|
||
<p>Chọn mạng WiFi và nhập mật khẩu</p>
|
||
<form method='POST' action='/save'>
|
||
<label>Mạng WiFi</label>
|
||
<select name='ssid'>
|
||
)rawhtml";
|
||
|
||
for (int i = 0; i < networkCount; ++i) {
|
||
int rssi = WiFi.RSSI(i);
|
||
String bar = rssi > -60 ? "▊▊▊" : rssi > -75 ? "▊▊░" : "▊░░";
|
||
html += "<option value='" + WiFi.SSID(i) + "'>" +
|
||
WiFi.SSID(i) + " " + bar + " (" + String(rssi) + " dBm)</option>\n";
|
||
}
|
||
|
||
html += R"rawhtml(
|
||
</select>
|
||
<label>Mật khẩu</label>
|
||
<input type='password' name='pass' placeholder='(để trống nếu mạng mở)'>
|
||
<button type='submit'>Kết nối & Lưu</button>
|
||
</form>
|
||
<p class='note'>ESP32 sẽ khởi động lại sau khi lưu</p>
|
||
</div>
|
||
</body>
|
||
</html>)rawhtml";
|
||
return html;
|
||
}
|
||
|
||
static String buildSavedHtml(const String& ssid) {
|
||
return String("<!DOCTYPE html><html lang='vi'><head><meta charset='UTF-8'>"
|
||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||
"<title>Đã lưu</title>"
|
||
"<style>body{font-family:sans-serif;background:#111;color:#eee;"
|
||
"display:flex;justify-content:center;align-items:center;min-height:100vh}"
|
||
".card{background:#1e1e1e;border:1px solid #333;border-radius:12px;"
|
||
"padding:28px 24px;width:300px;text-align:center}"
|
||
"h2{color:#81c784}p{color:#888;font-size:.9rem}</style></head>"
|
||
"<body><div class='card'>"
|
||
"<h2>✓ Đã lưu!</h2>"
|
||
"<p>Đang kết nối tới <b>" + ssid + "</b></p>"
|
||
"<p>ESP32 sẽ khởi động lại trong vài giây...</p>"
|
||
"</div></body></html>");
|
||
}
|
||
|
||
// ── Khởi động AP + DNS + WebServer ───────────────────────────────────────────
|
||
void startCaptivePortal() {
|
||
apMode = true;
|
||
WiFi.mode(WIFI_AP);
|
||
WiFi.softAP(WIFI_MANAGER_SSID, WIFI_MANAGER_PASS[0] ? WIFI_MANAGER_PASS : nullptr);
|
||
|
||
IPAddress apIP(192, 168, 4, 1);
|
||
// DNS: mọi domain trỏ về AP IP → trình duyệt tự mở captive portal
|
||
dnsServer.start(DNS_PORT, "*", apIP);
|
||
|
||
// Quét WiFi trước khi khởi webserver
|
||
logPush("Scanning WiFi...");
|
||
logRender();
|
||
int n = WiFi.scanNetworks();
|
||
char tmp[22];
|
||
snprintf(tmp, sizeof(tmp), "Found %d networks", n);
|
||
logPush(tmp);
|
||
logPush("AP: " WIFI_MANAGER_SSID);
|
||
logPush("IP: " AP_IP_STR);
|
||
logRender();
|
||
|
||
// Route: GET / → trang chọn WiFi
|
||
webServer.on("/", HTTP_GET, [n]() {
|
||
webServer.send(200, "text/html", buildHtml(n));
|
||
});
|
||
|
||
// Route: POST /save → lưu NVS, restart
|
||
webServer.on("/save", HTTP_POST, []() {
|
||
String newSsid = webServer.arg("ssid");
|
||
String newPass = webServer.arg("pass");
|
||
if (newSsid.isEmpty()) {
|
||
webServer.send(400, "text/plain", "SSID khong duoc de trong");
|
||
return;
|
||
}
|
||
saveCredentials(newSsid, newPass);
|
||
webServer.send(200, "text/html", buildSavedHtml(newSsid));
|
||
logPush("Saved! Restarting...");
|
||
logRender();
|
||
delay(2000);
|
||
ESP.restart();
|
||
});
|
||
|
||
// Captive portal redirect: mọi URL khác → trang chính
|
||
webServer.onNotFound([]() {
|
||
webServer.sendHeader("Location", "http://" AP_IP_STR, true);
|
||
webServer.send(302, "text/plain", "");
|
||
});
|
||
|
||
webServer.begin();
|
||
Serial.printf("[AP] SSID=%s IP=%s\n", WIFI_MANAGER_SSID, AP_IP_STR);
|
||
}
|
||
|
||
// ── Dừng AP ───────────────────────────────────────────────────────────────────
|
||
void stopCaptivePortal() {
|
||
webServer.stop();
|
||
dnsServer.stop();
|
||
WiFi.softAPdisconnect(true);
|
||
apMode = false;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Kết nối WiFi station
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
bool connectToWifi(const String& ssid, const String& pass) {
|
||
if (ssid.isEmpty()) return false;
|
||
|
||
WiFi.mode(WIFI_STA);
|
||
WiFi.begin(ssid.c_str(), pass.isEmpty() ? nullptr : pass.c_str());
|
||
|
||
char tmp[22];
|
||
snprintf(tmp, sizeof(tmp), "Connecting...");
|
||
logPush(tmp); logRender();
|
||
Serial.printf("[WiFi] Connecting to '%s'\n", ssid.c_str());
|
||
|
||
const uint32_t t0 = millis();
|
||
while (WiFi.status() != WL_CONNECTED) {
|
||
if (millis() - t0 >= WIFI_CONNECT_TIMEOUT_MS) {
|
||
logPush("Timeout!");
|
||
logRender();
|
||
Serial.println("[WiFi] Timeout.");
|
||
return false;
|
||
}
|
||
delay(500);
|
||
if ((millis() - t0) % 5000 < 500) {
|
||
snprintf(tmp, sizeof(tmp), "Wait %lus", (millis() - t0) / 1000UL);
|
||
logPush(tmp); logRender();
|
||
}
|
||
}
|
||
|
||
snprintf(tmp, sizeof(tmp), "IP:%s", WiFi.localIP().toString().c_str());
|
||
logPush(tmp);
|
||
snprintf(tmp, sizeof(tmp), "RSSI:%d dBm", WiFi.RSSI());
|
||
logPush(tmp); logRender();
|
||
Serial.printf("[WiFi] OK. IP=%s\n", WiFi.localIP().toString().c_str());
|
||
delay(600);
|
||
return true;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// Calibrate
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
void calibrate() {
|
||
char tmp[22];
|
||
snprintf(tmp, sizeof(tmp), "Cal %u samples", CALIBRATION_SAMPLES);
|
||
logPush(tmp);
|
||
logPush("Keep room EMPTY!");
|
||
logRender();
|
||
|
||
float mean = 0.0f;
|
||
for (uint16_t i = 0; i < CALIBRATION_SAMPLES; ++i) {
|
||
mean += (float)WiFi.RSSI();
|
||
delay(30);
|
||
if (i % 40 == 0) {
|
||
snprintf(tmp, sizeof(tmp), "Cal %d/%d", i, CALIBRATION_SAMPLES);
|
||
logPush(tmp); logRender();
|
||
}
|
||
}
|
||
baseline = mean / (float)CALIBRATION_SAMPLES;
|
||
kalmanEstimate = baseline;
|
||
smoothedRssi = baseline;
|
||
initializeRssiWindows(baseline);
|
||
|
||
snprintf(tmp, sizeof(tmp), "Base:%.2f dBm", baseline);
|
||
logPush(tmp);
|
||
logPush("Done! Starting...");
|
||
logRender();
|
||
delay(1000);
|
||
Serial.printf("[Cal] Baseline=%.2f dBm\n", baseline);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// setup()
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
void setup() {
|
||
Serial.begin(115200);
|
||
pinMode(LED_PIN, OUTPUT);
|
||
setDetectionLed(false);
|
||
|
||
// OLED
|
||
Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
|
||
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
|
||
Serial.println("[OLED] Init failed — check wiring!");
|
||
}
|
||
display.cp437(true);
|
||
display.setTextWrap(false);
|
||
oledSplash();
|
||
|
||
Serial.println("\n===== WiFiSense ESP32-S3 v3 =====");
|
||
|
||
// Đọc credentials từ NVS
|
||
loadCredentials();
|
||
|
||
// Thử kết nối với credentials đã lưu
|
||
bool connected = connectToWifi(savedSsid, savedPass);
|
||
|
||
if (!connected) {
|
||
// Mở captive portal
|
||
startCaptivePortal();
|
||
// Loop trong AP mode — chờ người dùng cấu hình
|
||
while (apMode) {
|
||
dnsServer.processNextRequest();
|
||
webServer.handleClient();
|
||
oledShowApMode();
|
||
delay(300);
|
||
}
|
||
// Sau khi /save gọi ESP.restart() → không bao giờ thoát vòng while ở đây
|
||
}
|
||
|
||
stopCaptivePortal(); // đảm bảo AP tắt nếu không cần
|
||
calibrate();
|
||
Serial.println("[Main] Sensing started.");
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// loop()
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
void loop() {
|
||
// ── Reconnect nếu mất kết nối ──────────────────────────────────────────
|
||
if (WiFi.status() != WL_CONNECTED) {
|
||
setDetectionLed(false);
|
||
reconnectAttempts++;
|
||
logPush("WiFi lost!");
|
||
char tmp[22];
|
||
snprintf(tmp, sizeof(tmp), "Retry %d/%d", reconnectAttempts, MAX_RECONNECT_ATTEMPTS);
|
||
logPush(tmp); logRender();
|
||
|
||
bool ok = connectToWifi(savedSsid, savedPass);
|
||
if (ok) {
|
||
reconnectAttempts = 0;
|
||
calibrate(); // tái calibrate sau khi reconnect
|
||
} else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||
// Thất bại nhiều lần → mở portal để người dùng đổi mạng
|
||
logPush("Open portal...");
|
||
logRender();
|
||
delay(1000);
|
||
clearCredentials();
|
||
ESP.restart(); // restart sẽ vào setup() → portal
|
||
}
|
||
return;
|
||
}
|
||
|
||
reconnectAttempts = 0;
|
||
|
||
// ── Lấy mẫu ─────────────────────────────────────────────────────────────
|
||
const int rawRssi = WiFi.RSSI();
|
||
const float kalmanFiltered = kalmanFilter((float)rawRssi);
|
||
const float smoothFiltered = exponentialSmoothing(kalmanFiltered);
|
||
|
||
rssiWindow[windowIndex] = smoothFiltered;
|
||
rssiLongWindow[longWindowIndex] = smoothFiltered;
|
||
windowIndex = (windowIndex + 1) % WINDOW_SIZE;
|
||
longWindowIndex = (longWindowIndex + 1) % LONG_WINDOW;
|
||
++sampleCount;
|
||
|
||
// ── Tính toán ────────────────────────────────────────────────────────────
|
||
const float variance = analyzeWindowVariance();
|
||
const float longVariance = analyzeLongTermVariance();
|
||
const float rateOfChange = detectRateOfChange();
|
||
const float peak = detectPeak();
|
||
const float zScore = calculateZScore();
|
||
const int confidence = calculateConfidence(variance, rateOfChange, peak, zScore);
|
||
const float signalQuality = clampValue(100.0f - longVariance * 20.0f, 0.0f, 100.0f);
|
||
|
||
baseline = baseline * (1.0f - ADAPTIVE_ALPHA) + smoothFiltered * ADAPTIVE_ALPHA;
|
||
baselineVariance = baselineVariance * (1.0f - ADAPTIVE_BETA) + variance * ADAPTIVE_BETA;
|
||
|
||
const bool motion = variance > SLOW_MOVEMENT_THRESHOLD ||
|
||
(zScore > Z_SCORE_THRESHOLD && peak > PEAK_THRESHOLD);
|
||
disturbanceCounter = motion ? disturbanceCounter + 1 : 0;
|
||
const bool detected = disturbanceCounter >= PERSISTENCE_REQUIRED;
|
||
if (detected && disturbanceCounter == PERSISTENCE_REQUIRED) ++totalDetections;
|
||
setDetectionLed(detected);
|
||
|
||
// ── Hiển thị ─────────────────────────────────────────────────────────────
|
||
drawPageMain(rawRssi, confidence, (int)signalQuality, variance, rateOfChange, detected);
|
||
|
||
// ── Serial debug ─────────────────────────────────────────────────────────
|
||
Serial.printf("Raw:%d K:%.2f S:%.2f Base:%.2f Var:%.2f Z:%.2f Conf:%d Det:%lu\n",
|
||
rawRssi, kalmanFiltered, smoothFiltered, baseline, variance, zScore,
|
||
confidence, (unsigned long)totalDetections);
|
||
|
||
delay(SAMPLE_INTERVAL_MS);
|
||
}
|