Rakit Sendiri Alat INFO Gempa BMKG dengan ESP32, Max7219 dan Buzzer
Audio Podcast :
Dokumen:
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>💥 Info Gempa BMKG — Realtime</h1>
<div class="sub">Sumber data resmi: data.bmkg.go.id (via ESP32) • ESP32 IP: <b id="espIp">-</b></div>
<span class="badge live" id="statusBadge">● 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 — 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 ≥ 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 — 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 — 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 • ESP32 & 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