Kabar Terkini

Rakit Sendiri Alat INFO Gempa BMKG dengan ESP32, Max7219 dan Buzzer

 




Audio Podcast : 



Dokumen:

https://docs.google.com/document/d/1ZWl-Pt51RwHAYtvuULV-jHnQp12ZQww3/edit?usp=sharing&ouid=103568289814508092792&rtpof=true&sd=true





Code:


// =============================================================================
//  ESP32 + MAX7219 (4 x 8x8) - INFO GEMPA BMKG (InaTEWS) REALTIME
//  + Web Server (tabel gempa realtime, bisa dibuka dari HP)
//  + Tampilan IP & info gempa berjalan (loop) di matrix, auto-update
//  + Notifikasi Suara (Buzzer) PANJANG & MENCOLOK (sirene) saat ada gempa BARU
// =============================================================================
//  CARA KERJA / ARSITEKTUR:
//  ------------------------
//  1. ESP32 melakukan polling ke API resmi BMKG di BACKGROUND dengan interval
//     CEPAT (default 3 detik untuk data gempa terbaru, 8 detik untuk daftar
//     15 gempa terkini) supaya kecepatan update-nya sedekat mungkin dengan
//     halaman https://www.bmkg.go.id/gempabumi/gempabumi-realtime. Hasilnya
//     disimpan sebagai "cache" di RAM ESP32.
//
//  2. ESP32 menjalankan Web Server di port 80. Saat HP dibuka ke alamat IP
//     ESP32 (ditampilkan di matrix & Serial Monitor), browser HP akan
//     mengambil data dari ESP32 sendiri (endpoint /api/autogempa dan
//     /api/gempaterkini) tiap 3 detik lewat JavaScript, sehingga tabel di
//     HP selalu update tanpa perlu reload halaman.
//
//     CATATAN: kita TIDAK fetch langsung dari cdn.bmkg.go.id / bmkg.go.id di
//     browser HP, karena endpoint tsb didesain untuk dipakai dari domain
//     BMKG sendiri dan bisa diblokir CORS kalau diakses dari domain/IP lain.
//     Solusinya: ESP32 yang mengambil data dari BMKG (server-side, tidak
//     kena CORS), lalu HP tinggal ambil dari ESP32 (satu jaringan lokal,
//     tidak ada masalah CORS).
//
//  3. Data yang sama (cache di ESP32) juga dipakai untuk update tampilan di
//     matrix, sehingga matrix & halaman web selalu sinkron dan sama-sama
//     ter-update begitu ESP32 selesai polling ke BMKG.
//
//  SUMBER DATA RESMI BMKG (Data Terbuka / Open Data):
//    https://data.bmkg.go.id/DataMKG/TEWS/autogempa.json        (1 gempa terbaru, semua magnitudo)
//    https://data.bmkg.go.id/DataMKG/TEWS/gempaterkini.json     (15 gempa terkini, KHUSUS M 5.0+)
//    https://data.bmkg.go.id/DataMKG/TEWS/gempadirasakan.json   (15 gempa dirasakan, magnitudo lebih kecil)
//
//  LAPORAN "SEMUA GEMPA MINIMAL M 2.0":
//  BMKG TIDAK menyediakan endpoint publik berisi "semua gempa >= M2.0" secara
//  lengkap/real-time (data seperti itu hanya ada di API internal yang dipakai
//  halaman web resmi BMKG, bukan bagian dari Data Terbuka). Sebagai gantinya,
//  sketch ini MENGGABUNGKAN tiga sumber resmi di atas (autogempa + gempaterkini
//  dirasakan), lalu memfilter hanya yang magnitudonya >= 2.0, dan menghapus
//  duplikat (gempa yang sama muncul di kedua daftar). Ini adalah pendekatan
//  terbaik yang bisa dilakukan dengan Data Terbuka BMKG yang tersedia -
//  cakupannya terbatas pada gabungan maksimal 30 kejadian terakhir (15+15),
//  BUKAN seluruh gempa M2.0+ se-Indonesia secara lengkap.
//
//  ALARM PINTAR BERBASIS JARAK & MAGNITUDO (gabungan dari sketch versi LED/GPS):
//  --------------------------------------------------------------------------
//  Tanpa filter, alarm KERAS akan bunyi untuk SETIAP gempa baru di seluruh
//  Indonesia -> cepat bikin lelah/annoying ("alarm fatigue") karena mayoritas
//  gempa terjadi jauh dari lokasi pengguna dan tidak akan terasa sama sekali.
//
//  Sketch ini menghitung JARAK dari lokasi pengguna (LOKASI_LAT/LOKASI_LON) ke
//  episentrum tiap gempa baru, memakai field "Coordinates" yang SUDAH ADA di
//  JSON resmi BMKG (jadi TIDAK perlu API geocoding eksternal tambahan seperti
//  OpenWeatherMap - lebih sedikit dependency, lebih cepat, lebih tahan gagal).
//  Jarak dihitung dengan rumus Haversine (functions hitungJarakKm()).
//
//  Berdasarkan jarak & magnitudo, notifikasi dibagi 3 tingkat:
//    1) TSUNAMI                      -> SELALU alarm sirene penuh, apapun jaraknya.
//    2) Dekat (<= JARAK_ALARM_PENUH_KM) ATAU magnitudo >= MAGNITUDO_ALARM_PENUH
//                                     -> alarm sirene penuh (mendesak).
//    3) Jauh tapi magnitudo >= MAGNITUDO_MINIMAL_CATAT -> notifikasi RINGAN
//       (2x beep singkat), tetap tercatat penuh di layar matrix & web.
//    4) Magnitudo di bawah MAGNITUDO_MINIMAL_CATAT      -> tanpa bunyi sama
//       sekali, hanya tercatat di layar & web (hindari micro-tremor noise).
//  Semua gempa baru (tingkat manapun) TETAP tampil di matrix & web -> tidak
//  ada informasi yang hilang, hanya VOLUME alarm yang disesuaikan relevansinya.
// =============================================================================

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <ArduinoJson.h>     // Install: "ArduinoJson" by Benoit Blanchon (versi 6.x)
#include <MD_Parola.h>
#include <MD_MAX72xx.h>
#include <SPI.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <time.h>            // untuk fitur tampilan JAM (NTP) di matrix
#include <math.h>            // untuk hitung jarak (Haversine) - lihat hitungJarakKm()

// ------------------- KONFIGURASI WIFI -------------------
const char* WIFI_SSID     = "********************";
const char* WIFI_PASSWORD = "**********************";

// Hostname mDNS opsional -> bisa diakses via http://gempa-esp32.local
const char* MDNS_HOSTNAME = "gempa-esp32";

// ------------------- KONFIGURASI JAM (NTP) -------------------
const char* NTP_SERVER_1   = "pool.ntp.org";
const char* NTP_SERVER_2   = "time.nist.gov";
const long  GMT_OFFSET_SEC = 7 * 3600;   // WIB = UTC+7 (ganti 8*3600 utk WITA, 9*3600 utk WIT)
const int   DST_OFFSET_SEC = 0;
const unsigned long DURASI_TAMPIL_JAM_MS = 10000; // jam tampil 10 detik tiap giliran

// ------------------- KONFIGURASI BMKG --------------------
const char* URL_AUTOGEMPA      = "https://data.bmkg.go.id/DataMKG/TEWS/autogempa.json";
const char* URL_GEMPATERKINI   = "https://data.bmkg.go.id/DataMKG/TEWS/gempaterkini.json";   // 15 gempa M5.0+
const char* URL_GEMPADIRASAKAN = "https://data.bmkg.go.id/DataMKG/TEWS/gempadirasakan.json"; // 15 gempa dirasakan (magnitudo lebih kecil)

// SUMBER TAMBAHAN (TIDAK RESMI / UNDOCUMENTED): bucket cloud InaTEWS, dipakai
// HANYA sebagai info "Quick Look" (deteksi otomatis awal, cepat tapi BELUM
// tentu final/terverifikasi) untuk melengkapi tampilan matrix & web.
// CATATAN PENTING:
//  - Endpoint ini TIDAK didokumentasikan resmi oleh BMKG di halaman Data
//    Terbuka (data.bmkg.go.id) -> formatnya bisa berubah/dimatikan sewaktu²
//    tanpa pemberitahuan. Kalau parsing gagal terus, cukup nonaktifkan lewat
//    LASTQL_AKTIF = false di bawah, sisanya (autogempa dkk) tetap jalan normal.
//  - Isinya cuma 1 kejadian TERBARU (mirip autogempa), field waktu "time"
//    dalam UTC (BUKAN WIB), dan BELUM tentu sinkron/identik dgn autogempa.json
//    karena beda pipeline internal BMKG.
//  - ATAS PERMINTAAN USER, sumber ini SEKARANG JUGA memicu alarm buzzer
//    (sirene penuh) dan mendapat PRIORITAS tampil di matrix (memotong rotasi
//    normal/mode jam) setiap ada kiriman data BARU dari lastQL - persis
//    seperti gempa baru dari autogempa, hanya dibedakan alur/variable-nya
//    (adaQuicklookBaruPending) supaya sumbernya tetap bisa dibedakan di kode.
//    Karena datanya belum tentu terverifikasi, tetap disaring minimal M2.0
//    (MAGNITUDO_MINIMAL_CATAT) supaya tidak berisik untuk micro-tremor.
//  - SEMUA laporan quicklook (M>=2.0) juga diakumulasi ESP32 selama menyala
//    menjadi tabel riwayat hingga 200 data (lihat riwayatQL[] & endpoint web
//    /api/lastql-riwayat) - BUKAN dari BMKG sekaligus, karena lastQL.json itu
//    sendiri hanya berisi 1 entri terbaru tiap kali di-poll.
const char* URL_LASTQL = "https://bmkg-content-inatews.storage.googleapis.com/lastQL.json";
bool LASTQL_AKTIF = true;   // set false untuk mematikan sumber tambahan ini sepenuhnya

// Dipercepat supaya sedekat mungkin dengan kecepatan update halaman InaTEWS/
// gempabumi-realtime BMKG. CATATAN PENTING: mempercepat interval ini TIDAK
// membuat BMKG mem-publish data lebih cepat dari yang sebenarnya - ini cuma
// memperkecil JEDA antara BMKG selesai publish dan ESP32 "notice" datanya.
// Endpoint autogempa.json paling sering dicek karena itu sumber alarm gempa
// BARU (yang paling butuh realtime); gempaterkini & gempadirasakan cukup
// sedikit lebih longgar karena isinya daftar historis (15 entri), bukan
// pemicu alarm.
// PENTING: setiap request di bawah ini adalah HTTPS (TLS) PENUH - bukan HTTP biasa.
// Handshake TLS di ESP32 itu BERAT (bisa makan 1-3 detik CPU + puluhan KB heap
// SETIAP KALI dibuka, karena WiFiClientSecure di sini membuat koneksi BARU
// setiap poll, tidak reuse/keep-alive). Interval sebelumnya (1 detik utk
// autogempa, 2 detik utk lainnya) artinya ESP32 mencoba buka 3-4 koneksi TLS
// TERUS-MENERUS nyaris tanpa jeda -> heap cepat terfragmentasi/habis -> lama-lama
// SEMUA request gagal terus (WiFiClientSecure gagal alokasi buffer TLS) -> cache
// data di ESP32 tidak pernah "siap" -> endpoint /api/... selalu balas 503 ->
// halaman web nge-stuck di "menghubungkan..." dan tabel selalu kosong.
// Nilai di bawah ini dikembalikan ke rentang yang wajar/aman untuk ESP32.
unsigned long AUTOGEMPA_POLL_INTERVAL   = 4000;    // polling gempa terbaru tiap 4 detik
unsigned long TERKINI_POLL_INTERVAL     = 10000;   // polling daftar 15 gempa M5.0+ tiap 10 detik
unsigned long DIRASAKAN_POLL_INTERVAL   = 10000;   // polling daftar 15 gempa dirasakan tiap 10 detik
unsigned long LASTQL_POLL_INTERVAL      = 6000;    // polling sumber tambahan (Quick Look, tidak resmi) tiap 6 detik
const unsigned long HTTP_TIMEOUT_MS     = 8000;    // batas waktu tiap request ke BMKG (TLS handshake butuh waktu lebih dari 4 detik kadang)

// ------------------- KONFIGURASI LOKASI PENGGUNA (untuk alarm pintar) -------------------
// GANTI dengan koordinat lokasi kamu (cari di Google Maps: klik-kanan lokasi ->
// koordinat muncul di bagian atas, contoh "-6.914744, 107.609810"). Dipakai untuk
// menghitung jarak ke episentrum tiap gempa, supaya alarm KERAS hanya bunyi untuk
// gempa yang relevan (dekat/besar), bukan setiap gempa di seluruh Indonesia.
double LOKASI_LAT = -6.200000;   // GANTI: lintang lokasi kamu (contoh: Jakarta)
double LOKASI_LON = 106.816666;  // GANTI: bujur lokasi kamu   (contoh: Jakarta)

// Ambang batas alarm pintar (silakan sesuaikan dengan kebutuhan/wilayah kamu):
float JARAK_ALARM_PENUH_KM    = 500.0;  // gempa dlm radius ini dari lokasi -> alarm sirene PENUH
float MAGNITUDO_ALARM_PENUH   = 5.0;    // magnitudo >= ini SELALU alarm sirene PENUH, walau jauh
float MAGNITUDO_MINIMAL_CATAT = 2.0;    // di bawah ini: hanya dicatat di layar/web, TANPA bunyi sama sekali
// Gempa berpotensi TSUNAMI SELALU dapat alarm sirene penuh, apapun jarak/magnitudonya (lihat pollAutogempa()).

// ------------------- KONFIGURASI BUZZER -------------------
#define BUZZER_PIN 14                // ganti sesuai pin buzzer yang dipakai
#define BUZZER_ACTIVE_HIGH true      // hanya dipakai kalau BUZZER_TYPE_PASSIVE = false
#define BUZZER_TYPE_PASSIVE  true    // true  = buzzer PASIF (2 pin, butuh sinyal PWM/tone -> alarm sirene naik-turun)
                                      // false = buzzer AKTIF (2 pin, sudah ada osilator sendiri -> hanya ON/OFF)
                                      // Kalau buzzer kamu berbunyi "tit" konstan saat dikasih tegangan DC langsung
                                      // tanpa sinyal, berarti itu buzzer AKTIF -> set false.

// ------------------- KONFIGURASI MAX7219 -------------------
#define HARDWARE_TYPE MD_MAX72XX::FC16_HW   // untuk upload fisik (bukan simulator)
#define MAX_DEVICES 4
#define CLK_PIN    25
#define CS_PIN     26
#define Data_PIN   27

MD_Parola P = MD_Parola(HARDWARE_TYPE, Data_PIN, CLK_PIN, CS_PIN, MAX_DEVICES);

// ------------------- WEB SERVER -------------------
WebServer server(80);

// =============================================================================
//  ARSITEKTUR DUAL-CORE (INI KUNCI SUPAYA RUNNING TEXT TIDAK NYENDAT-NYENDAT)
//  ------------------------------------------------------------------------
//  ESP32 punya 2 CPU core. Sebelumnya semua kerjaan (polling HTTP ke BMKG,
//  web server, alarm buzzer) dan animasi matrix jalan di SATU core yang sama
//  secara bergantian -> setiap kali polling BMKG jalan (blocking, bisa 0.5-5
//  detik), P.displayAnimate() berhenti dipanggil -> teks berhenti total lalu
//  "lompat" begitu request selesai. Itu penyebab nyendat-nyendat.
//
//  Solusi: pisahkan tugas ke 2 core.
//   - CORE 0 -> taskJaringan(): WiFi, web server, polling BMKG, alarm buzzer.
//               Boleh blocking sepuasnya, karena TIDAK menyentuh matrix sama
//               sekali secara langsung.
//   - CORE 1 -> loop() utama (default Arduino loop): HANYA memanggil
//               P.displayAnimate() terus-menerus tanpa delay/blocking apa
//               pun -> scroll selalu mulus, berapa pun lambatnya BMKG.
//
//  Data (teks gempa, IP, dll) yang dibutuhkan kedua core dilindungi pakai
//  mutex (xDataMutex) supaya tidak "tabrakan" saat dibaca/ditulis bersamaan.
// =============================================================================
SemaphoreHandle_t xDataMutex;                  // melindungi buffer teks lintas-core
TaskHandle_t      taskNetworkHandle;           // handle task jaringan (core 0)
volatile bool     adaGempaBaruPending = false; // network task set true saat ada gempa baru;
                                                // matrix (core 1) yang konsumsi di titik aman
volatile bool     gempaBaruAdalahTsunami = false; // true jika gempa baru yg pending berpotensi tsunami
                                                   // (dipakai core1 untuk menentukan lama alert & bukan buzzer,
                                                   //  karena buzzer sudah dibunyikan duluan di core0)

// --- PRIORITAS ALERT UNTUK QUICKLOOK (lastQL) ---
// PERUBAHAN ATAS PERMINTAAN USER: berbeda dari desain awal (lastQL hanya info,
// TIDAK memicu alarm), sekarang setiap ada KIRIMAN DATA BARU dari sumber
// Quick Look (lastQL) akan: (1) membunyikan sirene, dan (2) LANGSUNG diprioritaskan
// tampil di layar matrix, memotong rotasi normal/mode jam - mirip perlakuan
// gempa baru dari autogempa, tapi dengan alert terpisah (adaQuicklookBaruPending)
// supaya tidak tertukar sumbernya dan tetap bisa dibedakan di kode/Serial log.
volatile bool     adaQuicklookBaruPending = false; // network task (core0) set true saat ada entri QL baru;
                                                    // matrix (core1) konsumsi di titik aman lalu prioritaskan tampil

// ------------------- DATA JARAK & KELAYAKAN ALARM (fitur alarm pintar) -------------------
double  gempaLat = 0, gempaLon = 0;      // koordinat gempa TERBARU, dari field "Coordinates" JSON BMKG
float   jarakKm  = -1;                   // jarak (km) gempa terbaru ke LOKASI_LAT/LOKASI_LON (-1 = belum diketahui)
float   gempaMagnitudeTerbaru = 0;       // magnitudo gempa terbaru (angka, hasil atof())
volatile bool gempaBaruLayakAlarmPenuh = false; // true jika gempa baru cukup dekat/besar utk alarm sirene PENUH

// ------------------- CACHE DATA GEMPA (dipakai bareng: matrix & web) -------------------
String   cacheAutogempaJson      = "";   // raw JSON dari BMKG (autogempa) -> diteruskan apa adanya ke web
String   cacheGempaTerkiniJson   = "";   // raw JSON dari BMKG (gempaterkini, M5.0+) -> diteruskan apa adanya ke web
String   cacheGempaDirasakanJson = "";   // raw JSON dari BMKG (gempadirasakan) -> diteruskan apa adanya ke web
String   cacheLastQLJson         = "";   // raw JSON dari sumber tambahan (lastQL, tidak resmi) -> diteruskan apa adanya ke web

String   lastDateTime = "";            // dipakai untuk deteksi gempa BARU
String   lastQLTime   = "";            // dipakai untuk deteksi entri lastQL baru (bukan pemicu alarm, cuma info tampilan)
bool     dataAutogempaSiap    = false; // sudah pernah berhasil ambil data?
bool     dataTerkiniSiap      = false;
bool     dataDirasakanSiap    = false;
bool     dataLastQLSiap        = false; // sumber tambahan (tidak resmi) sudah pernah berhasil ambil data?

unsigned long lastAutogempaPoll   = 0;
unsigned long lastTerkiniPoll     = 0;
unsigned long lastDirasakanPoll   = 0;
unsigned long lastLastQLPoll      = 0;
unsigned long lastAutogempaOkTime = 0; // millis() saat terakhir kali polling autogempa SUKSES
unsigned long lastLastQLOkTime    = 0; // millis() saat terakhir kali polling lastQL SUKSES

// ------------------- LAPORAN GABUNGAN GEMPA M >= 2.0 -------------------
// Digabung dari gempaterkini (M5.0+) + gempadirasakan (magnitudo lebih kecil),
// difilter M >= 2.0, duplikat (DateTime sama) dibuang. Lihat catatan di
// bagian atas file soal keterbatasan cakupan data ini.
#define MAX_LAPORAN_M2 31   // 1 (autogempa) + 15 (gempaterkini M5.0+) + 15 (gempadirasakan), setelah dedup biasanya lebih sedikit
struct GempaRingkas {
  String dateTime;
  String tanggal;
  String jam;
  String magnitude;
  String kedalaman;
  String wilayah;
};
GempaRingkas laporanM2[MAX_LAPORAN_M2];
uint8_t jumlahLaporanM2 = 0;
String  laporanM2Json = "[]";  // hasil gabungan, siap dikirim sebagai JSON ke web

// ------------------- RIWAYAT 200 DATA QUICKLOOK (lastQL) -------------------
// lastQL.json HANYA berisi 1 entri TERBARU tiap kali di-poll (bukan daftar
// historis), jadi untuk membuat "tabel 200 data gempa quicklook" kita HARUS
// menyimpan/akumulasi sendiri tiap entri baru yang lewat selama ESP32 menyala
// (disimpan di RAM, hilang kalau ESP32 restart - bukan dari BMKG sekaligus).
// Entri terbaru selalu ditaruh di indeks 0 (paling atas tabel), minimal M2.0
// sesuai permintaan ("walopun minimal M 2+").
#define MAX_RIWAYAT_QL 200
GempaRingkas riwayatQL[MAX_RIWAYAT_QL];
uint16_t riwayatQLCount = 0;               // jumlah entri quicklook yang sudah tersimpan (maks MAX_RIWAYAT_QL)
String   riwayatQLJson  = "[]";            // hasil riwayat, siap dikirim sebagai JSON ke web

// ------------------- TEKS UNTUK MATRIX -------------------
char ipText[64]      = "Menghubungkan WiFi...";
char gempaText[260]  = "Menunggu data gempa dari BMKG...";              // gempa TERKINI (autogempa)
char laporanText[200] = "Menunggu data laporan M2.0+...";               // ringkasan laporan M2.0+ (dipakai di web/opsional)
char gempaTertinggiText[260]  = "Menunggu data gempa tertinggi...";     // gempa dgn magnitudo TERTINGGI dari laporan gabungan
char gempaDirasakanText[260]  = "Menunggu data gempa dirasakan...";     // gempa DIRASAKAN paling baru
char lastQLText[220] = "Menunggu info cepat (Quick Look, tidak resmi)...";  // sumber tambahan lastQL (INFO SAJA, bukan pemicu alarm)

volatile bool ipTeksPerluTampilLangsung = false; // true sesaat setelah WiFi konek/reconnect -> core1 loncat tampilkan IP asli, tidak nunggu rotasi normal
volatile bool potensiTsunamiAktif = false; // true jika gempa TERBARU berpotensi tsunami (untuk alarm & prioritas khusus)

bool    alertActive      = false;   // sedang mode alert (gempa baru) di matrix? (hanya dipakai core1/loop)
bool    alertIsTsunami   = false;   // alert saat ini dipicu oleh gempa berpotensi TSUNAMI -> lebih lama & diprioritaskan
uint8_t alertRepeatCount = 0;       // (hanya dipakai core1/loop)
const uint8_t ALERT_REPEAT_MAX         = 4;  // gempa biasa: berapa kali teks alert diulang sebelum kembali ke rotasi normal
const uint8_t ALERT_REPEAT_MAX_TSUNAMI = 10; // gempa berpotensi TSUNAMI: diulang lebih lama supaya benar2 diprioritaskan

// Mode alert TERPISAH untuk kiriman data baru dari QUICKLOOK (lastQL). Dibuat
// terpisah dari alertActive (autogempa) supaya sumbernya tidak tertukar, tapi
// perlakuannya sama: memotong rotasi normal/mode jam & tampil berulang.
bool    alertActiveQL      = false; // hanya dipakai core1/loop
uint8_t alertRepeatCountQL = 0;     // hanya dipakai core1/loop
const uint8_t ALERT_REPEAT_MAX_QL = 4; // berapa kali teks quicklook diulang sebelum kembali ke rotasi normal

// Mode tampilan JAM (berjalan ~10 detik, di-SCROLL karena teksnya lebih lebar
// dari layar 32px, lalu lanjut rotasi info gempa)
bool          clockModeActive  = false;
unsigned long clockModeStart   = 0;

// Urutan rotasi normal layar matrix:
// 0=IP, 1=JAM (10 detik), 2=Gempa Terkini, 3=Gempa Tertinggi, 4=Gempa Dirasakan
enum { SCR_IP = 0, SCR_JAM = 1, SCR_GEMPA_TERKINI = 2, SCR_GEMPA_TERTINGGI = 3, SCR_GEMPA_DIRASAKAN = 4, SCR_LASTQL = 5 };
uint8_t screenIndex = 0;
const uint8_t JUMLAH_SCREEN = 6;

// Buffer LOKAL milik core1 (matrix). Ini yang benar-benar dikasih ke
// P.displayText(). Isinya hanya disalin dari buffer "shared" (ipText,
// gempaText, laporanText di atas) pada titik AMAN, yaitu tepat sebelum satu
// siklus scroll baru dimulai -> teks tidak akan berubah/robek di tengah scroll.
char dispIpText[64];
char dispGempaText[260];
char dispLaporanText[200];
char dispGempaTertinggiText[260];
char dispGempaDirasakanText[260];
char dispLastQLText[220];
char dispJamText[32];   // dipakai KHUSUS mode jam, statis, hanya diisi langsung di core1 (tidak perlu mutex)

#ifndef ARRAY_SIZE
#define ARRAY_SIZE(x) (sizeof(x)/sizeof(x[0]))
#endif

// Salin teks shared -> buffer lokal matrix, dilindungi mutex singkat (bukan blocking lama).
void salinTeksUntukMatrix(char *tujuan, size_t ukuran, const char *sumber) {
  if (xSemaphoreTake(xDataMutex, portMAX_DELAY) == pdTRUE) {
    strncpy(tujuan, sumber, ukuran - 1);
    tujuan[ukuran - 1] = '\0';
    xSemaphoreGive(xDataMutex);
  }
}

// =============================================================================
//                      FUNGSI BUZZER
// =============================================================================
void buzzerOn()  { digitalWrite(BUZZER_PIN, BUZZER_ACTIVE_HIGH ? HIGH : LOW); }
void buzzerOff() { digitalWrite(BUZZER_PIN, BUZZER_ACTIVE_HIGH ? LOW  : HIGH); }

// -----------------------------------------------------------------------
// NOTIFIKASI: ESP32 baru saja DINYALAKAN (dipanggil sekali di setup(), sebelum
// WiFi/matrix mulai jalan). Pola singkat 2x "beep" naik nada -> menandakan
// perangkat hidup & siap boot.
// -----------------------------------------------------------------------
void buzzerNotifStartup() {
  if (BUZZER_TYPE_PASSIVE) {
    tone(BUZZER_PIN, 1500); delay(120); noTone(BUZZER_PIN); delay(80);
    tone(BUZZER_PIN, 2200); delay(160); noTone(BUZZER_PIN);
  } else {
    buzzerOn(); delay(100); buzzerOff(); delay(80);
    buzzerOn(); delay(150); buzzerOff();
  }
}

// -----------------------------------------------------------------------
// NOTIFIKASI: WiFi BERHASIL terhubung (dipanggil dari connectWiFi() di CORE 0).
// Pola 3x beep naik nada ("do-mi-sol") -> nada ceria menandakan sukses konek.
// -----------------------------------------------------------------------
void buzzerNotifWifiOk() {
  if (BUZZER_TYPE_PASSIVE) {
    int nada[3] = {1300, 1650, 2000};
    for (int i = 0; i < 3; i++) {
      tone(BUZZER_PIN, nada[i]);
      delay(110);
      noTone(BUZZER_PIN);
      delay(50);
    }
  } else {
    for (int i = 0; i < 3; i++) {
      buzzerOn(); delay(90);
      buzzerOff(); delay(70);
    }
  }
}

// -----------------------------------------------------------------------
// NOTIFIKASI: WiFi GAGAL konek (opsional, dipanggil kalau connectWiFi() habis
// waktu tanpa berhasil) -> 2x beep nada rendah, beda dari notifikasi sukses.
// -----------------------------------------------------------------------
void buzzerNotifWifiGagal() {
  if (BUZZER_TYPE_PASSIVE) {
    tone(BUZZER_PIN, 500); delay(200); noTone(BUZZER_PIN); delay(100);
    tone(BUZZER_PIN, 400); delay(200); noTone(BUZZER_PIN);
  } else {
    buzzerOn(); delay(250); buzzerOff(); delay(120);
    buzzerOn(); delay(250); buzzerOff();
  }
}

// -----------------------------------------------------------------------
// NOTIFIKASI RINGAN: gempa baru terdeteksi tapi JAUH dari lokasi & magnitudonya
// tidak cukup besar untuk alarm penuh -> cukup 2x beep singkat sebagai info,
// TANPA sirene panjang. Tujuannya menghindari "alarm fatigue" (pengguna jadi
// abai/mematikan alarm) untuk kejadian yang kemungkinan besar tidak dirasakan
// di lokasi pengguna. Info lengkapnya tetap tercatat penuh di layar & web.
// -----------------------------------------------------------------------
void buzzerNotifGempaJauh() {
  if (BUZZER_TYPE_PASSIVE) {
    tone(BUZZER_PIN, 1800); delay(150); noTone(BUZZER_PIN); delay(100);
    tone(BUZZER_PIN, 1800); delay(150); noTone(BUZZER_PIN);
  } else {
    buzzerOn(); delay(120); buzzerOff(); delay(100);
    buzzerOn(); delay(120); buzzerOff();
  }
}

// -----------------------------------------------------------------------
// ALARM SIRENE (untuk buzzer PASIF - pakai tone()/noTone(), butuh PWM)
// Pola: beberapa siklus sirene naik-turun (seperti ambulans/alarm gempa),
// ditutup dengan rentetan beep cepat yang tegas. Total durasi ~9-10 detik.
// -----------------------------------------------------------------------
void alarmSirenePasif() {
  const int FREQ_MIN = 700;
  const int FREQ_MAX = 2000;
  const int FREQ_STEP = 25;
  const int SIKLUS_SIRENE = 6;

  for (int s = 0; s < SIKLUS_SIRENE; s++) {
    for (int f = FREQ_MIN; f <= FREQ_MAX; f += FREQ_STEP) {
      tone(BUZZER_PIN, f);
      delay(6);
    }
    for (int f = FREQ_MAX; f >= FREQ_MIN; f -= FREQ_STEP) {
      tone(BUZZER_PIN, f);
      delay(6);
    }
  }
  noTone(BUZZER_PIN);
  delay(200);

  // Rentetan beep cepat penutup, nada tinggi & tegas -> menarik perhatian
  for (int i = 0; i < 14; i++) {
    tone(BUZZER_PIN, 2800);
    delay(110);
    noTone(BUZZER_PIN);
    delay(70);
  }
  noTone(BUZZER_PIN);
}

// -----------------------------------------------------------------------
// ALARM PANJANG (untuk buzzer AKTIF - hanya ON/OFF, tidak bisa ganti nada)
// Pola: bunyi panjang bertahap, cepat -> sedang -> sangat cepat, seperti
// alarm darurat. Total durasi ~9-10 detik.
// -----------------------------------------------------------------------
void alarmPanjangAktif() {
  // Tahap 1: bunyi panjang menyala terus 1.5 detik (menarik perhatian awal)
  buzzerOn();  delay(1500);
  buzzerOff(); delay(250);

  // Tahap 2: beep sedang berulang
  for (int i = 0; i < 8; i++) {
    buzzerOn();  delay(250);
    buzzerOff(); delay(150);
  }
  delay(250);

  // Tahap 3: beep cepat mendesak (makin lama makin cepat)
  int jedaOn = 180, jedaOff = 140;
  for (int i = 0; i < 16; i++) {
    buzzerOn();  delay(jedaOn);
    buzzerOff(); delay(jedaOff);
    if (jedaOn > 60)  jedaOn  -= 8;
    if (jedaOff > 40) jedaOff -= 6;
  }
  delay(250);

  // Tahap 4: bunyi panjang penutup
  buzzerOn(); delay(1200);
  buzzerOff();
}

// Fungsi utama alarm - dipanggil dari kode lain, otomatis pilih pola
// sesuai jenis buzzer yang dikonfigurasi di atas (BUZZER_TYPE_PASSIVE).
void bunyikanAlarmGempa() {
  if (BUZZER_TYPE_PASSIVE) {
    alarmSirenePasif();
  } else {
    alarmPanjangAktif();
  }
}

// -----------------------------------------------------------------------
// ALARM KHUSUS BAHAYA TSUNAMI - dipanggil HANYA kalau gempa baru berpotensi
// tsunami (field "Potensi" dari BMKG mengandung kata "tsunami" tanpa "tidak").
// Dibuat LEBIH PANJANG, LEBIH CEPAT, dan diulang 2x dibanding alarm gempa
// biasa, supaya jelas berbeda & langsung menarik perhatian sebagai peringatan
// paling darurat.
// -----------------------------------------------------------------------
void alarmSireneTsunamiPasif() {
  const int FREQ_MIN = 900;
  const int FREQ_MAX = 2600;
  const int FREQ_STEP = 40;

  for (int putaran = 0; putaran < 2; putaran++) {
    for (int s = 0; s < 8; s++) {
      for (int f = FREQ_MIN; f <= FREQ_MAX; f += FREQ_STEP) { tone(BUZZER_PIN, f); delay(4); }
      for (int f = FREQ_MAX; f >= FREQ_MIN; f -= FREQ_STEP) { tone(BUZZER_PIN, f); delay(4); }
    }
    noTone(BUZZER_PIN);
    delay(150);
    for (int i = 0; i < 20; i++) {
      tone(BUZZER_PIN, 3000);
      delay(90);
      noTone(BUZZER_PIN);
      delay(50);
    }
    delay(200);
  }
  noTone(BUZZER_PIN);
}

void alarmPanjangTsunamiAktif() {
  for (int putaran = 0; putaran < 2; putaran++) {
    buzzerOn();  delay(2000);
    buzzerOff(); delay(200);

    int jedaOn = 200, jedaOff = 130;
    for (int i = 0; i < 26; i++) {
      buzzerOn();  delay(jedaOn);
      buzzerOff(); delay(jedaOff);
      if (jedaOn > 50)  jedaOn  -= 6;
      if (jedaOff > 30) jedaOff -= 4;
    }
    delay(200);
  }
  buzzerOn(); delay(1500);
  buzzerOff();
}

void bunyikanAlarmTsunami() {
  if (BUZZER_TYPE_PASSIVE) {
    alarmSireneTsunamiPasif();
  } else {
    alarmPanjangTsunamiAktif();
  }
}

// =============================================================================
//                 FUNGSI KONEKSI WIFI
// =============================================================================
// CATATAN: fungsi ini jalan di taskJaringan (CORE 0). SENGAJA tidak ada satu
// pun panggilan P.displayXxx()/P.displayAnimate() di sini -> objek matrix (P)
// hanya boleh disentuh dari core1 (loop()), supaya tidak "rebutan" dan bikin
// animasi corrupt/patah. Selagi WiFi konek (blocking, bisa ~20 detik), core1
// tetap bebas nge-scroll teks placeholder "Menghubungkan WiFi..." dengan mulus.
void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

  Serial.print("Menghubungkan ke WiFi");
  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) {
    delay(300);
    Serial.print(".");
  }

  bool berhasilKonek = (WiFi.status() == WL_CONNECTED);

  if (xSemaphoreTake(xDataMutex, portMAX_DELAY) == pdTRUE) {
    if (berhasilKonek) {
      String ip = WiFi.localIP().toString();
      Serial.println("\nWiFi Terhubung: " + ip);
      Serial.println("Buka dari HP: http://" + ip + "/");
      snprintf(ipText, sizeof(ipText), "Buka di HP (WiFi sama): http://%s/ ", ip.c_str());
      ipTeksPerluTampilLangsung = true; // begitu ada IP asli, matrix loncat tampilkan sekarang juga
    } else {
      Serial.println("\nGagal konek WiFi, akan dicoba lagi otomatis.");
      snprintf(ipText, sizeof(ipText), "WiFi belum terhubung... ");
    }
    xSemaphoreGive(xDataMutex);
  }

  if (berhasilKonek) {
    buzzerNotifWifiOk();   // notifikasi suara: WiFi berhasil terhubung
    // Sinkronkan jam via NTP supaya fitur tampilan JAM di matrix akurat.
    configTime(GMT_OFFSET_SEC, DST_OFFSET_SEC, NTP_SERVER_1, NTP_SERVER_2);
  } else {
    buzzerNotifWifiGagal(); // notifikasi suara: WiFi gagal terhubung (akan dicoba lagi otomatis)
  }
}

void cekReconnectWiFiJikaPerlu() {
  if (WiFi.status() != WL_CONNECTED) {
    connectWiFi();
  }
}

// =============================================================================
//     FUNGSI HTTP GET HTTPS (dipakai untuk kedua endpoint BMKG)
//     Mengembalikan true kalau berhasil, isi payload dimasukkan ke "out"
// =============================================================================
bool httpGetBmkg(const char* url, String &out) {
  // Cek heap SEBELUM buka TLS -> kalau sudah mepet, mending skip request ini
  // drpd memaksa alokasi buffer TLS (~40-50KB) yang bisa bikin ESP32 crash/panic.
  uint32_t heapSebelum = ESP.getFreeHeap();
  if (heapSebelum < 30000) {
    Serial.printf("Skip request ke %s: free heap kurang (%u bytes)\n", url, heapSebelum);
    return false;
  }

  WiFiClientSecure client;
  client.setInsecure();          // BMKG pakai HTTPS publik, skip validasi CA untuk simpel
  client.setTimeout(HTTP_TIMEOUT_MS / 1000);

  HTTPClient http;
  http.setTimeout(HTTP_TIMEOUT_MS);
  http.setReuse(false);          // JANGAN keep-alive: pastikan socket/TLS session ditutup bersih tiap request
  if (!http.begin(client, url)) {
    Serial.println("HTTP begin gagal");
    return false;
  }
  http.addHeader("Cache-Control", "no-cache");
  http.addHeader("Connection", "close");
  http.addHeader("User-Agent", "ESP32-InfoGempaBMKG/1.0"); // sebagian server suka menolak request tanpa User-Agent

  int httpCode = http.GET();
  bool ok = false;

  if (httpCode == HTTP_CODE_OK) {
    out = http.getString();
    ok = true;
  } else {
    Serial.printf("HTTP GET gagal (%s), kode: %d, free heap: %u -> %u\n",
                  url, httpCode, heapSebelum, ESP.getFreeHeap());
  }

  http.end();
  client.stop();   // pastikan koneksi TCP/TLS benar2 ditutup & buffernya dilepas
  return ok;
}

// =============================================================================
//     BANGUN TEKS INFO GEMPA UNTUK MATRIX DARI JSON autogempa.json
// =============================================================================
// Cek apakah field "Potensi" dari BMKG menandakan bahaya TSUNAMI.
// BMKG biasanya mengisi "Berpotensi tsunami" atau "Tidak berpotensi tsunami",
// jadi kita cek kata "tsunami" ADA tapi kata "tidak" TIDAK ADA.
bool potensiMenandakanTsunami(const char* potensi) {
  String p = String(potensi);
  p.toLowerCase();
  return (p.indexOf("tsunami") >= 0) && (p.indexOf("tidak") < 0);
}

// =============================================================================
//   HITUNG JARAK (KM) ANTAR DUA KOORDINAT - RUMUS HAVERSINE
//   Dipakai untuk menilai seberapa relevan sebuah gempa terhadap lokasi
//   pengguna (LOKASI_LAT/LOKASI_LON) -> dasar dari alarm pintar berbasis jarak.
// =============================================================================
double hitungJarakKm(double lat1, double lon1, double lat2, double lon2) {
  const double R = 6371.0; // radius bumi (km)
  double dLat = (lat2 - lat1) * M_PI / 180.0;
  double dLon = (lon2 - lon1) * M_PI / 180.0;
  lat1 = lat1 * M_PI / 180.0;
  lat2 = lat2 * M_PI / 180.0;

  double a = sin(dLat / 2) * sin(dLat / 2) +
             cos(lat1) * cos(lat2) * sin(dLon / 2) * sin(dLon / 2);
  double c = 2 * atan2(sqrt(a), sqrt(1 - a));
  return R * c;
}

// Ambil koordinat episentrum dari field "Coordinates" JSON BMKG (format "lat,lon",
// contoh "-6.20,106.82"), lalu hitung jaraknya ke lokasi pengguna. Hasil disimpan
// ke variabel global gempaLat/gempaLon/jarakKm. jarakKm = -1 jika parsing gagal.
void hitungJarakDariKoordinat(const char* coordStr) {
  gempaLat = 0; gempaLon = 0; jarakKm = -1;
  if (coordStr == nullptr || strlen(coordStr) == 0) return;

  char buf[32];
  strncpy(buf, coordStr, sizeof(buf) - 1);
  buf[sizeof(buf) - 1] = '\0';

  char* komaPos = strchr(buf, ',');
  if (komaPos == nullptr) return;   // format tak dikenali, biarkan jarakKm = -1

  *komaPos = '\0';
  gempaLat = atof(buf);
  gempaLon = atof(komaPos + 1);
  jarakKm  = (float) hitungJarakKm(LOKASI_LAT, LOKASI_LON, gempaLat, gempaLon);
}

void bangunTeksGempaDariJson(const String &payload, bool &adaGempaBaru, bool &adaTsunamiBaru) {
  adaGempaBaru    = false;
  adaTsunamiBaru  = false;

  DynamicJsonDocument doc(4096);
  DeserializationError err = deserializeJson(doc, payload);
  if (err) {
    Serial.print("Gagal parsing JSON autogempa: ");
    Serial.println(err.c_str());
    return;
  }

  JsonObject gempa = doc["Infogempa"]["gempa"];

  const char* tanggal   = gempa["Tanggal"]   | "-";
  const char* jam       = gempa["Jam"]       | "-";
  const char* dateTime  = gempa["DateTime"]  | "-";
  const char* magnitude = gempa["Magnitude"] | "-";
  const char* kedalaman = gempa["Kedalaman"] | "-";
  const char* wilayah   = gempa["Wilayah"]   | "-";
  const char* potensi   = gempa["Potensi"]   | "-";
  const char* dirasakan = gempa["Dirasakan"] | "-";
  const char* koordinat = gempa["Coordinates"] | "";

  bool tsunami = potensiMenandakanTsunami(potensi);
  potensiTsunamiAktif = tsunami;

  // Hitung jarak episentrum ke lokasi pengguna & tentukan kelayakan alarm PENUH.
  hitungJarakDariKoordinat(koordinat);
  float magFloat = atof(magnitude);
  gempaMagnitudeTerbaru = magFloat;
  gempaBaruLayakAlarmPenuh =
      (magFloat >= MAGNITUDO_ALARM_PENUH) ||
      (jarakKm >= 0 && jarakKm <= JARAK_ALARM_PENUH_KM && magFloat >= MAGNITUDO_MINIMAL_CATAT);

  String currentDateTime = String(dateTime);
  if (currentDateTime.length() > 0 && currentDateTime != lastDateTime) {
    // Baseline pertama kali (lastDateTime masih kosong) TIDAK dianggap gempa baru,
    // supaya tidak bunyi alarm begitu ESP32 baru nyala.
    if (lastDateTime.length() > 0) {
      adaGempaBaru   = true;
      adaTsunamiBaru = tsunami;
    }
    lastDateTime = currentDateTime;
  }

  char teksJarak[32];
  if (jarakKm >= 0) {
    snprintf(teksJarak, sizeof(teksJarak), "~%.0f KM DR ANDA", jarakKm);
  } else {
    snprintf(teksJarak, sizeof(teksJarak), "JARAK TDK DIKETAHUI");
  }

  if (xSemaphoreTake(xDataMutex, portMAX_DELAY) == pdTRUE) {
    if (tsunami) {
      snprintf(gempaText, sizeof(gempaText),
        "!!! WASPADA TSUNAMI !!!  GEMPA M%s  WILAYAH: %s (%s)  KEDALAMAN %s  TGL %s JAM %s WIB  POTENSI: %s  DIRASAKAN: %s   ",
        magnitude, wilayah, teksJarak, kedalaman, tanggal, jam, potensi, dirasakan);
    } else {
      snprintf(gempaText, sizeof(gempaText),
        "GEMPA M%s  WILAYAH: %s (%s)  KEDALAMAN %s  TGL %s JAM %s WIB  POTENSI: %s  DIRASAKAN: %s   ",
        magnitude, wilayah, teksJarak, kedalaman, tanggal, jam, potensi, dirasakan);
    }
    xSemaphoreGive(xDataMutex);
  }
}

// =============================================================================
//     POLLING autogempa.json (gempa terbaru) -> update cache + teks matrix
// =============================================================================
void pollAutogempa() {
  cekReconnectWiFiJikaPerlu();
  if (WiFi.status() != WL_CONNECTED) return;

  String payload;
  if (httpGetBmkg(URL_AUTOGEMPA, payload)) {
    cacheAutogempaJson = payload;
    dataAutogempaSiap  = true;
    lastAutogempaOkTime = millis();

    bool adaGempaBaru   = false;
    bool adaTsunamiBaru = false;
    bangunTeksGempaDariJson(payload, adaGempaBaru, adaTsunamiBaru);

    if (adaGempaBaru) {
      Serial.printf("Jarak dari lokasi Anda: %s | Magnitudo: %.1f | Alarm penuh: %s\n",
                    (jarakKm >= 0) ? (String(jarakKm, 1) + " km").c_str() : "tidak diketahui",
                    gempaMagnitudeTerbaru, gempaBaruLayakAlarmPenuh ? "YA" : "TIDAK");

      // --- ALARM PINTAR 3 TINGKAT (berbasis jarak & magnitudo) ---
      if (adaTsunamiBaru) {
        // Tingkat 1: TSUNAMI -> selalu alarm sirene PENUH, apapun jaraknya.
        Serial.println("=== !!! GEMPA BARU BERPOTENSI TSUNAMI !!! ===");
        Serial.println(gempaText);
        gempaBaruAdalahTsunami = true;
        bunyikanAlarmTsunami();        // alarm KHUSUS bahaya, lebih panjang & mendesak
      } else if (gempaBaruLayakAlarmPenuh) {
        // Tingkat 2: cukup DEKAT atau cukup BESAR -> alarm sirene PENUH.
        Serial.println("=== GEMPA BARU TERDETEKSI (dekat/besar - ALARM PENUH) ===");
        Serial.println(gempaText);
        gempaBaruAdalahTsunami = false;
        bunyikanAlarmGempa();          // alarm gempa biasa, keras & penuh
      } else if (gempaMagnitudeTerbaru >= MAGNITUDO_MINIMAL_CATAT) {
        // Tingkat 3: jauh tapi masih cukup berarti utk dicatat -> notif ringan saja.
        Serial.println("=== GEMPA BARU TERDETEKSI (jauh - notifikasi ringan) ===");
        Serial.println(gempaText);
        gempaBaruAdalahTsunami = false;
        buzzerNotifGempaJauh();        // 2x beep singkat, tidak mengganggu spt sirene penuh
      } else {
        // Tingkat 4: magnitudo sangat kecil -> tanpa bunyi sama sekali, cukup tercatat.
        Serial.println("=== GEMPA BARU TERDETEKSI (magnitudo kecil - tanpa bunyi) ===");
        Serial.println(gempaText);
        gempaBaruAdalahTsunami = false;
      }
      adaGempaBaruPending = true;      // core1 (loop) tetap tampilkan info di matrix utk SEMUA gempa baru
    }
  }
}

// =============================================================================
//     POLLING gempaterkini.json (15 gempa M5.0+) -> update cache untuk web
// =============================================================================
void pollGempaTerkini() {
  cekReconnectWiFiJikaPerlu();
  if (WiFi.status() != WL_CONNECTED) return;

  String payload;
  if (httpGetBmkg(URL_GEMPATERKINI, payload)) {
    cacheGempaTerkiniJson = payload;
    dataTerkiniSiap = true;
  }
}

// =============================================================================
//     POLLING gempadirasakan.json (15 gempa dirasakan) -> update cache untuk web
// =============================================================================
// Bangun teks utk matrix dari gempa DIRASAKAN paling baru (elemen pertama array).
void bangunTeksGempaDirasakanTerbaru() {
  if (!dataDirasakanSiap || cacheGempaDirasakanJson.length() == 0) return;

  DynamicJsonDocument doc(8192);
  if (deserializeJson(doc, cacheGempaDirasakanJson)) return;

  JsonArray arr = doc["Infogempa"]["gempa"].as<JsonArray>();
  if (arr.size() == 0) return;

  JsonObject g = arr[0];
  const char* tanggal   = g["Tanggal"]   | "-";
  const char* jam       = g["Jam"]       | "-";
  const char* magnitude = g["Magnitude"] | "-";
  const char* kedalaman = g["Kedalaman"] | "-";
  const char* wilayah   = g["Wilayah"]   | "-";
  const char* dirasakan = g["Dirasakan"] | "-";

  if (xSemaphoreTake(xDataMutex, portMAX_DELAY) == pdTRUE) {
    snprintf(gempaDirasakanText, sizeof(gempaDirasakanText),
      "GEMPA DIRASAKAN M%s  WILAYAH: %s  KEDALAMAN %s  TGL %s JAM %s WIB  SKALA: %s   ",
      magnitude, wilayah, kedalaman, tanggal, jam, dirasakan);
    xSemaphoreGive(xDataMutex);
  }
}

void pollGempaDirasakan() {
  cekReconnectWiFiJikaPerlu();
  if (WiFi.status() != WL_CONNECTED) return;

  String payload;
  if (httpGetBmkg(URL_GEMPADIRASAKAN, payload)) {
    cacheGempaDirasakanJson = payload;
    dataDirasakanSiap = true;
    bangunTeksGempaDirasakanTerbaru();
  }
}

// =============================================================================
//   SUMBER TAMBAHAN (TIDAK RESMI): lastQL.json - info "Quick Look" InaTEWS
//   Formatnya GeoJSON, BEDA TOTAL dari autogempa.json/gempaterkini.json, jadi
//   parsing-nya dipisah sendiri di sini. HANYA untuk info tampilan (matrix &
//   web) - TIDAK dipakai memicu alarm buzzer maupun masuk ke laporan M2.0+,
//   karena sumbernya belum tentu identik/tersinkron dgn data.bmkg.go.id dan
//   belum tentu sudah terverifikasi (masih "Quick Look" / deteksi otomatis).
// =============================================================================
// -----------------------------------------------------------------------
// Bangun ulang JSON riwayat quicklook (dipanggil setiap kali ada entri baru
// ditambahkan). Format sama seperti laporanM2Json supaya mudah dipakai ulang
// di halaman web (tabel dgn kolom Tanggal/Jam/Magnitude/Kedalaman/Wilayah).
// -----------------------------------------------------------------------
void bangunRiwayatQLJson() {
  String json = "{\"sumber\":\"lastQL (Quick Look, TIDAK RESMI, belum tentu final) - diakumulasi ESP32 sejak nyala\",\"jumlah\":";
  json += String(riwayatQLCount);
  json += ",\"data\":[";
  json.reserve(riwayatQLCount * 110 + 128);
  for (uint16_t i = 0; i < riwayatQLCount; i++) {
    if (i > 0) json += ",";
    json += "{\"Tanggal\":\""   + riwayatQL[i].tanggal   + "\",";
    json += "\"Jam\":\""        + riwayatQL[i].jam       + "\",";
    json += "\"DateTime\":\""   + riwayatQL[i].dateTime  + "\",";
    json += "\"Magnitude\":\""  + riwayatQL[i].magnitude + "\",";
    json += "\"Kedalaman\":\""  + riwayatQL[i].kedalaman + "\",";
    json += "\"Wilayah\":\""    + riwayatQL[i].wilayah   + "\"}";
  }
  json += "]}";
  riwayatQLJson = json;
}

// -----------------------------------------------------------------------
// Tambahkan satu entri quicklook baru ke riwayat (paling atas / indeks 0).
// Difilter minimal M2.0 (sesuai permintaan: "walopun minimal M 2+" tetap
// dicatat, di bawah itu diabaikan supaya tabel tidak penuh micro-tremor).
// Kalau sudah penuh (MAX_RIWAYAT_QL), entri paling lama otomatis "terdorong
// keluar" dari tabel (FIFO sederhana lewat pergeseran array).
// -----------------------------------------------------------------------
void tambahKeRiwayatQL(const char* waktuUtc, const char* place, float mag, float depth) {
  if (mag < 2.0) return;                 // filter minimal M2.0
  String dt = String(waktuUtc);
  if (dt.length() == 0) return;
  if (riwayatQLCount > 0 && riwayatQL[0].dateTime == dt) return; // sudah tercatat, hindari duplikat

  // Geser semua entri turun 1 posisi (buang yang paling lama kalau sudah penuh)
  uint16_t batas = (riwayatQLCount < MAX_RIWAYAT_QL) ? riwayatQLCount : (MAX_RIWAYAT_QL - 1);
  for (uint16_t i = batas; i > 0; i--) {
    riwayatQL[i] = riwayatQL[i - 1];
  }

  // Pecah "YYYY-MM-DD HH:MM:SS.ffffff" (UTC) jadi tanggal & jam terpisah
  String tanggal = dt, jam = "-";
  int spasi = dt.indexOf(' ');
  if (spasi > 0) {
    tanggal = dt.substring(0, spasi);
    String sisaJam = dt.substring(spasi + 1);
    int titik = sisaJam.indexOf('.');
    jam = (titik > 0) ? sisaJam.substring(0, titik) : sisaJam;
  }

  char magBuf[8];   snprintf(magBuf,   sizeof(magBuf),   "%.1f", mag);
  char depthBuf[8]; snprintf(depthBuf, sizeof(depthBuf), "%.0f", depth);

  riwayatQL[0].dateTime   = dt;
  riwayatQL[0].tanggal    = tanggal + " (UTC)";
  riwayatQL[0].jam        = jam;
  riwayatQL[0].magnitude  = String(magBuf);
  riwayatQL[0].kedalaman  = String(depthBuf);
  riwayatQL[0].wilayah    = String(place);

  if (riwayatQLCount < MAX_RIWAYAT_QL) riwayatQLCount++;
  bangunRiwayatQLJson();
}

void bangunTeksLastQL(const String &payload, bool &adaQLBaru) {
  adaQLBaru = false;
  DynamicJsonDocument doc(1024);
  DeserializationError err = deserializeJson(doc, payload);
  if (err) {
    Serial.print("Gagal parsing JSON lastQL (sumber tambahan, tidak resmi): ");
    Serial.println(err.c_str());
    return;
  }

  JsonArray features = doc["features"].as<JsonArray>();
  if (features.isNull() || features.size() == 0) return;

  JsonObject f  = features[0];
  JsonObject pr = f["properties"];
  if (pr.isNull()) return;

  const char* waktuUtc = pr["time"]  | "-";   // format: "YYYY-MM-DD HH:MM:SS.ffffff", dalam UTC (BUKAN WIB)
  const char* place    = pr["place"] | "-";
  float magFloat        = atof((const char*)(pr["mag"]   | "0"));
  float depthFloat       = atof((const char*)(pr["depth"] | "0"));

  // Deteksi entri BARU dari quicklook (waktu berbeda dari sebelumnya).
  String currentTime = String(waktuUtc);
  bool entriBaru = (currentTime.length() > 0 && currentTime != lastQLTime);
  if (entriBaru) {
    bool iniBaseline = (lastQLTime.length() == 0); // baseline pertama saat boot -> jangan bunyi alarm
    lastQLTime = currentTime;

    // Catat ke tabel riwayat 200 data (baseline pertama tetap dicatat supaya
    // langsung ada isinya di tabel, hanya alarmnya saja yang di-skip).
    tambahKeRiwayatQL(waktuUtc, place, magFloat, depthFloat);

    // PERMINTAAN USER: bunyikan suara & prioritaskan tampilan matrix setiap
    // ada KIRIMAN DATA TERBARU dari quicklook (bukan baseline saat boot),
    // tetap dijaga minimal M2.0 supaya tidak berisik untuk micro-tremor.
    if (!iniBaseline && magFloat >= MAGNITUDO_MINIMAL_CATAT) {
      Serial.println("=== QUICKLOOK: ada kiriman data baru (belum tentu terverifikasi) ===");
      adaQLBaru = true;
    } else if (!iniBaseline) {
      Serial.println("=== QUICKLOOK: entri baru terdeteksi, magnitudo kecil - tanpa bunyi ===");
    }
  }

  if (xSemaphoreTake(xDataMutex, portMAX_DELAY) == pdTRUE) {
    snprintf(lastQLText, sizeof(lastQLText),
      "INFO CEPAT (TIDAK RESMI) M%.1f  %s  KEDALAMAN %.0f KM  WAKTU %s UTC  *belum tentu final*   ",
      magFloat, place, depthFloat, waktuUtc);
    xSemaphoreGive(xDataMutex);
  }
}

void pollLastQL() {
  if (!LASTQL_AKTIF) return;
  cekReconnectWiFiJikaPerlu();
  if (WiFi.status() != WL_CONNECTED) return;

  String payload;
  if (httpGetBmkg(URL_LASTQL, payload)) {
    cacheLastQLJson = payload;
    dataLastQLSiap  = true;
    lastLastQLOkTime = millis();

    bool adaQLBaru = false;
    bangunTeksLastQL(payload, adaQLBaru);

    if (adaQLBaru) {
      // Kiriman data terbaru dari quicklook -> bunyikan sirene & minta core1
      // memprioritaskan tampilannya di matrix, memotong rotasi/jam yg sedang jalan.
      Serial.println(lastQLText);
      bunyikanAlarmGempa();            // sirene penuh (sama seperti alarm gempa biasa)
      adaQuicklookBaruPending = true;  // core1 (loop) akan konsumsi ini di titik aman
    }
  }
}

// =============================================================================
//     BANGUN LAPORAN GABUNGAN GEMPA M >= 2.0
//     (gabung gempaterkini M5.0+ dan gempadirasakan, filter M>=2.0, buang duplikat)
// =============================================================================
void tambahKeLaporanJikaMemenuhi(JsonObject g) {
  if (jumlahLaporanM2 >= MAX_LAPORAN_M2) return;

  const char* dateTime  = g["DateTime"]  | "";
  const char* magnitude = g["Magnitude"] | "0";
  if (strlen(dateTime) == 0) return;

  float mag = atof(magnitude);
  if (mag < 2.0) return;   // filter minimal M2.0

  // Cek duplikat berdasarkan DateTime (gempa yang sama bisa muncul di 2 daftar)
  for (uint8_t i = 0; i < jumlahLaporanM2; i++) {
    if (laporanM2[i].dateTime == dateTime) return; // sudah ada, skip
  }

  laporanM2[jumlahLaporanM2].dateTime  = String(dateTime);
  laporanM2[jumlahLaporanM2].tanggal   = String((const char*)(g["Tanggal"]   | "-"));
  laporanM2[jumlahLaporanM2].jam       = String((const char*)(g["Jam"]       | "-"));
  laporanM2[jumlahLaporanM2].magnitude = String(magnitude);
  laporanM2[jumlahLaporanM2].kedalaman = String((const char*)(g["Kedalaman"] | "-"));
  laporanM2[jumlahLaporanM2].wilayah   = String((const char*)(g["Wilayah"]   | "-"));
  jumlahLaporanM2++;
}

void bangunLaporanGabunganM2() {
  jumlahLaporanM2 = 0;

  // 1) Ambil dari autogempa (gempa PALING baru, semua magnitudo) supaya kejadian
  //    yang baru saja terjadi (belum tentu masuk ke daftar 15 gempaterkini/gempadirasakan) tetap tercakup
  if (dataAutogempaSiap && cacheAutogempaJson.length() > 0) {
    DynamicJsonDocument doc(4096);
    if (!deserializeJson(doc, cacheAutogempaJson)) {
      JsonObject g = doc["Infogempa"]["gempa"];
      if (!g.isNull()) tambahKeLaporanJikaMemenuhi(g);
    }
  }

  // 2) Ambil dari gempaterkini (15 gempa M5.0+)
  if (dataTerkiniSiap && cacheGempaTerkiniJson.length() > 0) {
    DynamicJsonDocument doc(8192);
    if (!deserializeJson(doc, cacheGempaTerkiniJson)) {
      JsonArray arr = doc["Infogempa"]["gempa"].as<JsonArray>();
      for (JsonObject g : arr) tambahKeLaporanJikaMemenuhi(g);
    }
  }

  // 3) Lalu tambahkan dari gempadirasakan (magnitudo lebih kecil, sering M2-M4)
  if (dataDirasakanSiap && cacheGempaDirasakanJson.length() > 0) {
    DynamicJsonDocument doc(8192);
    if (!deserializeJson(doc, cacheGempaDirasakanJson)) {
      JsonArray arr = doc["Infogempa"]["gempa"].as<JsonArray>();
      for (JsonObject g : arr) tambahKeLaporanJikaMemenuhi(g);
    }
  }

  // Urutkan dari terbaru ke terlama (insertion sort sederhana, data sedikit jadi ringan)
  for (uint8_t i = 1; i < jumlahLaporanM2; i++) {
    GempaRingkas tmp = laporanM2[i];
    int j = i - 1;
    while (j >= 0 && laporanM2[j].dateTime < tmp.dateTime) {
      laporanM2[j + 1] = laporanM2[j];
      j--;
    }
    laporanM2[j + 1] = tmp;
  }

  // Bangun JSON hasil gabungan untuk endpoint web
  String json = "{\"sumber\":\"gabungan autogempa + gempaterkini + gempadirasakan (BMKG), filter M>=2.0\",\"jumlah\":";
  json += String(jumlahLaporanM2);
  json += ",\"data\":[";
  for (uint8_t i = 0; i < jumlahLaporanM2; i++) {
    if (i > 0) json += ",";
    json += "{\"Tanggal\":\""   + laporanM2[i].tanggal   + "\",";
    json += "\"Jam\":\""        + laporanM2[i].jam       + "\",";
    json += "\"DateTime\":\""   + laporanM2[i].dateTime  + "\",";
    json += "\"Magnitude\":\""  + laporanM2[i].magnitude + "\",";
    json += "\"Kedalaman\":\""  + laporanM2[i].kedalaman + "\",";
    json += "\"Wilayah\":\""    + laporanM2[i].wilayah   + "\"}";
  }
  json += "]}";
  laporanM2Json = json;

  // Update teks ringkas untuk matrix: jumlah total + magnitudo terbesar dalam laporan
  float magTerbesar = 0;
  int8_t idxTerbesar = -1;
  for (uint8_t i = 0; i < jumlahLaporanM2; i++) {
    float m = laporanM2[i].magnitude.toFloat();
    if (m > magTerbesar) { magTerbesar = m; idxTerbesar = i; }
  }
  if (xSemaphoreTake(xDataMutex, portMAX_DELAY) == pdTRUE) {
    snprintf(laporanText, sizeof(laporanText),
      "LAPORAN M2.0+: %d gempa terpantau  (gabungan data M5.0+ & dirasakan BMKG)  MAGNITUDO TERTINGGI: %.1f   ",
      jumlahLaporanM2, magTerbesar);

    // Teks detail gempa dengan magnitudo TERTINGGI dari gabungan laporan M2.0+ (utk layar "Gempa Tertinggi")
    if (idxTerbesar >= 0) {
      snprintf(gempaTertinggiText, sizeof(gempaTertinggiText),
        "GEMPA TERTINGGI (dr %d laporan M2.0+): M%s  WILAYAH: %s  KEDALAMAN %s  TGL %s JAM %s WIB   ",
        jumlahLaporanM2, laporanM2[idxTerbesar].magnitude.c_str(), laporanM2[idxTerbesar].wilayah.c_str(),
        laporanM2[idxTerbesar].kedalaman.c_str(), laporanM2[idxTerbesar].tanggal.c_str(), laporanM2[idxTerbesar].jam.c_str());
    } else {
      snprintf(gempaTertinggiText, sizeof(gempaTertinggiText), "Belum ada data gempa tertinggi...   ");
    }
    xSemaphoreGive(xDataMutex);
  }
}

// =============================================================================
//                       HALAMAN HTML (disimpan di flash)
// =============================================================================
const char INDEX_HTML[] PROGMEM = R"HTMLPAGE(
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Info Gempa BMKG Realtime - ESP32</title>
<style>
  :root{
    --bg:#0f1420; --card:#161d2e; --accent:#ff5252; --accent2:#2b8aff;
    --text:#eef1f7; --muted:#8a93a6; --ok:#2ecf76; --warn:#ffb020;
  }
  *{box-sizing:border-box;}
  body{
    margin:0; font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;
    background:var(--bg); color:var(--text); padding:14px;
  }
  header{margin-bottom:14px;}
  h1{font-size:1.25rem; margin:0 0 4px 0;}
  .sub{color:var(--muted); font-size:0.85rem;}
  .badge{
    display:inline-block; padding:2px 8px; border-radius:20px;
    font-size:0.72rem; background:#1e2740; color:var(--muted); margin-top:6px;
  }
  .badge.live{color:var(--ok); border:1px solid var(--ok);}
  .card{
    background:var(--card); border-radius:14px; padding:16px; margin-bottom:16px;
    box-shadow:0 2px 10px rgba(0,0,0,0.25);
  }
  .card h2{margin:0 0 10px 0; font-size:1rem; color:var(--muted); font-weight:600;}
  .mag{font-size:2.4rem; font-weight:700; color:var(--accent);}
  .row{display:flex; flex-wrap:wrap; gap:10px 20px; margin-top:8px;}
  .row div{min-width:130px;}
  .label{color:var(--muted); font-size:0.75rem; text-transform:uppercase; letter-spacing:.03em;}
  .val{font-size:0.98rem; margin-top:2px;}
  .tsunami{color:var(--warn); font-weight:700;}
  table{width:100%; border-collapse:collapse; font-size:0.85rem;}
  th,td{text-align:left; padding:8px 6px; border-bottom:1px solid #232c42;}
  th{color:var(--muted); font-weight:600; font-size:0.75rem; text-transform:uppercase;}
  tr:last-child td{border-bottom:none;}
  .mchip{
    display:inline-block; min-width:34px; text-align:center; padding:2px 6px;
    border-radius:6px; font-weight:700; background:#26314d;
  }
  .mchip.hi{background:#4a1f28; color:#ff8a8a;}
  footer{color:var(--muted); font-size:0.72rem; text-align:center; margin-top:20px;}
  a{color:var(--accent2);}
</style>
</head>
<body>

<header>
  <h1>&#128165; Info Gempa BMKG &mdash; Realtime</h1>
  <div class="sub">Sumber data resmi: data.bmkg.go.id (via ESP32) &bull; ESP32 IP: <b id="espIp">-</b></div>
  <span class="badge live" id="statusBadge">&#9679; menghubungkan...</span>
</header>

<div class="card">
  <h2>Gempa Terkini</h2>
  <div class="mag" id="magVal">-</div>
  <div class="row">
    <div><div class="label">Wilayah</div><div class="val" id="wilayahVal">-</div></div>
    <div><div class="label">Waktu</div><div class="val" id="waktuVal">-</div></div>
    <div><div class="label">Kedalaman</div><div class="val" id="kedalamanVal">-</div></div>
    <div><div class="label">Dirasakan</div><div class="val" id="dirasakanVal">-</div></div>
    <div><div class="label">Potensi</div><div class="val" id="potensiVal">-</div></div>
    <div><div class="label">Jarak dari Lokasi Anda</div><div class="val" id="jarakVal">-</div></div>
  </div>
</div>

<div class="card">
  <h2>Info Cepat &mdash; Quick Look <span style="color:#8a93a6; font-weight:400; font-size:0.75rem;">(sumber tambahan, TIDAK RESMI, belum tentu final)</span></h2>
  <div class="row">
    <div><div class="label">Magnitudo</div><div class="val" id="qlMagVal">-</div></div>
    <div><div class="label">Lokasi</div><div class="val" id="qlPlaceVal">-</div></div>
    <div><div class="label">Kedalaman</div><div class="val" id="qlDepthVal">-</div></div>
    <div><div class="label">Waktu (UTC)</div><div class="val" id="qlTimeVal">-</div></div>
  </div>
</div>

<div class="card">
  <h2>15 Gempa Terakhir (M5.0+)</h2>
  <table>
    <thead>
      <tr><th>Waktu</th><th>Mag</th><th>Kedalaman</th><th>Wilayah</th></tr>
    </thead>
    <tbody id="tabelGempa">
      <tr><td colspan="4">Memuat data...</td></tr>
    </tbody>
  </table>
</div>

<div class="card">
  <h2>Laporan Gempa M &ge; 2.0 <span id="jumlahLaporan" style="color:#8a93a6; font-weight:400;"></span></h2>
  <div class="sub" style="margin-bottom:10px;">
    Gabungan data resmi BMKG: gempa terbaru, 15 gempa M5.0+, dan 15 gempa dirasakan &mdash; semuanya disaring minimal M2.0 dan ditampilkan lengkap di tabel ini (tanpa dipotong).
    BMKG tidak menyediakan API terbuka untuk seluruh gempa M2.0+ se-Indonesia,
    jadi cakupan laporan ini terbatas pada gabungan kedua daftar tsb.
  </div>
  <table>
    <thead>
      <tr><th>Waktu</th><th>Mag</th><th>Kedalaman</th><th>Wilayah</th></tr>
    </thead>
    <tbody id="tabelLaporanM2">
      <tr><td colspan="4">Memuat data...</td></tr>
    </tbody>
  </table>
</div>

<div class="card">
  <h2>Tabel 200 Data Gempa &mdash; Quick Look <span id="jumlahRiwayatQL" style="color:#8a93a6; font-weight:400;"></span></h2>
  <div class="sub" style="margin-bottom:10px;">
    Semua laporan dari sumber tambahan Quick Look (lastQL, <b>TIDAK RESMI</b>, belum tentu final/terverifikasi),
    diakumulasi otomatis oleh ESP32 selama menyala (maks 200 data terbaru, minimal M2.0). Waktu ditampilkan dalam UTC.
    Data ini hilang jika ESP32 di-restart.
  </div>
  <table>
    <thead>
      <tr><th>Waktu (UTC)</th><th>Mag</th><th>Kedalaman</th><th>Wilayah</th></tr>
    </thead>
    <tbody id="tabelRiwayatQL">
      <tr><td colspan="4">Memuat data...</td></tr>
    </tbody>
  </table>
</div>

<footer>
  Data disegarkan otomatis tiap 3 detik &bull; ESP32 &amp; BMKG Open Data
</footer>

<script>
  document.getElementById('espIp').textContent = location.host;

  function setBadge(ok){
    const b = document.getElementById('statusBadge');
    if(ok){ b.textContent = '\u25CF live'; b.classList.add('live'); }
    else{ b.textContent = '\u25CF terputus, mencoba lagi...'; b.classList.remove('live'); }
  }

  async function muatGempaTerbaru(){
    try{
      const res = await fetch('/api/autogempa?t=' + Date.now());
      const data = await res.json();
      const g = data.Infogempa.gempa;
      document.getElementById('magVal').textContent = 'M ' + g.Magnitude;
      document.getElementById('wilayahVal').textContent = g.Wilayah;
      document.getElementById('waktuVal').textContent = g.Tanggal + ' ' + g.Jam;
      document.getElementById('kedalamanVal').textContent = g.Kedalaman;
      document.getElementById('dirasakanVal').textContent = g.Dirasakan || '-';
      const potEl = document.getElementById('potensiVal');
      potEl.textContent = g.Potensi;
      potEl.className = (g.Potensi && g.Potensi.toLowerCase().includes('tsunami')) ? 'val tsunami' : 'val';
      setBadge(true);
    }catch(e){
      setBadge(false);
    }
  }

  function isiTabel(tbodyId, list){
    const tbody = document.getElementById(tbodyId);
    tbody.innerHTML = '';
    if (!list || list.length === 0) {
      tbody.innerHTML = '<tr><td colspan="4">Belum ada data.</td></tr>';
      return;
    }
    list.forEach(g => {
      const mag = parseFloat(g.Magnitude);
      const chipClass = (mag >= 5) ? 'mchip hi' : 'mchip';
      const tr = document.createElement('tr');
      tr.innerHTML =
        '<td>' + g.Tanggal + '<br><span style="color:#8a93a6">' + g.Jam + '</span></td>' +
        '<td><span class="' + chipClass + '">' + g.Magnitude + '</span></td>' +
        '<td>' + g.Kedalaman + '</td>' +
        '<td>' + g.Wilayah + '</td>';
      tbody.appendChild(tr);
    });
  }

  async function muatGempaTerkini(){
    try{
      const res = await fetch('/api/gempaterkini?t=' + Date.now());
      const data = await res.json();
      isiTabel('tabelGempa', data.Infogempa.gempa);
    }catch(e){
      setBadge(false);
    }
  }

  async function muatLaporanM2(){
    try{
      const res = await fetch('/api/laporan-m2?t=' + Date.now());
      const data = await res.json();
      isiTabel('tabelLaporanM2', data.data);
      document.getElementById('jumlahLaporan').textContent = '(' + data.jumlah + ' kejadian)';
    }catch(e){
      setBadge(false);
    }
  }

  async function muatRiwayatQL(){
    try{
      const res = await fetch('/api/lastql-riwayat?t=' + Date.now());
      const data = await res.json();
      isiTabel('tabelRiwayatQL', data.data);
      document.getElementById('jumlahRiwayatQL').textContent = '(' + data.jumlah + ' / 200 data)';
    }catch(e){
      // opsional -> gagal diam-diam, tidak menandai koneksi utama terputus
    }
  }

  async function muatLastQL(){
    try{
      const res = await fetch('/api/lastql?t=' + Date.now());
      const data = await res.json();
      const f = data.features && data.features[0];
      if (!f) return;
      const p = f.properties;
      document.getElementById('qlMagVal').textContent = 'M ' + parseFloat(p.mag).toFixed(1);
      document.getElementById('qlPlaceVal').textContent = p.place;
      document.getElementById('qlDepthVal').textContent = parseFloat(p.depth).toFixed(0) + ' km';
      document.getElementById('qlTimeVal').textContent = p.time;
    }catch(e){
      // sumber tambahan opsional -> gagal diam-diam, tidak menandai koneksi utama terputus
    }
  }

  async function muatInfoJarak(){
    try{
      const res = await fetch('/api/info?t=' + Date.now());
      const data = await res.json();
      const el = document.getElementById('jarakVal');
      if (data.jarak_gempa_terbaru_km === null || data.jarak_gempa_terbaru_km === undefined) {
        el.textContent = 'tidak diketahui';
      } else {
        el.textContent = '~' + data.jarak_gempa_terbaru_km + ' km' +
          (data.alarm_penuh_gempa_terbaru ? ' (alarm penuh)' : ' (notifikasi ringan)');
      }
    }catch(e){}
  }

  function refreshSemua(){
    muatGempaTerbaru();
    muatGempaTerkini();
    muatLaporanM2();
    muatLastQL();
    muatRiwayatQL();
    muatInfoJarak();
  }

  refreshSemua();
  setInterval(refreshSemua, 3000); // auto-refresh tiap 3 detik -> cukup responsif tanpa membebani web server ESP32
</script>

</body>
</html>
)HTMLPAGE";

// =============================================================================
//                        HANDLER WEB SERVER
// =============================================================================
void handleRoot() {
  server.send_P(200, "text/html", INDEX_HTML);
}

void handleApiAutogempa() {
  server.sendHeader("Access-Control-Allow-Origin", "*");
  server.sendHeader("Cache-Control", "no-store");
  if (dataAutogempaSiap && cacheAutogempaJson.length() > 0) {
    server.send(200, "application/json", cacheAutogempaJson);
  } else {
    server.send(503, "application/json", "{\"error\":\"data belum tersedia, coba lagi sebentar\"}");
  }
}

void handleApiGempaTerkini() {
  server.sendHeader("Access-Control-Allow-Origin", "*");
  server.sendHeader("Cache-Control", "no-store");
  if (dataTerkiniSiap && cacheGempaTerkiniJson.length() > 0) {
    server.send(200, "application/json", cacheGempaTerkiniJson);
  } else {
    server.send(503, "application/json", "{\"error\":\"data belum tersedia, coba lagi sebentar\"}");
  }
}

void handleApiGempaDirasakan() {
  server.sendHeader("Access-Control-Allow-Origin", "*");
  server.sendHeader("Cache-Control", "no-store");
  if (dataDirasakanSiap && cacheGempaDirasakanJson.length() > 0) {
    server.send(200, "application/json", cacheGempaDirasakanJson);
  } else {
    server.send(503, "application/json", "{\"error\":\"data belum tersedia, coba lagi sebentar\"}");
  }
}

void handleApiLastQL() {
  server.sendHeader("Access-Control-Allow-Origin", "*");
  server.sendHeader("Cache-Control", "no-store");
  if (dataLastQLSiap && cacheLastQLJson.length() > 0) {
    server.send(200, "application/json", cacheLastQLJson);
  } else {
    server.send(503, "application/json", "{\"error\":\"data belum tersedia, coba lagi sebentar\"}");
  }
}

void handleApiLaporanM2() {
  server.sendHeader("Access-Control-Allow-Origin", "*");
  server.sendHeader("Cache-Control", "no-store");
  server.send(200, "application/json", laporanM2Json);
}

void handleApiLastQLRiwayat() {
  server.sendHeader("Access-Control-Allow-Origin", "*");
  server.sendHeader("Cache-Control", "no-store");
  server.send(200, "application/json", riwayatQLJson);
}

void handleApiInfo() {
  server.sendHeader("Access-Control-Allow-Origin", "*");
  String jarakStr = (jarakKm >= 0) ? String(jarakKm, 1) : String("null");
  String json = "{";
  json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
  json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
  json += "\"uptime_detik\":" + String(millis() / 1000) + ",";
  json += "\"terakhir_update_gempa_detik_lalu\":" + String(dataAutogempaSiap ? (millis() - lastAutogempaOkTime) / 1000 : -1) + ",";
  json += "\"lokasi_lat\":" + String(LOKASI_LAT, 4) + ",";
  json += "\"lokasi_lon\":" + String(LOKASI_LON, 4) + ",";
  json += "\"jarak_gempa_terbaru_km\":" + jarakStr + ",";
  json += "\"alarm_penuh_gempa_terbaru\":" + String(gempaBaruLayakAlarmPenuh ? "true" : "false");
  json += "}";
  server.send(200, "application/json", json);
}

void handleNotFound() {
  server.send(404, "text/plain", "404 - Halaman tidak ditemukan");
}

void setupWebServer() {
  server.on("/", handleRoot);
  server.on("/api/autogempa", handleApiAutogempa);
  server.on("/api/gempaterkini", handleApiGempaTerkini);
  server.on("/api/gempadirasakan", handleApiGempaDirasakan);
  server.on("/api/lastql", handleApiLastQL);
  server.on("/api/laporan-m2", handleApiLaporanM2);
  server.on("/api/lastql-riwayat", handleApiLastQLRiwayat);
  server.on("/api/info", handleApiInfo);
  server.onNotFound(handleNotFound);
  server.begin();
  Serial.println("Web server aktif di port 80");

  if (MDNS.begin(MDNS_HOSTNAME)) {
    MDNS.addService("http", "tcp", 80);
    Serial.printf("mDNS aktif: http://%s.local/\n", MDNS_HOSTNAME);
  }
}

// =============================================================================
//   TASK JARINGAN (CORE 0) - WiFi, web server, polling BMKG, alarm buzzer
//   Semua yang BOLEH blocking ditaruh di sini. Task ini TIDAK PERNAH memanggil
//   P.displayXxx()/P.displayAnimate() -> objek matrix murni milik core1.
// =============================================================================
void taskJaringan(void *parameter) {
  connectWiFi();
  setupWebServer();

  // Ambil data pertama kali langsung saat boot (baseline, tidak trigger alarm)
  pollAutogempa();
  pollGempaTerkini();
  pollGempaDirasakan();
  pollLastQL();
  bangunLaporanGabunganM2();
  adaGempaBaruPending      = false;   // baseline awal jangan sampai membunyikan alarm
  adaQuicklookBaruPending  = false;   // baseline awal quicklook juga jangan sampai memicu alert

  lastAutogempaPoll = millis();
  lastTerkiniPoll   = millis();
  lastDirasakanPoll = millis();
  lastLastQLPoll    = millis();
  unsigned long lastDiagPrint = millis();

  for (;;) {
    // Diagnostik ringkas tiap 15 detik -> kalau tabel web masih kosong,
    // buka Serial Monitor (115200 baud) dan lihat baris ini utk tahu penyebabnya:
    // - WiFi belum connect?
    // - Free heap turun terus (menuju 0)?
    // - dataAutogempaSiap dkk masih "belum"?
    if (millis() - lastDiagPrint >= 15000) {
      lastDiagPrint = millis();
      Serial.printf("[DIAG] WiFi:%s | freeHeap:%u | autogempa:%s | terkini:%s | dirasakan:%s | lastQL:%s\n",
        (WiFi.status() == WL_CONNECTED) ? "OK" : "TERPUTUS",
        ESP.getFreeHeap(),
        dataAutogempaSiap ? "siap" : "belum",
        dataTerkiniSiap   ? "siap" : "belum",
        dataDirasakanSiap ? "siap" : "belum",
        dataLastQLSiap    ? "siap" : "belum");
    }
    server.handleClient();

    unsigned long now = millis();
    bool listBaruDiambil = false;

    if (now - lastAutogempaPoll >= AUTOGEMPA_POLL_INTERVAL) {
      lastAutogempaPoll = now;
      pollAutogempa();
      listBaruDiambil = true;   // gempa terbaru ikut dihitung ke laporan M2.0+
    }
    if (now - lastTerkiniPoll >= TERKINI_POLL_INTERVAL) {
      lastTerkiniPoll = now;
      pollGempaTerkini();
      listBaruDiambil = true;
    }
    if (now - lastDirasakanPoll >= DIRASAKAN_POLL_INTERVAL) {
      lastDirasakanPoll = now;
      pollGempaDirasakan();
      listBaruDiambil = true;
    }
    if (LASTQL_AKTIF && now - lastLastQLPoll >= LASTQL_POLL_INTERVAL) {
      lastLastQLPoll = now;
      pollLastQL();   // sumber tambahan, tidak resmi -> HANYA update tampilan, tidak ikut laporan M2.0+/alarm
    }
    if (listBaruDiambil) {
      bangunLaporanGabunganM2();   // gabungkan ulang laporan M2.0+ setiap ada data baru
    }

    vTaskDelay(2 / portTICK_PERIOD_MS); // jeda kecil, kasih napas ke task WiFi & watchdog
  }
}

// =============================================================================
//                         SETUP
// =============================================================================
void setup() {
  Serial.begin(115200);

  pinMode(BUZZER_PIN, OUTPUT);
  buzzerOff();
  buzzerNotifStartup();   // notifikasi suara: ESP32 baru saja dinyalakan/boot

  P.begin();
  P.setIntensity(5);
  P.displayClear();

  xDataMutex = xSemaphoreCreateMutex();

  // Tampilkan placeholder "Menghubungkan WiFi..." langsung, TANPA menunggu
  // WiFi konek -> matrix sudah mulai scroll mulus sejak detik pertama.
  strncpy(dispIpText, ipText, sizeof(dispIpText) - 1);
  dispIpText[sizeof(dispIpText) - 1] = '\0';
  P.displayText(dispIpText, PA_LEFT, 30, 400, PA_SCROLL_LEFT, PA_SCROLL_LEFT);

  // Semua kerjaan jaringan (WiFi, web server, polling BMKG, alarm) dijalankan
  // di CORE 0, terpisah total dari core1 yang menjalankan loop() di bawah.
  xTaskCreatePinnedToCore(
    taskJaringan,
    "taskJaringan",
    16384,              // stack cukup besar untuk HTTPS (WiFiClientSecure) + JSON
    NULL,
    1,                  // priority
    &taskNetworkHandle,
    0                   // jalankan di CPU core 0
  );
}

// =============================================================================
//   FUNGSI JAM (dipakai HANYA oleh core1/loop, tidak perlu mutex karena cuma
//   membaca jam sistem, bukan buffer "shared" antar-core)
// =============================================================================
// Isi dispJamText dengan jam:menit:detik + tanggal saat ini (WIB, hasil sinkron NTP).
// Kalau NTP belum sempat sinkron (mis. baru boot / belum ada internet), tampilkan pesan itu saja.
void bangunTeksJam() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo, 200)) {   // timeout 200ms, non-blocking lama
    snprintf(dispJamText, sizeof(dispJamText), "Menyinkronkan jam...");
    return;
  }
  static const char* hariNama[7] = {"Min","Sen","Sel","Rab","Kam","Jum","Sab"};
  snprintf(dispJamText, sizeof(dispJamText), "%s %02d:%02d:%02d",
           hariNama[timeinfo.tm_wday], timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
}

// =============================================================================
//   MASUK MODE ALERT (gempa baru / tsunami) - dipanggil dari loop() di titik
//   aman. Mode ini PALING PRIORITAS: memotong mode jam maupun rotasi normal,
//   dan untuk tsunami diulang lebih lama (ALERT_REPEAT_MAX_TSUNAMI).
// =============================================================================
void masukModeAlert() {
  adaGempaBaruPending = false;
  clockModeActive     = false;   // batalkan mode jam kalau sedang jalan -> gempa/tsunami selalu diprioritaskan
  alertIsTsunami       = gempaBaruAdalahTsunami;

  salinTeksUntukMatrix(dispGempaText, sizeof(dispGempaText), gempaText);
  P.displayClear();
  P.displayReset();
  // Tsunami: scroll sedikit lebih cepat & intensitas layar dimaksimalkan supaya lebih mencolok.
  if (alertIsTsunami) {
    P.setIntensity(15);
    P.displayText(dispGempaText, PA_LEFT, 20, 250, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
  } else {
    P.displayText(dispGempaText, PA_LEFT, 25, 300, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
  }
  alertActive      = true;
  alertRepeatCount = 1;
}

// =============================================================================
//   MASUK MODE ALERT QUICKLOOK - dipanggil dari loop() saat ada kiriman data
//   BARU dari sumber quicklook (lastQL). PRIORITAS: memotong mode jam & rotasi
//   normal (persis seperti alert gempa dari autogempa), supaya info quicklook
//   terbaru langsung terlihat begitu datang, sesuai permintaan user.
// =============================================================================
void masukModeAlertQL() {
  adaQuicklookBaruPending = false;
  clockModeActive         = false;   // batalkan mode jam kalau sedang jalan -> quicklook baru selalu diprioritaskan

  salinTeksUntukMatrix(dispLastQLText, sizeof(dispLastQLText), lastQLText);
  P.displayClear();
  P.displayReset();
  P.displayText(dispLastQLText, PA_LEFT, 25, 300, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
  alertActiveQL      = true;
  alertRepeatCountQL = 1;
}

// =============================================================================
//   LOOP (CORE 1) - HANYA mengurus animasi matrix, tidak ada blocking apa pun
//   di sini -> P.displayAnimate() dipanggil seterusnya secepat mungkin ->
//   scroll teks selalu mulus, seberapa pun lambatnya server BMKG.
// =============================================================================
void loop() {
  // -------- Mode ALERT: ada gempa baru/tsunami, tampilkan info berulang + PALING prioritas --------
  if (alertActive) {
    if (P.displayAnimate()) {
      alertRepeatCount++;
      uint8_t batasUlang = alertIsTsunami ? ALERT_REPEAT_MAX_TSUNAMI : ALERT_REPEAT_MAX;
      if (alertRepeatCount >= batasUlang) {
        alertActive = false;   // kembali ke rotasi normal (IP -> jam -> gempa terkini -> tertinggi -> dirasakan)
        if (alertIsTsunami) P.setIntensity(5); // kembalikan kecerahan normal setelah alert tsunami selesai
      } else {
        salinTeksUntukMatrix(dispGempaText, sizeof(dispGempaText), gempaText);
        P.displayText(dispGempaText, PA_LEFT, alertIsTsunami ? 20 : 25, alertIsTsunami ? 250 : 300,
                      PA_SCROLL_LEFT, PA_SCROLL_LEFT);
      }
    }
    return; // selama alert aktif, jangan jalankan mode jam ataupun rotasi normal
  }

  // -------- Mode ALERT QUICKLOOK: kiriman data baru dari lastQL, prioritas tampil + sirene sudah dibunyikan di core0 --------
  if (alertActiveQL) {
    if (P.displayAnimate()) {
      alertRepeatCountQL++;
      if (alertRepeatCountQL >= ALERT_REPEAT_MAX_QL) {
        alertActiveQL = false;   // kembali ke rotasi normal
      } else {
        salinTeksUntukMatrix(dispLastQLText, sizeof(dispLastQLText), lastQLText);
        P.displayText(dispLastQLText, PA_LEFT, 25, 300, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
      }
    }
    return; // selama alert quicklook aktif, jangan jalankan mode jam ataupun rotasi normal
  }

  // Ada gempa baru dari task jaringan? Langsung masuk mode alert di titik aman ini,
  // MEMOTONG mode jam sekalipun sedang berjalan (gempa/tsunami selalu diprioritaskan).
  // Data resmi BMKG (autogempa) diprioritaskan LEBIH DULU dibanding quicklook (tidak resmi).
  if (adaGempaBaruPending) {
    masukModeAlert();
    return;
  }

  // Ada kiriman data BARU dari quicklook (lastQL)? Prioritaskan tampil + sudah
  // bunyi sirene di core0 (pollLastQL) -> di sini core1 tinggal memotong
  // rotasi/mode jam supaya info quicklook terbaru langsung terlihat.
  if (adaQuicklookBaruPending) {
    masukModeAlertQL();
    return;
  }

  // WiFi baru saja konek/reconnect -> loncat tampilkan IP asli SEKARANG juga,
  // jangan nunggu rotasi normal muter penuh (yang di lapangan bisa lama karena
  // sering keinterupsi mode alert gempa). Prioritas di bawah alert gempa,
  // tapi di atas mode jam/rotasi biasa.
  if (ipTeksPerluTampilLangsung) {
    ipTeksPerluTampilLangsung = false;
    clockModeActive = false;
    screenIndex     = SCR_IP;
    salinTeksUntukMatrix(dispIpText, sizeof(dispIpText), ipText);
    P.displayClear();
    P.displayReset();
    P.displayText(dispIpText, PA_LEFT, 30, 400, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
    return;
  }

  // -------- Mode JAM: di-SCROLL (bukan statis) --------
  // "Sen 12:34:56" lebih lebar (~44-50px) daripada layar 4x8x8 (32px), jadi kalau
  // dipaksa statis (PA_PRINT) pasti kepotong. Solusinya di-scroll seperti layar
  // info gempa lainnya. Supaya jam tidak terasa "basi", tiap kali satu putaran
  // scroll selesai, teks jam dibangun ulang dengan waktu TERBARU sebelum putaran
  // scroll berikutnya dimulai -> jam tetap berjalan maju walau tampilannya scroll.
  if (clockModeActive) {
    if (P.displayAnimate()) {
      if (millis() - clockModeStart >= DURASI_TAMPIL_JAM_MS) {
        clockModeActive = false;
        screenIndex = SCR_GEMPA_TERKINI;
        salinTeksUntukMatrix(dispGempaText, sizeof(dispGempaText), gempaText);
        P.displayClear();
        P.displayReset();
        P.displayText(dispGempaText, PA_LEFT, 25, 400, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
      } else {
        bangunTeksJam();   // refresh jam ke waktu terkini untuk putaran scroll selanjutnya
        P.displayText(dispJamText, PA_LEFT, 30, 250, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
      }
    }
    return;
  }

  // -------- Rotasi normal, hanya dilanjutkan di titik AMAN (antar-scroll) --------
  if (P.displayAnimate()) {

    // -------- Rotasi: IP -> JAM (10 dtk) -> Gempa Terkini -> Gempa Tertinggi -> Gempa Dirasakan --------
    screenIndex = (screenIndex + 1) % JUMLAH_SCREEN;

    switch (screenIndex) {
      case SCR_IP:
        salinTeksUntukMatrix(dispIpText, sizeof(dispIpText), ipText);
        P.displayText(dispIpText, PA_LEFT, 30, 400, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
        break;

      case SCR_JAM:
        // Masuk mode jam: di-scroll (teks terlalu lebar utk statis di 32px),
        // berjalan selama kurang lebih DURASI_TAMPIL_JAM_MS, jam di-refresh tiap putaran.
        clockModeActive = true;
        clockModeStart  = millis();
        bangunTeksJam();
        P.displayClear();
        P.displayReset();
        P.displayText(dispJamText, PA_LEFT, 30, 250, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
        break;

      case SCR_GEMPA_TERKINI:
        salinTeksUntukMatrix(dispGempaText, sizeof(dispGempaText), gempaText);
        P.displayText(dispGempaText, PA_LEFT, 25, 400, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
        break;

      case SCR_GEMPA_TERTINGGI:
        salinTeksUntukMatrix(dispGempaTertinggiText, sizeof(dispGempaTertinggiText), gempaTertinggiText);
        P.displayText(dispGempaTertinggiText, PA_LEFT, 25, 400, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
        break;

      case SCR_GEMPA_DIRASAKAN:
        salinTeksUntukMatrix(dispGempaDirasakanText, sizeof(dispGempaDirasakanText), gempaDirasakanText);
        P.displayText(dispGempaDirasakanText, PA_LEFT, 25, 400, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
        break;

      case SCR_LASTQL:
        if (!LASTQL_AKTIF) {
          // Kalau dimatikan (LASTQL_AKTIF = false), lewati layar ini -> langsung ke IP lagi
          screenIndex = SCR_IP;
          salinTeksUntukMatrix(dispIpText, sizeof(dispIpText), ipText);
          P.displayText(dispIpText, PA_LEFT, 30, 400, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
        } else {
          salinTeksUntukMatrix(dispLastQLText, sizeof(dispLastQLText), lastQLText);
          P.displayText(dispLastQLText, PA_LEFT, 25, 400, PA_SCROLL_LEFT, PA_SCROLL_LEFT);
        }
        break;
    }
  }
}


No comments

Featured Post

Rakit Sendiri Alat INFO Gempa BMKG dengan ESP32, Max7219 dan Buzzer

  Audio Podcast :  Dokumen: https://docs.google.com/document/d/1ZWl-Pt51RwHAYtvuULV-jHnQp12ZQww3/edit?usp=sharing&ouid=103568289...