// ========================================
// ASEP-JAGA Prototype Simulation
// ESP32-S3 / PlatformIO / Wokwi
// ========================================
// Forward declaration for Arduino/Wokwi auto-generated prototypes.
// This must appear before the includes so `fell(DebouncedButton&)`
// can be parsed even when the IDE generates function prototypes.
struct DebouncedButton;
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
// ===== OUTPUTS =====
#define LED_READY 4
#define LED_ALERT 2
#define LED_STROBE 6
#define LED_NETWORK 7
#define BUZZER 18
// ===== INPUTS =====
#define SOS_BUTTON 17
#define AUDIO_BUTTON 16
#define TAMPER_BUTTON 10
#define CAMERA_BUTTON 11
#define NETWORK_BUTTON 12
#define TALK_BUTTON 13
#define SELF_TEST_BUTTON 14
// ===== NODE CONFIG =====
const char *NODE_ID = "PJU001";
const char *LOCATION = "Jl. Asia Afrika - Bandung";
const unsigned long FUSION_WINDOW = 12000; // jendela verifikasi longgar (12s) agar realistis ditekan manual
const unsigned long HEARTBEAT_INTERVAL = 8000;
const unsigned long DEBOUNCE_MS = 35;
// ===== SERVER / NGROK INTEGRATION =====
// device_id yang dikenal server (lihat seed_devices di server.py)
#define API_DEVICE_ID "ASEP-012"
// Ganti dengan URL ngrok aktual (HTTPS). JANGAN pakai 127.0.0.1/localhost/IP LAN.
String SERVER_BASE = "https://5070-2400-9800-14-5a05-79e4-4a2e-1ba8-aea2.ngrok-free.app";
const char *WIFI_SSID = "Wokwi-GUEST";
const char *WIFI_PASS = "";
const int WIFI_CHANNEL = 6;
const float AUDIO_THRESHOLD = 0.70;
bool serverCaseActive = false;
bool serverConfirmed = false;
bool localEmergencyActive = false;
unsigned long lastServerPoll = 0;
const unsigned long SERVER_POLL_INTERVAL = 3000;
// ===== HTTP TIMEOUT =====
const uint16_t HTTP_CONNECT_TIMEOUT_MS = 5000; // timeout koneksi 5s
const uint16_t HTTP_RESPONSE_TIMEOUT_MS = 8000; // timeout response 8s
// ===== TRANSPORT FALLBACK =====
// Default HTTPS (WiFiClientSecure + setInsecure). Jika TLS handshake gagal
// (sering terjadi di simulator Wokwi), otomatis fallback ke HTTP pada host yang
// sama - ngrok melayani http maupun https. Setelah satu transport berhasil,
// transport itu dipakai terus agar tidak menambah latensi.
bool useHttpsTransport = true;
bool transportLocked = false;
// Kembalikan base URL sesuai transport aktif.
String activeBase() {
String base = SERVER_BASE;
if (!useHttpsTransport && base.startsWith("https://")) {
base = "http://" + base.substring(8);
}
return base;
}
// ===== SIRENE BEEP (non-blocking, bip-bip-bip) =====
bool sirenActive = false; // true saat sirene harus berbunyi
bool sirenBeepOn = false; // state internal on/off
unsigned long sirenLastToggle = 0;
const unsigned long SIREN_BEEP_ON_MS = 150; // durasi bunyi
const unsigned long SIREN_BEEP_OFF_MS = 250; // durasi jeda
const unsigned int SIREN_FREQ = 2200;
// Forward declarations (fungsi network didefinisikan sebelum setup()).
void sendEmergencyEvaluate(float audioConfidence);
void pollServerCommand();
void clearServerCommand();
void connectWiFi();
bool wifiConnected();
void activateLocalEmergency();
void serverHealthCheck();
void forceEmergencyTest();
// ===== SENSOR FUSION STATE =====
float audioScore = 0.0;
float vibrationScore = 0.0;
float visualConfidence = 0.0;
bool audioDetected = false;
bool vibrationPatternDetected = false;
unsigned long audioTime = 0;
unsigned long vibrationTime = 0;
// ===== HEALTH / COMMAND CENTER STATE =====
bool networkOnline = true;
bool cameraOnline = true;
bool latestCaseActive = false;
bool twoWayAudioOpen = false;
unsigned long latestCaseId = 0;
unsigned long queuedAlerts = 0;
unsigned long lastHeartbeat = 0;
// ===== VIBRATION PATTERN STATE =====
int knockCount = 0;
unsigned long firstKnockTime = 0;
unsigned long lastKnockTime = 0;
struct DebouncedButton {
uint8_t pin;
bool stable;
bool lastReading;
unsigned long lastChange;
};
// Explicit prototype for portability between .ino and .cpp builds.
bool fell(DebouncedButton &button);
DebouncedButton btnSos = { SOS_BUTTON, HIGH, HIGH, 0 };
DebouncedButton btnAudio = { AUDIO_BUTTON, HIGH, HIGH, 0 };
DebouncedButton btnTamper = { TAMPER_BUTTON, HIGH, HIGH, 0 };
DebouncedButton btnCamera = { CAMERA_BUTTON, HIGH, HIGH, 0 };
DebouncedButton btnNetwork = { NETWORK_BUTTON, HIGH, HIGH, 0 };
DebouncedButton btnTalk = { TALK_BUTTON, HIGH, HIGH, 0 };
DebouncedButton btnSelfTest = { SELF_TEST_BUTTON, HIGH, HIGH, 0 };
struct PatrolUnit {
const char *name;
float distanceKm;
int etaMinutes;
bool available;
};
PatrolUnit patrols[] = {
{ "Patrol A", 1.2, 3, true },
{ "Patrol B", 0.7, 2, false },
{ "Patrol C", 2.1, 5, true }
};
float randomFloat(float minVal, float maxVal) {
return minVal + ((float)random(0, 1000) / 1000.0) * (maxVal - minVal);
}
bool fell(DebouncedButton &button) {
bool reading = digitalRead(button.pin);
if (reading != button.lastReading) {
button.lastChange = millis();
button.lastReading = reading;
}
if ((millis() - button.lastChange) > DEBOUNCE_MS && reading != button.stable) {
button.stable = reading;
return button.stable == LOW;
}
return false;
}
void tampil(const String &line1, const String &line2, const String &line3 = "", const String &line4 = "") {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("ASEP-JAGA");
display.println(NODE_ID);
display.println();
display.println(line1);
display.println(line2);
if (line3.length() > 0) display.println(line3);
if (line4.length() > 0) display.println(line4);
display.display();
}
String ecsStatus(float ecs) {
if (ecs >= 0.85) return "EMERGENCY";
if (ecs >= 0.70) return "WARNING";
if (ecs >= 0.40) return "SUSPICIOUS";
return "NORMAL";
}
void publishCommandCenter(const String &topic, const String &payload, bool highPriority = false) {
if (!networkOnline) {
queuedAlerts++;
Serial.print("[NETWORK] OFFLINE - queued ");
Serial.print(topic);
Serial.print(" (#");
Serial.print(queuedAlerts);
Serial.println(")");
return;
}
Serial.print("[COMMAND_CENTER]");
if (highPriority) Serial.print("[PRIORITY]");
Serial.print(" ");
Serial.print(topic);
Serial.print(" -> ");
Serial.println(payload);
}
void flushQueuedAlerts() {
if (networkOnline && queuedAlerts > 0) {
Serial.print("[NETWORK] Link restored. Sending queued alerts: ");
Serial.println(queuedAlerts);
publishCommandCenter("QUEUE_FLUSH", "All local alerts forwarded to dashboard", true);
queuedAlerts = 0;
}
}
void takeSnapshot(const String &reason) {
if (!cameraOnline) {
publishCommandCenter("CAMERA_HEALTH", "Camera Obstruction Suspected / camera offline", true);
tampil("CAMERA WARNING", "Obstruction/offline", "Snapshot failed");
return;
}
visualConfidence = randomFloat(0.78, 0.95);
int personCount = random(1, 4);
int motorcycleCount = random(0, 2);
Serial.println("[EDGE_GATEWAY] Snapshot frame sent for visual verification");
Serial.print("[EDGE_GATEWAY] YOLOv12-N demo: Person=");
Serial.print(personCount);
Serial.print(", Motorcycle=");
Serial.print(motorcycleCount);
Serial.print(", VisualConfidence=");
Serial.print(visualConfidence * 100.0, 1);
Serial.println("%");
publishCommandCenter(
"CAMERA_SNAPSHOT",
reason + " | VisualConfidence=" + String(visualConfidence * 100.0, 1) + "% | Person=" + String(personCount),
false
);
}
int recommendPatrol() {
int bestIndex = -1;
for (uint8_t i = 0; i < sizeof(patrols) / sizeof(patrols[0]); i++) {
if (!patrols[i].available) continue;
if (bestIndex < 0 ||
patrols[i].etaMinutes < patrols[bestIndex].etaMinutes ||
(patrols[i].etaMinutes == patrols[bestIndex].etaMinutes &&
patrols[i].distanceKm < patrols[bestIndex].distanceKm)) {
bestIndex = i;
}
}
return bestIndex;
}
void runDssRecommendation(const String &priority) {
int best = recommendPatrol();
if (best < 0) {
publishCommandCenter("DSS", "No AVAILABLE patrol unit", true);
return;
}
String payload = priority + " | Recommend " + patrols[best].name +
" | ETA=" + String(patrols[best].etaMinutes) + " min" +
" | Distance=" + String(patrols[best].distanceKm, 1) + " km" +
" | Rule=Availability>ETA>Distance";
publishCommandCenter("DSS_DISPATCH", payload, true);
}
void localDeterrence(uint8_t cycles, uint16_t onMs, uint16_t offMs, bool buzzerEnabled = true) {
digitalWrite(LED_READY, LOW);
for (uint8_t i = 0; i < cycles; i++) {
digitalWrite(LED_ALERT, HIGH);
digitalWrite(LED_STROBE, HIGH);
if (buzzerEnabled) tone(BUZZER, 2200);
delay(onMs);
digitalWrite(LED_ALERT, LOW);
digitalWrite(LED_STROBE, LOW);
noTone(BUZZER);
delay(offMs);
}
digitalWrite(LED_READY, HIGH);
}
// Non-blocking siren: bunyi bip-bip-bip, bukan nada kontinu.
// Dipanggil terus-menerus dari loop().
void updateSiren() {
if (!sirenActive) {
if (sirenBeepOn) { noTone(BUZZER); sirenBeepOn = false; }
return;
}
unsigned long now = millis();
if (sirenBeepOn && now - sirenLastToggle >= SIREN_BEEP_ON_MS) {
noTone(BUZZER);
sirenBeepOn = false;
sirenLastToggle = now;
} else if (!sirenBeepOn && now - sirenLastToggle >= SIREN_BEEP_OFF_MS) {
tone(BUZZER, SIREN_FREQ);
sirenBeepOn = true;
sirenLastToggle = now;
}
}
// LOKAL FAIL-SAFE: dipanggil setelah SOS + audioConfidence >= 0.70.
// HANYA STROBE yang menyala secara lokal (tanpa menunggu server).
// SIREN + SPEAKER hanya menyala setelah server mengirim EMERGENCY_CONFIRMED.
void activateLocalEmergency() {
localEmergencyActive = true;
digitalWrite(LED_READY, LOW);
digitalWrite(LED_STROBE, HIGH); // STROBE ON (lokal, satu-satunya output lokal)
Serial.println("[LOCAL] EMERGENCY VERIFIED");
Serial.println("[LOCAL] STROBE ON");
Serial.println("[LOCAL] SIREN: menunggu konfirmasi server");
}
void openCase(const String &path, const String &status, float ecs, bool manual) {
latestCaseActive = true;
latestCaseId++;
twoWayAudioOpen = false;
String payload = "Case#" + String(latestCaseId) +
" | Node=" + NODE_ID +
" | Location=" + LOCATION +
" | Path=" + path +
" | Status=" + status +
" | A=" + String(audioScore * 100.0, 1) + "%" +
" | V=" + String(vibrationScore * 100.0, 1) + "%";
if (!manual) payload += " | ECS=" + String(ecs * 100.0, 1) + "%";
publishCommandCenter("EMERGENCY_ALERT", payload, status == "EMERGENCY");
takeSnapshot(path + " " + status);
if (status == "EMERGENCY") {
runDssRecommendation(manual ? "MAX_PRIORITY_SOS" : "AUTO_EMERGENCY");
// LOKAL: SOS + audio terverifikasi -> STROBE aktif tanpa menunggu server.
// SIREN + SPEAKER hanya aktif setelah server mengirim EMERGENCY_CONFIRMED.
activateLocalEmergency();
tampil("EMERGENCY LOKAL", "STROBE ON", "Kirim ke server...");
// Kirim ke server; server confirmation akan menambah SPEAKER (suara petugas).
sendEmergencyEvaluate(audioScore);
} else if (status == "WARNING") {
runDssRecommendation("AUTO_WARNING");
tampil("WARNING", "Operator verify", "ECS " + String(ecs * 100.0, 1) + "%");
localDeterrence(3, 120, 220);
} else {
tampil(status, "Logged locally", "ECS " + String(ecs * 100.0, 1) + "%");
}
}
void triggerSos() {
Serial.println();
const char *classes[] = { "Scream", "Help", "Impact" };
const char *detectedClass = classes[random(0, 3)];
audioScore = randomFloat(0.88, 0.99);
audioDetected = true;
audioTime = millis();
Serial.println("[SOS] BUTTON PRESSED");
Serial.print("[AUDIO AI] Confidence: ");
Serial.println(audioScore, 2);
Serial.print("[SOS_AI] Class=");
Serial.print(detectedClass);
Serial.print(" | AudioDistressProbability=");
Serial.print(audioScore * 100.0, 1);
Serial.println("%");
// ===== VERIFIKASI LOKAL: SOS + audioConfidence >= AUDIO_THRESHOLD (0.70) =====
// Tap/vibration TIDAK lagi menjadi syarat trigger emergency.
if (audioScore >= AUDIO_THRESHOLD) {
latestCaseActive = true;
latestCaseId++;
// Fail-safe lokal: STROBE saja (siren menunggu server).
activateLocalEmergency();
tampil("EMERGENCY LOKAL", "STROBE ON", "A=" + String(audioScore * 100.0, 1) + "%", "Kirim server...");
publishCommandCenter("EMERGENCY_ALERT",
"Case#" + String(latestCaseId) + " | Node=" + NODE_ID + " | Path=SOS_PLUS_AUDIO" +
" | A=" + String(audioScore * 100.0, 1) + "%", true);
// Kirim ke server; konfirmasi server akan menambah SIREN + SPEAKER.
sendEmergencyEvaluate(audioScore);
} else {
Serial.print("[LOCAL] REJECTED: confidence < ");
Serial.println(AUDIO_THRESHOLD, 2);
tampil("SOS", "Audio rendah", "A=" + String(audioScore * 100.0, 1) + "%");
audioDetected = false;
}
}
void evaluateFusion() {
float ecs = (0.70 * audioScore) + (0.30 * vibrationScore);
// SOS manual + audio terverifikasi = selalu EMERGENCY (tidak bergantung ambang ECS).
// Konsisten dengan server yang memperlakukan SOS sebagai emergency langsung.
String status = "EMERGENCY";
Serial.println();
Serial.println("=== [SENSOR FUSION EVALUATION] ===");
Serial.print("SOS / help probability : ");
Serial.print(audioScore * 100.0, 1);
Serial.println("%");
Serial.print("Audio pattern score : ");
Serial.print(vibrationScore * 100.0, 1);
Serial.println("%");
Serial.print("Emergency Confidence Score : ");
Serial.print(ecs * 100.0, 1);
Serial.print("% -> ");
Serial.print(status);
Serial.println(" (SOS manual = emergency langsung)");
if (status == "EMERGENCY" || status == "WARNING") {
openCase("SOS_PLUS_AUDIO", status, ecs, false);
} else {
publishCommandCenter("EVENT_HISTORY", "SOS + audio detection " + status + " | ECS=" + String(ecs * 100.0, 1) + "%", false);
tampil(status, "False alarm rejected", "ECS " + String(ecs * 100.0, 1) + "%");
delay(900);
}
audioDetected = false;
vibrationPatternDetected = false;
knockCount = 0;
}
void registerKnock() {
unsigned long now = millis();
if (knockCount == 0 || now - firstKnockTime > FUSION_WINDOW) {
knockCount = 1;
firstKnockTime = now;
lastKnockTime = now;
} else {
knockCount++;
lastKnockTime = now;
}
Serial.print("[AUDIO] Pattern tap ");
Serial.print(knockCount);
Serial.println("/3 registered");
tampil("AUDIO", "Pattern " + String(knockCount) + "/3", "Window 3 sec");
// CATATAN: tap/vibration BUKAN lagi trigger emergency.
// Emergency hanya dipicu oleh SOS + audioConfidence >= AUDIO_THRESHOLD.
// Tap hanya dicatat sebagai informasi pendukung (logging).
if (knockCount >= 3 && (lastKnockTime - firstKnockTime) <= FUSION_WINDOW) {
unsigned long totalInterval = lastKnockTime - firstKnockTime;
float timingScore = 1.0 - min(abs((long)totalInterval - 2000L) / 2500.0, 0.18);
vibrationScore = constrain(randomFloat(0.82, 0.97) * timingScore, 0.0, 1.0);
vibrationTime = now;
knockCount = 0;
Serial.print("[AI_AUDIO] Audio pattern logged (bukan trigger) | A=");
Serial.print(vibrationScore * 100.0, 1);
Serial.println("%");
publishCommandCenter("AUDIO_PATTERN_LOG", "Audio pattern 3x | Score=" + String(vibrationScore * 100.0, 1) + "% | Tidak memicu emergency", false);
tampil("AUDIO PATTERN", "Logged only", "Bukan trigger", "A=" + String(vibrationScore * 100.0, 1) + "%");
}
}
void triggerTamper() {
Serial.println();
Serial.println("===== [TAMPER PROTECTION] ABNORMAL IMPACT / PANEL OPEN =====");
publishCommandCenter("TAMPER_ALERT", "Node=" + String(NODE_ID) + " | Abnormal impact/panel opened | Maintenance ticket created", true);
takeSnapshot("Tamper verification");
tampil("TAMPER ALERT", "Separate from ECS", "Ticket created");
localDeterrence(4, 90, 120);
tampil("READY", "Node online");
}
void toggleCameraHealth() {
cameraOnline = !cameraOnline;
String health = cameraOnline ? "Camera online" : "Camera Obstruction Suspected";
publishCommandCenter("CAMERA_HEALTH", health, !cameraOnline);
tampil("CAMERA", cameraOnline ? "ONLINE" : "OFFLINE", cameraOnline ? "Live CCTV ready" : "Snapshot disabled");
}
void toggleNetwork() {
networkOnline = !networkOnline;
digitalWrite(LED_NETWORK, networkOnline ? HIGH : LOW);
Serial.println(networkOnline ? "[NETWORK] Wi-Fi/4G link ONLINE" : "[NETWORK] Wi-Fi/4G link OFFLINE");
tampil("NETWORK", networkOnline ? "ONLINE" : "OFFLINE", networkOnline ? "Queue flushing" : "Local mode");
flushQueuedAlerts();
}
void toggleTwoWayAudio() {
if (!latestCaseActive) {
tampil("AUDIO CHANNEL", "No active case");
Serial.println("[OPERATOR] Two-way audio denied: no active warning/emergency case");
return;
}
twoWayAudioOpen = !twoWayAudioOpen;
publishCommandCenter("TWO_WAY_AUDIO", twoWayAudioOpen ? "Operator opened channel" : "Operator closed channel", false);
tampil("2-WAY AUDIO", twoWayAudioOpen ? "OPEN" : "CLOSED", "On-demand only");
}
void runSelfTest() {
Serial.println();
Serial.println("===== [DEVICE SELF-TEST] =====");
Serial.println("MCU : OK");
Serial.println("SOS : OK");
Serial.println("Audio : OK (3-pattern simulation)");
Serial.print("Camera : ");
Serial.println(cameraOnline ? "OK" : "WARNING");
Serial.println("Speaker : OK");
Serial.print("Network : ");
Serial.println(networkOnline ? "OK" : "OFFLINE");
publishCommandCenter("SELF_TEST", cameraOnline && networkOnline ? "All components OK" : "Hardware warning detected", !cameraOnline || !networkOnline);
tampil("SELF TEST", cameraOnline && networkOnline ? "ALL OK" : "WARNINGS", "See Serial");
}
void heartbeat() {
if (millis() - lastHeartbeat < HEARTBEAT_INTERVAL) return;
lastHeartbeat = millis();
int riskScore = 82;
String riskLevel = "HIGH";
String payload = "Node=" + String(NODE_ID) +
" | MCU=OK | SOS=OK | AUDIO=OK" +
" | CAM=" + String(cameraOnline ? "OK" : "WARN") +
" | NET=" + String(networkOnline ? "OK" : "OFFLINE") +
" | RiskScore=" + String(riskScore) + "% " + riskLevel +
" | QueuedAlerts=" + String(queuedAlerts);
publishCommandCenter("HEARTBEAT", payload, !cameraOnline);
}
void handleSerialCommand() {
if (!Serial.available()) return;
char cmd = toupper(Serial.read());
if (cmd == 'R') {
latestCaseActive = false;
twoWayAudioOpen = false;
audioDetected = false;
vibrationPatternDetected = false;
knockCount = 0;
if (serverCaseActive) clearServerCommand();
localEmergencyActive = false;
sirenActive = false;
digitalWrite(LED_STROBE, LOW);
digitalWrite(LED_ALERT, LOW);
noTone(BUZZER);
digitalWrite(LED_READY, HIGH);
publishCommandCenter("CASE_CLOSED", "Operator reset node to READY", false);
tampil("READY", "Node online");
} else if (cmd == 'T') {
forceEmergencyTest();
} else if (cmd == 'C') {
serverHealthCheck();
} else if (cmd == 'V' && latestCaseActive) {
publishCommandCenter("CASE_STATUS", "Operator marked Case#" + String(latestCaseId) + " as VALID", true);
} else if (cmd == 'F' && latestCaseActive) {
publishCommandCenter("CASE_STATUS", "Operator marked Case#" + String(latestCaseId) + " as FALSE_ALARM", false);
}
}
// ============================================================
// NETWORK + SERVER HELPERS (aman terhadap WiFi/ngrok/Flask down)
// ============================================================
bool wifiConnected() { return WiFi.status() == WL_CONNECTED; }
void connectWiFi() {
Serial.println("[WIFI] Connecting...");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS, WIFI_CHANNEL);
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
delay(250);
Serial.print(".");
}
Serial.println();
if (wifiConnected()) {
networkOnline = true;
digitalWrite(LED_NETWORK, HIGH);
Serial.print("[WIFI] Connected IP=");
Serial.println(WiFi.localIP());
} else {
networkOnline = false;
digitalWrite(LED_NETWORK, LOW);
Serial.println("[WIFI] FAILED (local fail-safe tetap aktif)");
}
}
bool httpPostJson(const String &path, const String &body, String &response, int &statusCode) {
statusCode = -1; response = "";
if (!wifiConnected()) {
Serial.println("[HTTP] Batal: WiFi tidak terhubung");
return false;
}
// Satu percobaan POST memakai transport tertentu.
bool ok = false;
for (uint8_t attempt = 0; attempt < 2; attempt++) {
String url = activeBase() + path;
HTTPClient http;
bool begun = false;
WiFiClientSecure secureClient;
WiFiClient plainClient;
if (useHttpsTransport) {
secureClient.setInsecure(); // prototype: skip verifikasi sertifikat ngrok
begun = http.begin(secureClient, url);
} else {
begun = http.begin(plainClient, url);
}
if (!begun) {
Serial.println("[HTTP] begin() gagal - URL tidak valid?");
return false;
}
http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS);
http.setTimeout(HTTP_RESPONSE_TIMEOUT_MS);
http.addHeader("Content-Type", "application/json");
http.addHeader("ngrok-skip-browser-warning", "true");
statusCode = http.POST(body);
if (statusCode > 0) {
response = http.getString();
http.end();
transportLocked = true;
ok = true;
break;
}
Serial.print("[HTTP] POST error: ");
Serial.print(statusCode);
Serial.print(" (");
Serial.print(http.errorToString(statusCode));
Serial.print(") via ");
Serial.println(useHttpsTransport ? "HTTPS" : "HTTP");
http.end();
if (transportLocked || attempt == 1) break;
// Coba transport lain sekali (HTTPS <-> HTTP).
useHttpsTransport = !useHttpsTransport;
Serial.print("[HTTP] Fallback -> ");
Serial.println(useHttpsTransport ? "HTTPS" : "HTTP");
}
return ok;
}
bool httpGetJson(const String &path, String &response, int &statusCode) {
statusCode = -1; response = "";
if (!wifiConnected()) {
Serial.println("[HTTP] Batal: WiFi tidak terhubung");
return false;
}
bool ok = false;
for (uint8_t attempt = 0; attempt < 2; attempt++) {
String url = activeBase() + path;
HTTPClient http;
bool begun = false;
WiFiClientSecure secureClient;
WiFiClient plainClient;
if (useHttpsTransport) {
secureClient.setInsecure();
begun = http.begin(secureClient, url);
} else {
begun = http.begin(plainClient, url);
}
if (!begun) {
Serial.println("[HTTP] begin() gagal - URL tidak valid?");
return false;
}
http.setConnectTimeout(HTTP_CONNECT_TIMEOUT_MS);
http.setTimeout(HTTP_RESPONSE_TIMEOUT_MS);
http.addHeader("ngrok-skip-browser-warning", "true");
statusCode = http.GET();
if (statusCode > 0) {
response = http.getString();
http.end();
transportLocked = true;
ok = true;
break;
}
Serial.print("[HTTP] GET error: ");
Serial.print(statusCode);
Serial.print(" (");
Serial.print(http.errorToString(statusCode));
Serial.print(") via ");
Serial.println(useHttpsTransport ? "HTTPS" : "HTTP");
http.end();
if (transportLocked || attempt == 1) break;
useHttpsTransport = !useHttpsTransport;
Serial.print("[HTTP] Fallback -> ");
Serial.println(useHttpsTransport ? "HTTPS" : "HTTP");
}
return ok;
}
void sendEmergencyEvaluate(float audioConfidence) {
String body;
JsonDocument doc;
doc["device_id"] = API_DEVICE_ID;
doc["sos"] = true;
doc["audio_confidence"] = audioConfidence;
doc["hotspot_risk"] = "High";
serializeJson(doc, body);
Serial.println("[HTTP] POST /api/emergency/evaluate");
tampil("EMERGENCY LOKAL", "STROBE ON", "Kirim server...");
String resp; int status;
if (!httpPostJson("/api/emergency/evaluate", body, resp, status)) {
// PENTING: selalu update OLED saat gagal, kalau tidak layar tampak menggantung
// di "Kirim server..." dan seolah tidak ada respons.
Serial.println("[HTTP] POST gagal (WiFi/ngrok/Flask down) - local mode lanjut");
tampil("SERVER GAGAL", "STROBE tetap ON", "Mode lokal", "Tekan C=cek");
return;
}
Serial.print("[HTTP] Status: "); Serial.println(status);
if (status != 200) {
tampil("SERVER ERROR", "HTTP " + String(status), "STROBE tetap ON");
return;
}
JsonDocument res;
if (deserializeJson(res, resp)) {
Serial.println("[HTTP] JSON invalid");
tampil("SERVER ERROR", "JSON invalid", "STROBE tetap ON");
return;
}
const char *state = res["decision"]["emergency_state"] | "";
Serial.print("[SERVER] state="); Serial.println(state);
if (String(state) == "EMERGENCY") Serial.println("[SERVER] EMERGENCY_CONFIRMED");
tampil("SERVER OK", String(state), "Tunggu command...");
serverCaseActive = true;
lastServerPoll = 0;
}
void pollServerCommand() {
Serial.println("[COMMAND] GET /api/device/" API_DEVICE_ID "/command");
String resp; int status;
if (!httpGetJson("/api/device/" API_DEVICE_ID "/command", resp, status)) {
Serial.println("[COMMAND] GET gagal - local fail-safe tetap aktif");
tampil("COMMAND GAGAL", "STROBE tetap ON", "Mode lokal");
return;
}
if (status != 200) { Serial.print("[COMMAND] Status: "); Serial.println(status); return; }
JsonDocument doc;
if (deserializeJson(doc, resp)) { Serial.println("[COMMAND] JSON invalid"); return; }
const char *command = doc["command"] | "NONE";
if (String(command) == "EMERGENCY_CONFIRMED") {
bool strobe = doc["strobe"] | false;
bool siren = doc["siren"] | false;
bool speaker = doc["speaker"] | false;
String voice = doc["voice_message"] | "";
if (!serverConfirmed) { Serial.println("[SERVER] EMERGENCY_CONFIRMED"); serverConfirmed = true; }
// Strobe lokal dipertahankan (fail-safe). Server menambah SIREN + SPEAKER.
if (strobe || localEmergencyActive) digitalWrite(LED_STROBE, HIGH);
sirenActive = siren; // SIREN dikontrol server
digitalWrite(LED_ALERT, speaker ? HIGH : LOW); // SPEAKER dikontrol server
Serial.print("[COMMAND] STROBE: "); Serial.println((strobe || localEmergencyActive) ? "ON" : "OFF");
Serial.print("[COMMAND] SIREN: "); Serial.println(siren ? "ON" : "OFF");
Serial.print("[COMMAND] SPEAKER: "); Serial.println(speaker ? "ON" : "OFF");
if (voice.length()) { Serial.print("[COMMAND] VOICE: "); Serial.println(voice); }
tampil("EMERGENCY CONFIRMED", "STROBE+SIREN+SPK", voice);
} else {
// command == NONE: matikan output yang dikontrol server.
// STROBE lokal tetap dipertahankan selama case lokal masih aktif (fail-safe).
if (serverConfirmed) {
Serial.println("[COMMAND] NONE -> server outputs OFF");
serverConfirmed = false;
}
sirenActive = false;
digitalWrite(LED_ALERT, LOW);
if (!localEmergencyActive) digitalWrite(LED_STROBE, LOW);
Serial.print("[COMMAND] SIREN: OFF | SPEAKER: OFF | STROBE: ");
Serial.println(localEmergencyActive ? "ON (lokal)" : "OFF");
}
}
// Cek konektivitas ke server SAAT BOOT, tanpa perlu menekan tombol.
// Ini membuat masalah koneksi langsung terlihat, bukan tersembunyi sampai trigger.
void serverHealthCheck() {
Serial.println();
Serial.println("===== [SERVER CHECK] =====");
if (!wifiConnected()) {
Serial.println("[SERVER CHECK] GAGAL: WiFi tidak terhubung");
tampil("SERVER CHECK", "WiFi FAIL", "Mode lokal");
return;
}
Serial.print("[SERVER CHECK] Target: ");
Serial.println(SERVER_BASE);
String resp; int status;
if (!httpGetJson("/api/device/" API_DEVICE_ID "/command", resp, status)) {
Serial.println("[SERVER CHECK] GAGAL: tidak bisa menjangkau server");
Serial.println("[SERVER CHECK] Cek: ngrok masih hidup? URL SERVER_BASE benar?");
tampil("SERVER CHECK", "UNREACHABLE", "Cek ngrok/URL");
return;
}
Serial.print("[SERVER CHECK] HTTP Status: ");
Serial.println(status);
if (status == 200) {
if (resp.length() && resp[0] == '<') {
Serial.println("[SERVER CHECK] ERROR: Body HTML, bukan JSON (ngrok warning page)");
tampil("SERVER CHECK", "HTML bukan JSON");
return;
}
Serial.println("[SERVER CHECK] BERHASIL - ESP32 TERHUBUNG KE SERVER");
Serial.print("[SERVER CHECK] Transport aktif: ");
Serial.println(useHttpsTransport ? "HTTPS" : "HTTP (fallback)");
Serial.println("[SERVER CHECK] Tekan 'T' di Serial untuk uji emergency tanpa tombol");
tampil("SERVER CHECK", "CONNECTED OK", useHttpsTransport ? "via HTTPS" : "via HTTP");
} else {
Serial.println("[SERVER CHECK] Server merespons tapi status bukan 200");
tampil("SERVER CHECK", "HTTP " + String(status));
}
}
// Uji emergency penuh tanpa perlu menekan tombol fisik (ketik 'T' di Serial).
void forceEmergencyTest() {
Serial.println();
Serial.println("===== [TEST] FORCE EMERGENCY (via Serial) =====");
audioScore = 0.92;
audioDetected = true;
audioTime = millis();
latestCaseActive = true;
latestCaseId++;
Serial.println("[SOS] BUTTON PRESSED (simulated)");
Serial.print("[AUDIO AI] Confidence: ");
Serial.println(audioScore, 2);
activateLocalEmergency();
tampil("EMERGENCY LOKAL", "STROBE ON", "TEST via Serial");
sendEmergencyEvaluate(audioScore);
}
void clearServerCommand() {
Serial.println("[COMMAND] POST /api/device/" API_DEVICE_ID "/command/clear");
String resp; int status;
httpPostJson("/api/device/" API_DEVICE_ID "/command/clear", "{}", resp, status);
Serial.print("[COMMAND] clear status: "); Serial.println(status);
serverCaseActive = false;
serverConfirmed = false;
sirenActive = false;
noTone(BUZZER);
}
void setup() {
Serial.begin(115200);
Serial.println();
Serial.println("[BOOT] ASEP-JAGA STARTING");
randomSeed(analogRead(0));
pinMode(LED_READY, OUTPUT);
pinMode(LED_ALERT, OUTPUT);
pinMode(LED_STROBE, OUTPUT);
pinMode(LED_NETWORK, OUTPUT);
pinMode(BUZZER, OUTPUT);
pinMode(SOS_BUTTON, INPUT_PULLUP);
pinMode(AUDIO_BUTTON, INPUT_PULLUP);
pinMode(TAMPER_BUTTON, INPUT_PULLUP);
pinMode(CAMERA_BUTTON, INPUT_PULLUP);
pinMode(NETWORK_BUTTON, INPUT_PULLUP);
pinMode(TALK_BUTTON, INPUT_PULLUP);
pinMode(SELF_TEST_BUTTON, INPUT_PULLUP);
digitalWrite(LED_READY, HIGH);
digitalWrite(LED_ALERT, LOW);
digitalWrite(LED_STROBE, LOW);
digitalWrite(LED_NETWORK, HIGH);
digitalWrite(BUZZER, LOW);
Wire.begin(8, 9);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("SSD1306 allocation failed"));
for (;;) {}
}
tampil("BOOTING", "Self-test...");
delay(700);
connectWiFi();
serverHealthCheck();
tampil("READY", "Node online", wifiConnected() ? "WiFi OK" : "Local mode");
Serial.println();
Serial.println("[SYSTEM] ASEP-JAGA Node PJU001 ONLINE");
Serial.println("[HELP] SOS + audioConfidence >= 0.70 = STROBE lokal + kirim server");
Serial.println("[HELP] SIREN + SPEAKER hanya aktif setelah server EMERGENCY_CONFIRMED");
Serial.println("[HELP] TAMPER = maintenance/security alert");
Serial.println("[HELP] CAM toggles camera health, NET toggles network, TALK opens 2-way audio");
Serial.println("[HELP] Serial: T=uji emergency (tanpa tombol), C=cek server");
Serial.println("[HELP] Serial: V=valid, F=false alarm, R=reset/close case");
runSelfTest();
}
void loop() {
if (fell(btnSos)) triggerSos();
if (fell(btnAudio)) registerKnock();
if (fell(btnTamper)) triggerTamper();
if (fell(btnCamera)) toggleCameraHealth();
if (fell(btnNetwork)) toggleNetwork();
if (fell(btnTalk)) toggleTwoWayAudio();
if (fell(btnSelfTest)) runSelfTest();
// Tidak ada lagi evaluasi fusion SOS+vibration:
// emergency dipicu langsung di triggerSos() bila audioConfidence >= threshold.
if (knockCount > 0 && millis() - firstKnockTime > FUSION_WINDOW) {
Serial.println("[TIMEOUT] Audio pattern incomplete. Logged as non-emergency audio.");
knockCount = 0;
vibrationScore = randomFloat(0.03, 0.18);
tampil("READY", "Audio timeout", "Pattern rejected");
}
// Polling command server saat case aktif (server confirmation loop)
if (serverCaseActive && millis() - lastServerPoll > SERVER_POLL_INTERVAL) {
lastServerPoll = millis();
pollServerCommand();
}
updateSiren(); // sirene bip-bip non-blocking
heartbeat();
handleSerialCommand();
delay(20);
}