From 628642a1f78e82d6ce81dfd21552b2debc0e7c59 Mon Sep 17 00:00:00 2001 From: Ferdi Date: Mon, 20 Jul 2026 11:10:24 +0700 Subject: [PATCH] first commit --- backend-jamur-update/.gitignore | 19 + backend-jamur-update/README.md | 626 +++++ backend-jamur-update/package-lock.json | 2077 +++++++++++++++++ backend-jamur-update/package.json | 25 + backend-jamur-update/src/index.js | 98 + .../src/jobs/offlineDetector.js | 63 + backend-jamur-update/src/mqtt/mqttClient.js | 442 ++++ .../src/queues/irrigationQueue.js | 34 + .../src/queues/irrigationWorker.js | 103 + .../src/queues/scheduleRestore.js | 84 + backend-jamur-update/src/routes/device.js | 146 ++ backend-jamur-update/src/routes/history.js | 109 + backend-jamur-update/src/routes/latency.js | 28 + backend-jamur-update/src/routes/mode.js | 151 ++ backend-jamur-update/src/routes/schedule.js | 264 +++ backend-jamur-update/src/routes/threshold.js | 93 + backend-jamur-update/src/supabase/client.js | 27 + .../src/utils/failoverManager.js | 145 ++ .../src/utils/notification.js | 100 + backend-jamur-update/test-latency.js | 126 + 20 files changed, 4760 insertions(+) create mode 100644 backend-jamur-update/.gitignore create mode 100644 backend-jamur-update/README.md create mode 100644 backend-jamur-update/package-lock.json create mode 100644 backend-jamur-update/package.json create mode 100644 backend-jamur-update/src/index.js create mode 100644 backend-jamur-update/src/jobs/offlineDetector.js create mode 100644 backend-jamur-update/src/mqtt/mqttClient.js create mode 100644 backend-jamur-update/src/queues/irrigationQueue.js create mode 100644 backend-jamur-update/src/queues/irrigationWorker.js create mode 100644 backend-jamur-update/src/queues/scheduleRestore.js create mode 100644 backend-jamur-update/src/routes/device.js create mode 100644 backend-jamur-update/src/routes/history.js create mode 100644 backend-jamur-update/src/routes/latency.js create mode 100644 backend-jamur-update/src/routes/mode.js create mode 100644 backend-jamur-update/src/routes/schedule.js create mode 100644 backend-jamur-update/src/routes/threshold.js create mode 100644 backend-jamur-update/src/supabase/client.js create mode 100644 backend-jamur-update/src/utils/failoverManager.js create mode 100644 backend-jamur-update/src/utils/notification.js create mode 100644 backend-jamur-update/test-latency.js diff --git a/backend-jamur-update/.gitignore b/backend-jamur-update/.gitignore new file mode 100644 index 0000000..5b66084 --- /dev/null +++ b/backend-jamur-update/.gitignore @@ -0,0 +1,19 @@ +# Environment variables — JANGAN pernah di-commit! +.env +.env.local +.env.*.local + +# Dependencies +node_modules/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db + +# Temporary test/scratch files +test-latency-standalone.js +scratch-check.js diff --git a/backend-jamur-update/README.md b/backend-jamur-update/README.md new file mode 100644 index 0000000..7e28973 --- /dev/null +++ b/backend-jamur-update/README.md @@ -0,0 +1,626 @@ +# 🍄 Backend IoT Kumbung Jamur + +Backend Node.js untuk sistem monitoring dan kontrol otomatis kumbung jamur berbasis IoT. Sistem ini menghubungkan sensor **SHT31** dan relay pada **ESP32** ke aplikasi mobile **Flutter** melalui **MQTT (HiveMQ Cloud)**, dengan penjadwalan penyiraman otomatis menggunakan **BullMQ (Redis)**, sistem push notification anti-spam via **OneSignal**, serta database utama **Supabase**. + +Aplikasi ini dirancang dengan arsitektur **High Availability (Dual Server Failover)** dan **In-Memory Optimizations** untuk memastikan keandalan sistem yang optimal dan efisiensi resource yang sangat tinggi. + +--- + +## 🏗️ Arsitektur Sistem Terdistribusi + +Sistem ini mendukung arsitektur **Failover Otomatis** menggunakan dua server (Primary & Backup) untuk menjamin layanan tetap berjalan meskipun server utama mengalami kendala (*down*). + +``` + ┌──────────────────────────────────────┐ + │ Aplikasi Flutter │ + └──────┬────────────────────────┬──────┘ + │ + HTTP REST API │ + (https://...railway.app) + ▼ + ┌────────────────────────┐ + │ PRIMARY SERVER ├ ──────────────────── + │ (Railway) │ | + └───────────┬────────────┘ | + │ │ + ┌─────┴─────────────────────────────────┴─────┐ + │ │ + ▼ ▼ ▼ + Supabase (DB) Upstash (Redis) HiveMQ Cloud + ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ + │ Tabel Utama & │ │ Antrian Kerja │ │ Broker MQTT │ + │ RPC Functions │ │ (BullMQ) │ │ (TLS) │ + └────────────────┘ └─────────────────┘ └────────┬─────────┘ + │ + ▼ (mqtts://) + ┌─────────────────┐ + │ ESP32 + SHT31 │ + └─────────────────┘ +``` + +### 🔄 Alur & Fitur Utama Failover (Dynamic Failover Manager) +1. **Primary Server (Railway)**: Berjalan secara aktif. Menangani REST API, koneksi MQTT, pemrosesan antrian BullMQ, dan pendeteksian perangkat offline. +2. **Backup Server (VPS - Standby)**: + - Saat startup, background services (MQTT, Worker BullMQ, Scheduler Restore, Offline Detector) **dinonaktifkan** (standby). + - Secara berkala (setiap 10 detik / `FAILOVER_PING_INTERVAL_MS`), server backup mengirimkan ping (HTTP GET) ke `PRIMARY_SERVER_URL`. + - **Jika Primary Down (Ping Gagal)**: Backup server langsung mengaktifkan semua background services lokal, me-resume worker BullMQ, me-restore jadwal aktif dari database, dan mengambil alih kendali sistem. + - **Jika Primary Kembali Online (Ping Sukses)**: Backup server secara otomatis mendegradasi diri kembali ke mode standby (disconnect MQTT, pause worker, matikan offline detector) untuk menghindari bentrokan eksekusi (*race conditions*). + +--- + +## ⚡ Optimalisasi Kinerja & Efisiensi Database + +Untuk mencegah pembengkakan biaya database (kuota request Supabase) dan beban broker MQTT akibat frekuensi transmisi data ESP32 yang tinggi, backend mengimplementasikan beberapa teknik optimalisasi tingkat tinggi: + +### 1. In-Memory Cache (Threshold Cache) +* **Masalah**: ESP32 mengirim data sensor secara real-time. Jika backend harus menanyakan batas threshold ke Supabase setiap kali data masuk, database akan terbebani ribuan query per menit. +* **Solusi**: Nilai threshold disimpan dalam *in-memory cache* server dengan TTL 30 detik (`CACHE_TTL_MS`). Query ke Supabase hanya dilakukan saat cache kedaluwarsa atau terjadi update threshold melalui API (cache langsung di-invalidate secara instan). + +### 2. Smart Filtering & Deadband Logging (Sensor Log Cache) +Backend menyaring log sensor sebelum disimpan ke database Supabase melalui algoritma *Deadband filtering*: +* Data sensor hanya akan disimpan ke tabel `sensor_logs` jika memenuhi salah satu kondisi berikut: + - Suhu berubah lebih dari **0.5°C** (`TEMP_DELTA`). + - Kelembapan berubah lebih dari **1.0%** (`HUM_DELTA`). + - Status relay berubah (`ON` ↔ `OFF`). + - Mode operasi perangkat berubah (`auto`, `manual`, `offline`). + - Interval waktu detak jantung (*heartbeat*) telah mencapai **5 menit** (`HEARTBEAT_INTERVAL_MS`), bertujuan untuk menjaga kontinuitas grafik di UI. +* Mengurangi penyimpanan database hingga **90%** tanpa kehilangan data historis yang penting. + +### 3. Online Status Throttling +* Status keaktifan perangkat (`last_seen` dan `is_online`) di database diperbarui secara berkala maksimal **1 menit sekali** (`LAST_SEEN_INTERVAL_MS`), mencegah spam penulisan (*write-heavy*) ke Supabase. + +--- + +## 🚨 Pendeteksi Perangkat Offline & Push Notification + +### 1. Offline Detector Job +* Berjalan di latar belakang setiap 5 menit. +* Memindai perangkat di database yang berstatus `is_online = true` namun `last_seen` berumur lebih dari 5 menit lalu. +* Jika ditemukan, secara otomatis memperbarui status perangkat menjadi offline di database dan memicu push notification darurat ke pemilik perangkat. + +### 2. Anti-Spam Push Notification (OneSignal Integration) +* **Koneksi**: Integrasi langsung ke OneSignal menggunakan API REST resmi. +* **Anti-Spam Guard (Cooldown)**: Setiap notifikasi yang sama (misal peringatan sensor kering/panas atau status offline) memiliki *cooldown time* (seperti 30 menit atau 1 jam) yang dikelola di Redis / In-Memory Map agar pengguna tidak dibanjiri spam push notification. +* **Notification Stacking & Threading**: Menggunakan `android_group`, `thread_id`, dan `collapse_id` dengan nama `jamur_monitoring_group` untuk mengelompokkan notifikasi secara rapi di notification tray Android & iOS (seperti gaya chat WhatsApp). + +--- + +## 📋 Prasyarat Layanan Cloud + +Pastikan Anda memiliki akun dan konfigurasi untuk layanan-layanan berikut: + +| Layanan | Keterangan | +|---|---| +| [Supabase](https://supabase.com) | Database PostgreSQL utama (gratis) | +| [HiveMQ Cloud](https://www.hivemq.com/mqtt-cloud-broker/) | MQTT Broker TLS aman port 8883 (gratis) | +| [Upstash Redis](https://upstash.com) | Redis Cloud untuk antrian BullMQ (gratis) | +| [OneSignal](https://onesignal.com) | Platform Push Notification ke Aplikasi Mobile | +| Node.js >= 18 | Runtime JavaScript lokal | + +--- + +## ⚙️ Instalasi & Konfigurasi + +### 1. Clone & Install Dependencies + +```bash +git clone +cd backend-jamur +npm install +``` + +### 2. Setup Environment Variables + +Buat file `.env` di root folder aplikasi, lalu isi konfigurasi berikut: + +```env +# 💻 Server Configuration +PORT=3000 +IS_BACKUP_SERVER=false # Set 'true' jika ini dideploy ke server VPS Backup +PRIMARY_SERVER_URL=https://nama-project-kamu.up.railway.app # URL Primary Server (diperlukan jika ini Backup Server) +FAILOVER_PING_INTERVAL_MS=10000 # Interval cek primary server (dalam milidetik, default 10 detik) + +# ⚡ Supabase Configuration (Ambil dari: Project Settings > API) +SUPABASE_URL=https://xxxxxxxxxxxxxxxx.supabase.co +SUPABASE_SERVICE_KEY=sb_secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# 📡 HiveMQ Cloud MQTT Broker (Ambil dari: Clusters > MQTT Credentials & Connection Settings) +MQTT_BROKER_URL=mqtts://xxxxxxxxxxxxxxxxxxxxxxxx.s1.eu.hivemq.cloud:8883 +MQTT_PORT=8883 +MQTT_USERNAME=username_hivemq_kamu +MQTT_PASSWORD=password_hivemq_kamu + +# 🔴 Upstash Redis Connection (Ambil dari: Database > Details > Connection URL) +REDIS_URL=rediss://default:xxxxxxxx@xxxx.upstash.io:6379 + +# 🔔 OneSignal Push Notification (Ambil dari: Settings > Keys & IDs) +ONESIGNAL_APP_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +ONESIGNAL_REST_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +> ⚠️ **PENTING**: Jangan pernah melakukan commit file `.env` ke Git! File ini sudah otomatis diabaikan di file `.gitignore`. + +### 3. Setup Database Supabase (Skema Tabel & Stored Procedure) + +Jalankan perintah SQL berikut di dashboard **Supabase > SQL Editor**: + +```sql +-- 1. TABEL UTAMA: Perangkat ESP32 +CREATE TABLE devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id TEXT UNIQUE NOT NULL, -- e.g. "esp32-01" + label TEXT, -- nama display e.g. "Kumbung Barat" + location TEXT, -- lokasi penempatan + claim_code TEXT UNIQUE, -- kode klaim huruf kapital, e.g. "JAMUR01" + claimed_by UUID REFERENCES auth.users(id), + claimed_at TIMESTAMPTZ, + is_online BOOLEAN DEFAULT false, + last_seen TIMESTAMPTZ, + current_mode TEXT DEFAULT 'auto' -- mode kerja aktif: auto, manual, offline +); + +-- 2. TABEL LOG: Riwayat Sensor & Log Aksi +CREATE TABLE sensor_logs ( + id BIGSERIAL PRIMARY KEY, + device_id TEXT NOT NULL, + temperature FLOAT, + humidity FLOAT, + relay_state BOOLEAN DEFAULT false, -- true = ON, false = OFF + mode TEXT DEFAULT 'auto', -- snapshot mode kerja saat log terekam + event TEXT, -- event penting (e.g. "manual_stop", "system_on") + note TEXT, -- catatan tambahan + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 3. TABEL CONFIG: Batas Sensor Otomatisasi +CREATE TABLE thresholds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id TEXT UNIQUE NOT NULL, + temp_max FLOAT NOT NULL DEFAULT 30.0, -- suhu maks sebelum pompa ON + hum_max FLOAT NOT NULL DEFAULT 80.0, -- kelembapan maks sebelum pompa ON + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 4. TABEL JADWAL: Jadwal Penyiraman BullMQ +CREATE TABLE schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id TEXT NOT NULL, + label TEXT, + cron TEXT NOT NULL, -- cron format: "menit jam hari bulan hari-minggu" + duration_s INTEGER NOT NULL, -- durasi penyiraman dalam detik + bull_job_id TEXT, -- ID job BullMQ untuk kontrol sinkronisasi + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 5. FUNCTION: Rata-rata Harian (RPC Function) +CREATE OR REPLACE FUNCTION get_daily_average(p_device_id TEXT, p_days INT) +RETURNS TABLE(day DATE, avg_temp NUMERIC, avg_hum NUMERIC) AS $$ +BEGIN + RETURN QUERY + SELECT + created_at::DATE AS day, + ROUND(AVG(temperature)::NUMERIC, 1) AS avg_temp, + ROUND(AVG(humidity)::NUMERIC, 1) AS avg_hum + FROM sensor_logs + WHERE device_id = p_device_id + AND created_at >= NOW() - (p_days || ' days')::INTERVAL + AND temperature IS NOT NULL + AND humidity IS NOT NULL + GROUP BY created_at::DATE + ORDER BY day DESC; +END; +$$ LANGUAGE plpgsql; + +-- 6. FUNCTION: Rata-rata Per Jam (RPC Function) +CREATE OR REPLACE FUNCTION get_hourly_average(p_device_id TEXT, p_days INT) +RETURNS TABLE(hour TIMESTAMPTZ, avg_temp NUMERIC, avg_hum NUMERIC) AS $$ +BEGIN + RETURN QUERY + SELECT + date_trunc('hour', created_at) AS hour, + ROUND(AVG(temperature)::NUMERIC, 1) AS avg_temp, + ROUND(AVG(humidity)::NUMERIC, 1) AS avg_hum + FROM sensor_logs + WHERE device_id = p_device_id + AND created_at >= NOW() - (p_days || ' days')::INTERVAL + AND temperature IS NOT NULL + AND humidity IS NOT NULL + GROUP BY date_trunc('hour', created_at) + ORDER BY hour DESC; +END; +$$ LANGUAGE plpgsql; +``` + +### 4. Jalankan Server Secara Lokal + +```bash +# Jalankan mode Development (Auto-restart via nodemon) +npm run dev + +# Jalankan mode Production +npm start +``` + +Server akan aktif pada port `http://localhost:3000` (atau sesuai konfigurasi env `PORT`). + +--- + +## 🚀 Panduan Deployment + +### A. Deploy ke Railway (Sebagai Primary Server) +1. Hubungkan repository GitHub Anda ke [Railway.app](https://railway.app). +2. Buat Project Baru dan pilih **Deploy dari GitHub**. +3. Di tab **Variables**, masukkan semua Environment Variables seperti isi file `.env` di atas (kecuali `PORT` karena dikelola otomatis oleh Railway). +4. Klik **Generate Domain** di tab **Settings > Networking** untuk mendapatkan URL server publik (contoh: `https://nama-project-kamu.up.railway.app`). + +### B. Deploy ke VPS (Sebagai Backup Server) +1. Siapkan server VPS (Ubuntu/Debian) dengan Node.js >= 18 dan PM2 terinstall. +2. Clone repository, jalankan `npm install`. +3. Set file `.env` dengan variabel `IS_BACKUP_SERVER=true` dan isikan `PRIMARY_SERVER_URL` dengan URL Railway yang didapatkan dari langkah di atas. +4. Jalankan server menggunakan PM2 agar berjalan di latar belakang: + ```bash + pm2 start src/index.js --name backend-jamur-backup + pm2 save + pm2 startup + ``` + +--- + +## 🔌 API Reference + +Base URL (Development): `http://localhost:3000/api` +Base URL (Production): `https://nama-project-kamu.up.railway.app/api` + +> 🛡️ **Rate Limiting**: +> * Global rate limit untuk semua API: **100 request/menit per IP**. +> * Rate limit ketat untuk trigger siram manual: **5 request/menit per IP** (menghindari banjir air pada kumbung). + +--- + +### 📋 Ringkasan Daftar API (API Cheat Sheet) + +Untuk mempermudah pencarian, berikut adalah ringkasan seluruh endpoint API yang tersedia pada backend ini: + +| Kategori | Fitur / Kegunaan | Method | Endpoint | Parameter | Deskripsi Singkat | +|---|---|:---:|---|---|---| +| **📱 Device** | Klaim Device Baru (Pairing) | `POST` | `/api/device/claim` | `claim_code` (Body), `user_id` (Body) | Menghubungkan perangkat fisik ke akun user via kode klaim. | +| | Ambil Info Device User | `GET` | `/api/device/my-device/:userId` | `userId` (Path) | Mengambil detail perangkat milik user (status online, last seen). | +| | Cek Status Online Device | `GET` | `/api/device/status/:deviceId` | `deviceId` (Path) | Membaca status keaktifan perangkat secara real-time. | +| **🌡️ Threshold** | Ambil Threshold Aktif | `GET` | `/api/threshold/:deviceId` | `deviceId` (Path) | Membaca batas suhu & kelembapan otomatisasi aktif. | +| | Update Threshold | `POST` | `/api/threshold/:deviceId` | `deviceId` (Path), `temp_max` (Body), `hum_max` (Body), `temp_min` (Body, opsional) | Mengubah batas threshold (langsung sinkron ke ESP32 via MQTT). | +| **🗓️ Jadwal** | Ambil Semua Jadwal | `GET` | `/api/schedule/:deviceId` | `deviceId` (Path) | Membaca seluruh daftar jadwal penyiraman perangkat. | +| | Buat Jadwal Baru | `POST` | `/api/schedule/:deviceId` | `deviceId` (Path), `cron` (Body), `duration_s` (Body), `label` (Body, opsional) | Mendaftarkan jadwal berulang baru ke DB & BullMQ (cron). | +| | Hapus Jadwal | `DELETE` | `/api/schedule/:id` | `id` (Path) | Menghapus jadwal permanen dari database dan antrian BullMQ. | +| | Toggle Status Jadwal | `PATCH` | `/api/schedule/:id/toggle` | `id` (Path) | Mengaktifkan/menonaktifkan jadwal sementara tanpa menghapus. | +| | Trigger Siram Instan | `POST` | `/api/schedule/:deviceId/now` | `deviceId` (Path), `duration_s` (Body, opsional) | Memicu penyiraman manual sekali jalan (default 30 detik). | +| | Hentikan Pompa Paksa | `POST` | `/api/schedule/:deviceId/stop` | `deviceId` (Path) | Mengirim sinyal `OFF` langsung ke relay pompa via MQTT. | +| **⚙️ Mode** | Ambil Mode Kerja Aktif | `GET` | `/api/mode/:deviceId` | `deviceId` (Path) | Membaca mode kerja aktif perangkat (`auto`, `manual`, `offline`). | +| | Ubah Mode Kerja | `POST` | `/api/mode/:deviceId` | `deviceId` (Path), `mode` (Body) | Mengubah mode kerja ESP32 dan sinkronisasi perintah via MQTT. | +| **📊 Riwayat** | Ambil Log Sensor Terbaru | `GET` | `/api/history/:deviceId` | `deviceId` (Path), `limit` (Query, opsional) | Mengambil data sensor real-time terbaru (terlimit maks 500). | +| | Rata-rata Harian | `GET` | `/api/history/:deviceId/daily` | `deviceId` (Path), `days` (Query, opsional) | Mengambil data rata-rata suhu & kelembapan harian (tren grafik). | +| | Rata-rata Per Jam | `GET` | `/api/history/:deviceId/hourly` | `deviceId` (Path), `days` (Query, opsional) | Mengambil data rata-rata suhu & kelembapan per jam (grafik analitis). | + +--- + +### 📱 Device Management + +#### 1. Klaim Device Baru (Pairing) +Menghubungkan kode unik perangkat fisik dengan ID akun pengguna. +* **Endpoint**: `POST /api/device/claim` +* **Body Request**: + ```json + { + "claim_code": "JAMUR01", + "user_id": "uuid-user-dari-supabase-auth" + } + ``` +* **Respons Sukses (200)**: + ```json + { + "message": "Device berhasil diklaim", + "device": { + "device_id": "esp32-01", + "label": "Kumbung Barat", + "location": "Sektor A" + } + } + ``` + +#### 2. Ambil Info Device Milik User +* **Endpoint**: `GET /api/device/my-device/:userId` +* **Respons Sukses (200)**: + ```json + { + "device_id": "esp32-01", + "label": "Kumbung Barat", + "location": "Sektor A", + "is_online": true, + "last_seen": "2026-05-24T10:00:00Z" + } + ``` + +--- + +### 🌡️ Threshold Management + +#### 1. Ambil Threshold Aktif +* **Endpoint**: `GET /api/threshold/:deviceId` +* **Respons Sukses (200)**: + ```json + { + "device_id": "esp32-01", + "temp_max": 30.0, + "hum_max": 80.0, + "updated_at": "2026-05-24T09:00:00Z" + } + ``` + +#### 2. Update Threshold +Memperbarui batas threshold di DB dan langsung mengirimkan pembaruan ke ESP32 secara instan via MQTT. +* **Endpoint**: `POST /api/threshold/:deviceId` +* **Body Request**: + ```json + { + "temp_max": 31.5, + "hum_max": 85.0 + } + ``` +* **Respons Sukses (200)**: + ```json + { + "message": "Threshold diupdate", + "data": { "device_id": "esp32-01", "temp_max": 31.5, "hum_max": 85.0 } + } + ``` + +--- + +### 🗓️ Jadwal & Kontrol Penyiraman + +#### 1. Ambil Semua Jadwal Perangkat +* **Endpoint**: `GET /api/schedule/:deviceId` +* **Respons Sukses (200)**: + ```json + [ + { + "id": "uuid-jadwal-1", + "device_id": "esp32-01", + "label": "Siram Pagi Hari", + "cron": "0 6 * * *", + "duration_s": 60, + "is_active": true, + "created_at": "2026-05-24T08:00:00Z" + } + ] + ``` + +#### 2. Buat Jadwal Penyiraman Baru +Menambahkan jadwal ke DB dan mendaftarkannya sebagai antrian *repeatable job* di BullMQ. +* **Endpoint**: `POST /api/schedule/:deviceId` +* **Body Request**: + ```json + { + "label": "Siram Sore", + "cron": "0 17 * * *", + "duration_s": 45 + } + ``` +* **Respons Sukses (210/201)**: + ```json + { + "id": "uuid-jadwal-baru", + "device_id": "esp32-01", + "label": "Siram Sore", + "cron": "0 17 * * *", + "duration_s": 45, + "is_active": true + } + ``` + +#### 3. Hapus Jadwal +Menghapus permanen jadwal dari database dan membatalkan repeatable job dari BullMQ. +* **Endpoint**: `DELETE /api/schedule/:id` +* **Respons Sukses (200)**: + ```json + { "message": "Jadwal dihapus" } + ``` + +#### 4. Toggle Jadwal (Aktif / Nonaktif) +Menghentikan eksekusi jadwal sementara waktu di BullMQ tanpa menghapus data jadwal dari database. +* **Endpoint**: `PATCH /api/schedule/:id/toggle` +* **Respons Sukses (200)**: + ```json + { + "message": "Jadwal dinonaktifkan", + "data": { "id": "uuid-jadwal-1", "is_active": false } + } + ``` + +#### 5. Trigger Siram Manual (Sekali Jalan) +Memicu penyiraman instan di luar jadwal reguler. +* **Endpoint**: `POST /api/schedule/:deviceId/now` +* **Body Request** (Opsional): + ```json + { "duration_s": 30 } + ``` +* **Respons Sukses (200)**: + ```json + { "message": "Siram manual 30s dijadwalkan" } + ``` + +#### 6. Hentikan Pompa Secara Paksa +Segera mengirimkan sinyal relay `OFF` via MQTT untuk mematikan pompa secara langsung. +* **Endpoint**: `POST /api/schedule/:deviceId/stop` +* **Respons Sukses (200)**: + ```json + { "message": "Pompa dimatikan" } + ``` + +--- + +### ⚙️ Mode Operasi Perangkat + +ESP32 mendukung 3 mode operasi utama: +* `auto`: Pompa dikendalikan secara otomatis berdasarkan data sensor SHT31. +* `manual`: Logika otomatis dimatikan, kontrol pompa sepenuhnya diatur manual lewat API/Aplikasi. +* `offline`: Mode darurat jika terputus dari jaringan cloud (logika auto berjalan mandiri di hardware lokal tanpa mempublikasikan data MQTT). + +#### 1. Ambil Mode Kerja Aktif +* **Endpoint**: `GET /api/mode/:deviceId` +* **Respons Sukses (200)**: + ```json + { + "device_id": "esp32-01", + "current_mode": "auto", + "is_online": true, + "last_seen": "2026-05-24T10:00:00Z" + } + ``` + +#### 2. Ubah Mode Kerja +Mengirim perintah ganti mode ke hardware via MQTT dan menyimpan perubahannya di database. +* **Endpoint**: `POST /api/mode/:deviceId` +* **Body Request**: + ```json + { "mode": "manual" } + ``` +* **Respons Sukses (200)**: + ```json + { + "message": "Mode berhasil diubah ke manual", + "mode": "manual", + "changed": true + } + ``` + +--- + +### 📊 Riwayat Sensor & Tren Kondisi + +#### 1. Ambil Log Sensor Terbaru +Mengambil log real-time sensor terbaru (termasuk filter in-memory deadband). +* **Endpoint**: `GET /api/history/:deviceId?limit=100` +* **Respons Sukses (200)**: + ```json + [ + { + "temperature": 28.5, + "humidity": 82.3, + "relay_state": false, + "mode": "auto", + "created_at": "2026-05-24T10:15:00Z" + } + ] + ``` + +#### 2. Ambil Rata-rata Sensor Harian (RPC get_daily_average) +Untuk visualisasi grafik jangka panjang di aplikasi Flutter. +* **Endpoint**: `GET /api/history/:deviceId/daily?days=7` +* **Respons Sukses (200)**: + ```json + [ + { + "day": "2026-05-24", + "avg_temp": 28.1, + "avg_hum": 83.4 + } + ] + ``` + +#### 3. Ambil Rata-rata Sensor Per Jam (RPC get_hourly_average) +Untuk visualisasi grafik analitis jangka menengah/harian. +* **Endpoint**: `GET /api/history/:deviceId/hourly?days=7` +* **Respons Sukses (200)**: + ```json + [ + { + "hour": "2026-05-24T10:00:00.000Z", + "avg_temp": 28.5, + "avg_hum": 82.9 + } + ] + ``` + +--- + +## 📡 MQTT Topics + +Sistem komunikasi backend dan ESP32 menggunakan protokol MQTT over TLS (mqtts) di port 8883. + +| Topic | Arah | Payload | Keterangan | +|---|---|---|---| +| `sensor/sht31` | ESP32 → Backend | `{"device_id":"esp32-01","temp":28.5,"hum":82.3,"mode":"auto","relay":false}` | Laporan status sensor & hardware berkala | +| `config/threshold/{deviceId}` | Backend → ESP32 | `{"temp":31.5,"hum":85.0}` | Sinkronisasi perubahan threshold sensor | +| `cmd/relay/{deviceId}` | Backend → ESP32 | `"ON"` atau `"OFF"` | Perintah langsung kontrol relay pompa | +| `cmd/mode/{deviceId}` | Backend → ESP32 | `"auto"`, `"manual"`, atau `"offline"` | Perintah langsung untuk mengubah mode operasi | + +--- + +## 🔁 Antrian Kerja (BullMQ) & Recovery Sistem + +Sistem penjadwalan dikelola secara hybrid menggunakan **BullMQ** dan **Supabase**. Hal ini memecahkan masalah hilangnya repeatable job jika server mengalami restart/deployment ulang. + +### Alur Kerja BullMQ Worker +1. Job repeatable terpicu sesuai jadwal *cron* atau instan dari trigger siram manual. +2. Worker BullMQ mengambil job dari Redis: + - Mengirim perintah relay `ON` ke ESP32 via MQTT. + - Mengirimkan push notification "Penyiraman Dimulai" via OneSignal ke pemilik alat. + - Menahan eksekusi (*non-blocking sleep*) selama durasi siram `duration_s`. + - Mengirim perintah relay `OFF` ke ESP32 via MQTT saat durasi berakhir. + +### Alur Recovery (Schedule Restore) +Saat server pertama kali menyala (atau setelah failover berpindah ke aktif): +1. Menghapus semua job antrian lama di Redis demi mencegah tumpang tindih. +2. Membaca semua data jadwal yang aktif (`is_active = true`) di tabel Supabase. +3. Mendaftarkan ulang job ke Redis menggunakan pustaka terbaru **BullMQ v5+** (menggunakan format parameter `cron`). + +--- + +## 📁 Struktur Folder + +``` +backend-jamur/ +├── src/ +│ ├── index.js # Entry point utama, Express & rate limit setup +│ ├── jobs/ +│ │ └── offlineDetector.js# Background job pemeriksa status online perangkat +│ ├── mqtt/ +│ │ └── mqttClient.js # Koneksi HiveMQ, subscriber sensor, & publisher command +│ ├── queues/ +│ │ ├── irrigationQueue.js# Inisialisasi antrian BullMQ & koneksi Redis Upstash +│ │ ├── irrigationWorker.js# Worker pengeksekusi siklus pompa ON -> DELAY -> OFF +│ │ └── scheduleRestore.js# Pemulihan otomatis jadwal aktif dari database saat startup +│ ├── routes/ +│ │ ├── device.js # API endpoint klaim & info perangkat +│ │ ├── history.js # API endpoint riwayat & agregasi sensor (daily/hourly) +│ │ ├── mode.js # API endpoint kontrol mode kerja ESP32 +│ │ ├── schedule.js # API endpoint CRUD jadwal & trigger pompa manual +│ │ └── threshold.js # API endpoint pembacaan & modifikasi batas sensor +│ ├── supabase/ +│ │ └── client.js # Setup Supabase JS client (Service Role authorization) +│ └── utils/ +│ └── notification.js # Pengirim OneSignal Push Notification + Cooldown redis +├── .env # File konfigurasi privat (lokal) +├── .gitignore # Daftar file terabaikan dari Git +├── package.json # Daftar pustaka dependencies & scripts npm +└── package-lock.json # Lock file dependencies +``` + +--- + +## 📦 Dependensi Utama + +Detail library penting yang digunakan pada proyek ini: + +```json +"dependencies": { + "@supabase/supabase-js": "^2.101.1", + "bullmq": "^5.73.0", + "dotenv": "^17.4.1", + "express": "^5.2.1", + "express-rate-limit": "^8.3.2", + "ioredis": "^5.10.1", + "mqtt": "^5.15.1" +} +``` diff --git a/backend-jamur-update/package-lock.json b/backend-jamur-update/package-lock.json new file mode 100644 index 0000000..c45f6f0 --- /dev/null +++ b/backend-jamur-update/package-lock.json @@ -0,0 +1,2077 @@ +{ + "name": "broker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "broker", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@supabase/supabase-js": "^2.101.1", + "bullmq": "^5.73.0", + "dotenv": "^17.4.1", + "express": "^5.2.1", + "express-rate-limit": "^8.3.2", + "ioredis": "^5.10.1", + "mqtt": "^5.15.1" + }, + "devDependencies": { + "nodemon": "^3.1.14" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/auth-js": { + "version": "2.101.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.101.1.tgz", + "integrity": "sha512-Kd0Wey+RkFHgyVep7adS6UOE2pN6MJ3mZ32PAXSvfw6IjUkFRC7IQpdZZjUOcUe5pXr1ejufCRgF6lsGINe4Tw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.101.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.101.1.tgz", + "integrity": "sha512-OZWU7YtaG+NNNFZK8p/FuJ6gpq7pFyrG2fLOopP73HAIDHDGpOttPJapvO8ADu3RkqfQfkwrB354vPkSBbZ20A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz", + "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.101.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.101.1.tgz", + "integrity": "sha512-UW1RajH5jbZoK+ldAJ1I6VZ+HWwZ2oaKjEQ6Gn+AQ67CHQVxGl8wNQoLYyumbyaExm41I+wn7arulcY1eHeZJw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.101.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.101.1.tgz", + "integrity": "sha512-Oa6dno0OB9I+hv5do5zsZHbFu41ViZnE9IWjmkeeF/8fPmB5fWoHGqeTYEC3/0DAgtpUoFJa4FpvzFH0SBHo1Q==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.0", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.101.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.101.1.tgz", + "integrity": "sha512-WhTaUOBgeEvnKLy95Cdlp6+D5igSF/65yC727w1olxbet5nzUvMlajKUWyzNtQu2efrz2cQ7FcdVBdQqgT9YKQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.101.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.101.1.tgz", + "integrity": "sha512-Jnhm3LfuACwjIzvk2pfUbGQn7pa7hi6MFzfSyPrRYWVCCu69RPLCFyHSBl7HSBwadbQ3UZOznnD3gPca3ePrRA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.101.1", + "@supabase/functions-js": "2.101.1", + "@supabase/postgrest-js": "2.101.1", + "@supabase/realtime-js": "2.101.1", + "@supabase/storage-js": "2.101.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", + "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/broker-factory": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.14.tgz", + "integrity": "sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bullmq": { + "version": "5.73.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.73.0.tgz", + "integrity": "sha512-uX8RbQaBbzk0H9JYXKGrNxpDqFcDBQFFKCyKarMjtfYHuct5X48M2LUq3Q9FXt/P2kWzPrqYlNnNqsico7ty5A==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-unique-numbers": { + "version": "9.0.27", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", + "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mqtt": { + "version": "5.15.1", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz", + "integrity": "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "number-allocator": "^1.0.14", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/worker-factory": { + "version": "7.0.49", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.49.tgz", + "integrity": "sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.31", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.31.tgz", + "integrity": "sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.16", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.16.tgz", + "integrity": "sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "broker-factory": "^3.1.14", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.14", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.14.tgz", + "integrity": "sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/backend-jamur-update/package.json b/backend-jamur-update/package.json new file mode 100644 index 0000000..36a0034 --- /dev/null +++ b/backend-jamur-update/package.json @@ -0,0 +1,25 @@ +{ + "name": "broker", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@supabase/supabase-js": "^2.101.1", + "bullmq": "^5.73.0", + "dotenv": "^17.4.1", + "express": "^5.2.1", + "express-rate-limit": "^8.3.2", + "ioredis": "^5.10.1", + "mqtt": "^5.15.1" + }, + "devDependencies": { + "nodemon": "^3.1.14" + } +} diff --git a/backend-jamur-update/src/index.js b/backend-jamur-update/src/index.js new file mode 100644 index 0000000..5c07f2a --- /dev/null +++ b/backend-jamur-update/src/index.js @@ -0,0 +1,98 @@ +/** + * ============================================================================= + * ENTRY POINT — Titik Masuk Aplikasi Backend + * ============================================================================= + * File PERTAMA yang berjalan saat server dinyalakan. + * Tugasnya: setup semua komponen (Express, MQTT, Worker, Rate Limiter) + * dan menghubungkan semuanya menjadi satu aplikasi yang siap melayani request. + * + * PENTING — Urutan startup sangat penting: + * 1. connect() -> Koneksi MQTT dijalankan PERTAMA karena Worker butuh MQTT. + * 2. require Worker -> Worker diaktifkan SETELAH MQTT siap, agar tidak error + * ketika Worker mencoba publishRelay di job pertamanya. + * 3. restoreSchedules() -> Baca jadwal aktif dari DB dan daftarkan ulang ke BullMQ. + * Ini menjamin jadwal tidak hilang setelah server restart/redeploy. + * 4. Baru setelah itu Express & semua route dijalankan. + * + * Kalau urutannya salah (misal Worker dijalankan sebelum MQTT connect), + * penyiraman pertama bisa gagal karena client MQTT belum siap. + * ============================================================================= + */ + +require('dotenv').config() // Load semua variabel dari file .env ke process.env +const express = require('express') +const rateLimit = require('express-rate-limit') +const { initFailover } = require('./utils/failoverManager') + +// Inisialisasi failover dinamis (menggantikan startup statis Langkah 1-4) +initFailover() + +// LANGKAH 5: Inisialisasi aplikasi Express +const app = express() + +// Percayai layer proxy di depan aplikasi secara dinamis. +// Jika di VPS (Backup) dibatasi 1 layer proxy (Nginx). +// Jika di Railway (Primary) dibatasi 2 layer proxy (Nginx di VPS -> Edge Proxy Railway -> Node.js). +const trustProxyLayers = process.env.IS_BACKUP_SERVER === 'true' ? 1 : 2 +app.set('trust proxy', trustProxyLayers) + +app.use(express.json()) // Supaya server bisa membaca body request dalam format JSON + +// Middleware logger sederhana untuk membantu memantau request di PM2 logs (terutama saat failover) +app.use((req, res, next) => { + console.log(`[HTTP] ${req.method} ${req.url}`) + next() +}) + +// ============================================================================= +// RATE LIMITER — Pelindung dari request berlebihan +// ============================================================================= +// Rate limiter global: berlaku untuk semua endpoint /api +// Maksimal 100 request per menit dari satu IP address yang sama +const globalLimiter = rateLimit({ + windowMs: 60 * 1000, // Window waktu: 1 menit + max: 100, // Maks 100 request per window + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Terlalu banyak request, coba lagi dalam 1 menit.' }, +}) + +// Rate limiter ketat khusus untuk endpoint siram manual +// Dibatasi hanya 5x per menit untuk mencegah penyiraman berlebihan yang +// bisa merusak tanaman atau menguras air terlalu cepat +const manualIrrigationLimiter = rateLimit({ + windowMs: 60 * 1000, // Window waktu: 1 menit + max: 5, // Maks 5 kali siram manual per menit + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Terlalu sering menyiram, tunggu sebentar.' }, +}) +// ============================================================================= + +// Terapkan rate limiter global ke semua route /api +app.use('/api', globalLimiter) + +// ============================================================================= +// ROUTE REGISTRATION — Daftarkan semua endpoint API +// ============================================================================= +app.use('/api/threshold', require('./routes/threshold')) // Atur batas suhu & kelembapan +app.use('/api/history', require('./routes/history')) // Lihat riwayat data sensor +app.use('/api/schedule/:deviceId/now', manualIrrigationLimiter) // Extra ketat untuk siram manual (HARUS sebelum route schedule!) +app.use('/api/schedule', require('./routes/schedule')) // Kelola jadwal penyiraman +app.use('/api/device', require('./routes/device')) // Klaim & kelola perangkat +app.use('/api/mode', require('./routes/mode')) // Kontrol mode operasi ESP32 (auto/manual/offline) +app.use('/api/latency', require('./routes/latency')) // Tes latensi dari backend asli ke MQTT broker +// ============================================================================= + +// Endpoint health check untuk platform deployment seperti Fly.io / Railway +app.get('/', (req, res) => { + res.status(200).send('OK'); +}); + +// Railway secara otomatis menyediakan variabel PORT via environment variable. +// Jangan hardcode port! Gunakan process.env.PORT agar server bisa berjalan di Railway. +// Fallback ke 3000 hanya untuk development lokal. +const PORT = process.env.PORT || 3000 +app.listen(PORT, () => { + console.log(`[Server] Running on port ${PORT}`) +}) \ No newline at end of file diff --git a/backend-jamur-update/src/jobs/offlineDetector.js b/backend-jamur-update/src/jobs/offlineDetector.js new file mode 100644 index 0000000..620a346 --- /dev/null +++ b/backend-jamur-update/src/jobs/offlineDetector.js @@ -0,0 +1,63 @@ +const supabase = require('../supabase/client'); +const { sendNotification } = require('../utils/notification'); + +// Jalankan pengecekan setiap 5 menit +const CHECK_INTERVAL_MS = 5 * 60 * 1000; + +let intervalId = null; + +function startOfflineDetector() { + if (intervalId) return; // Menghindari multiple intervals + intervalId = setInterval(async () => { + try { + // console.log('[OfflineDetector] Mengecek perangkat offline...'); + + // Waktu 5 menit yang lalu (di Supabase pakai UTC) + const fiveMinsAgo = new Date(Date.now() - CHECK_INTERVAL_MS).toISOString(); + + // Cari perangkat yang is_online = true TAPI last_seen < 5 menit yang lalu + const { data: devices, error } = await supabase + .from('devices') + .select('device_id, claimed_by, last_seen') + .eq('is_online', true) + .lt('last_seen', fiveMinsAgo); + + if (error) throw error; + + for (const device of devices) { + console.log(`[OfflineDetector] Perangkat ${device.device_id} terdeteksi OFFLINE!`); + + // 1. Update status di DB jadi offline + await supabase + .from('devices') + .update({ is_online: false }) + .eq('device_id', device.device_id); + + // 2. Kirim Notifikasi OneSignal + // Cooldown 1 jam (3600 detik) di-handle otomatis oleh sendNotification + if (device.claimed_by) { + sendNotification( + device.claimed_by, + 'Perangkat Offline! 🚨', + `Perangkat IoT Kumbung (${device.device_id}) terputus dari jaringan atau mati listrik.`, + 3600 + ); + } + } + } catch (error) { + console.error('[OfflineDetector] Error:', error.message); + } + }, CHECK_INTERVAL_MS); + + console.log('[OfflineDetector] Aktif. Pengecekan perangkat offline berjalan setiap 5 menit.'); +} + +function stopOfflineDetector() { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + console.log('[OfflineDetector] Dinonaktifkan.'); + } +} + +module.exports = { startOfflineDetector, stopOfflineDetector }; diff --git a/backend-jamur-update/src/mqtt/mqttClient.js b/backend-jamur-update/src/mqtt/mqttClient.js new file mode 100644 index 0000000..9e9c8e1 --- /dev/null +++ b/backend-jamur-update/src/mqtt/mqttClient.js @@ -0,0 +1,442 @@ +/** + * ============================================================================= + * MQTT CLIENT — Jembatan Komunikasi ke ESP32 + * ============================================================================= + * File ini mengurus semua komunikasi antara server Node.js dan perangkat ESP32 + * menggunakan protokol MQTT melalui HiveMQ Cloud. + * + * MQTT itu apa? + * MQTT adalah protokol pesan ringan yang populer di dunia IoT. Cara kerjanya + * seperti sistem "publish-subscribe": ada yang kirim pesan (publish) ke sebuah + * "topik", dan ada yang langganan (subscribe) topik itu untuk menerimanya. + * + * Di sistem ini: + * - ESP32 → PUBLISH data sensor ke topik 'sensor/sht31' + * - Server → SUBSCRIBE ke 'sensor/sht31' untuk menerima data tersebut + * - Server → PUBLISH perintah ke 'cmd/relay/{deviceId}' untuk nyala/matiin pompa + * - Server → PUBLISH config ke 'config/threshold' saat threshold diubah + * + * Fitur penting di file ini: IN-MEMORY CACHE untuk threshold. + * Tanpa cache: setiap data sensor masuk (bisa 1x/detik!) → query ke Supabase. + * Dengan cache: data threshold disimpan di memori, query DB hanya tiap 30 detik. + * Ini menghemat kuota database secara signifikan. + * ============================================================================= + */ + +const mqtt = require('mqtt') +const supabase = require('../supabase/client') +const { sendNotification } = require('../utils/notification') + +// Daftar topik MQTT yang digunakan dalam sistem ini +const TOPIC_SENSOR = 'sensor/sht31' // Topik untuk menerima data dari ESP32 +const TOPIC_THRESHOLD_BASE = 'config/threshold' // Topik dasar untuk kirim setting threshold ke ESP32 +const TOPIC_RELAY = 'cmd/relay' // Topik dasar untuk kontrol relay +const TOPIC_MODE = 'cmd/mode' // Topik dasar untuk mengirim perintah ganti mode ke ESP32 + +// ============================================================================= +// IN-MEMORY CACHE — Penyimpanan Sementara di Memori Server +// ============================================================================= +// Map ini menyimpan data threshold tiap device agar tidak perlu query DB terus-menerus. +// Format isi Map: { deviceId → { temp_min, temp_max, hum_max, cachedAt } } +const thresholdCache = new Map() +const CACHE_TTL_MS = 30 * 1000 // 30 detik dalam milidetik + +/** + * Ambil data threshold dari cache. Kalau cache kosong atau sudah kadaluarsa, + * baru query ke database Supabase, lalu simpan hasilnya ke cache. + * + * @param {string} deviceId - ID perangkat ESP32 + * @returns {Object|null} Objek { temp_min, temp_max, hum_max } atau null jika gagal + */ +async function getCachedThreshold(deviceId) { + const cached = thresholdCache.get(deviceId) + + // Cek apakah cache masih valid (belum kadaluarsa) + if (cached && (Date.now() - cached.cachedAt) < CACHE_TTL_MS) { + return cached // cache hit — tidak perlu query DB + } + + // Cache miss atau sudah expired — ambil data segar dari Supabase + const { data, error } = await supabase + .from('thresholds') + .select('temp_min, temp_max, hum_max') + .eq('device_id', deviceId) + .limit(1) + .maybeSingle() + + if (error) { + console.error('[Cache] Gagal baca threshold:', error.message) + return null + } + + if (!data) { + console.warn(`[Cache] Threshold belum disetel untuk ${deviceId}`) + return null + } + + // Fallback temp_min ke 20.0 jika di db nilainya null + const temp_min = (data.temp_min === undefined || data.temp_min === null) ? 20.0 : data.temp_min; + + // Simpan hasil query ke cache beserta timestamp saat ini + thresholdCache.set(deviceId, { + temp_min, + temp_max: data.temp_max, + hum_max: data.hum_max, + cachedAt: Date.now() + }) + console.log(`[Cache] Threshold ${deviceId} diperbarui dari DB`) + return { temp_min, temp_max: data.temp_max, hum_max: data.hum_max } +} +// ============================================================================= + +// ============================================================================= +// IN-MEMORY CACHE UNTUK SMART FILTER (DEADBAND) & STATUS +// ============================================================================= +// Map ini mencatat status terakhir agar tidak spam ke Supabase +const sensorLogCache = new Map() // { temp, hum, relay_state, mode, lastSavedAt } +const lastSeenCache = new Map() // timestamp terakhir kali device laporan online +const pendingPings = new Map() // Menyimpan callback untuk pencocokan pingId latency test + +const TEMP_DELTA = 0.5; // Perubahan suhu minimal untuk disimpan +const HUM_DELTA = 1.0; // Perubahan kelembapan minimal untuk disimpan +const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 menit (jaga grafik tidak putus) +const LAST_SEEN_INTERVAL_MS = 1 * 60 * 1000; // 1 menit (throttle status online) +// ============================================================================= + +// Variabel untuk menyimpan instance koneksi MQTT (digunakan di seluruh file ini) +let client + +/** + * Memulai koneksi ke MQTT broker (HiveMQ Cloud). + * Fungsi ini dipanggil PERTAMA KALI saat server start di index.js, + * sebelum Worker dijalankan, agar MQTT siap saat Worker butuh publishRelay. + */ +function connect() { + if (client) { + console.warn('[MQTT] Client sudah terhubung. Mengabaikan pemanggilan connect() baru.') + return + } + // Buat koneksi ke HiveMQ menggunakan kredensial dari .env + client = mqtt.connect(process.env.MQTT_BROKER_URL, { + port: parseInt(process.env.MQTT_PORT) || 8883, + username: process.env.MQTT_USERNAME, + password: process.env.MQTT_PASSWORD, + protocol: 'mqtts', // wajib TLS untuk HiveMQ Cloud (koneksi terenkripsi) + clientId: `nodejs-backend-${Date.now()}`, // ID unik agar tidak bentrok + clean: true, + reconnectPeriod: 5000, // Coba reconnect tiap 5 detik jika koneksi putus + }) + + // Event: Berhasil terhubung ke broker + client.on('connect', () => { + console.log('[MQTT] Terhubung ke HiveMQ Cloud') + // Mulai "dengarkan" data sensor dari ESP32 + client.subscribe(TOPIC_SENSOR, { qos: 1 }) + // Mulai "dengarkan" status keaktifan perangkat (LWT) + client.subscribe('status/+', { qos: 1 }) + // Mulai "dengarkan" pesan ping latensi + client.subscribe('latency/test/ping', { qos: 1 }) + }) + + /** + * Event: Ada pesan masuk dari topik yang di-subscribe. + * Ini adalah inti dari pemrosesan data sensor. + * Alur: Terima data → Simpan ke DB → Cek threshold → Kontrol relay + */ + client.on('message', async (topic, payload) => { + try { + // Proses ping latensi loopback backend + if (topic === 'latency/test/ping') { + try { + const data = JSON.parse(payload.toString()) + const { pingId } = data + const callback = pendingPings.get(pingId) + if (callback) { + callback(Date.now()) + pendingPings.delete(pingId) + } + } catch (e) {} + return + } + + // Proses pesan status LWT (status/+) + if (topic.startsWith('status/')) { + const device_id = topic.split('/')[1] + const status = payload.toString().trim() // "online" atau "offline" + console.log(`[MQTT] [LWT] Perangkat ${device_id} berstatus: ${status}`) + + const isOnline = status === 'online' + + // Update status di Supabase + const { data, error } = await supabase.from('devices') + .update({ + is_online: isOnline, + last_seen: new Date().toISOString() + }) + .eq('device_id', device_id) + .select('claimed_by') + .maybeSingle() + + if (error) { + console.error(`[MQTT] [LWT] Gagal update status ${device_id} ke DB:`, error.message) + } else { + console.log(`[MQTT] [LWT] Berhasil update status ${device_id} ke DB -> is_online: ${isOnline}`) + if (!isOnline && data && data.claimed_by) { + sendNotification( + data.claimed_by, + 'Perangkat Offline! 🚨', + `Perangkat IoT Kumbung (${device_id}) terputus dari jaringan atau mati listrik (LWT).`, + 3600 // Cooldown 1 jam + ); + } + } + return + } + + // Hanya proses pesan dari topik sensor, abaikan topik lain + if (topic !== TOPIC_SENSOR) return + + // Parsing payload dari format JSON ke objek JavaScript + let data + try { + data = JSON.parse(payload.toString()) + } catch { + return console.error('[MQTT] Payload tidak valid JSON') + } + + // Destructure data sensor. Jika ESP32 tidak kirim device_id, pakai default 'esp32-01' + // relay dikirim ESP32 sebagai boolean (true = ON, false = OFF) pada key 'relay' atau 'relay_state' + // mode dikirim ESP32 sebagai string (contoh: "auto", "auto-on", "cooldown", "schedule-on") + const { temp, hum, mode, device_id = 'esp32-01' } = data + const relay_state = data.relay_state ?? data.relay ?? false; + console.log(`[MQTT] [${device_id}] Sensor: ${temp}°C | ${hum}% | Relay: ${relay_state}`) + + const now = Date.now(); + + // Langkah 1A: Update status online (last_seen) dengan throttle 1 menit + const lastSeen = lastSeenCache.get(device_id) || 0; + if (now - lastSeen > LAST_SEEN_INTERVAL_MS) { + // Kita tidak perlu await di sini agar tidak memblokir proses peringatan (Push Notif) + supabase.from('devices') + .update({ is_online: true, last_seen: new Date().toISOString() }) + .eq('device_id', device_id) + .then(({ error }) => { + if (error) console.error('[MQTT] Gagal update last_seen:', error.message); + else lastSeenCache.set(device_id, now); + }); + } + + // Langkah 1B: Smart Filter untuk menyimpan data ke tabel sensor_logs + let shouldSaveData = false; + const lastData = sensorLogCache.get(device_id); + + // Pastikan mode tidak pernah null agar Flutter app tidak crash (terpental) + const safeMode = mode ?? (lastData?.mode ?? 'auto'); + + if (!lastData) { + shouldSaveData = true; // Simpan jika belum ada riwayat di memori server + } else { + const tempDiff = Math.abs(temp - lastData.temp); + const humDiff = Math.abs(hum - lastData.hum); + const timeDiff = now - lastData.lastSavedAt; + const relayChanged = relay_state !== lastData.relay_state; + const modeChanged = safeMode !== lastData.mode; + + // Logika Smart Filter (Deadband) + if (tempDiff > TEMP_DELTA || humDiff > HUM_DELTA || relayChanged || modeChanged || timeDiff > HEARTBEAT_INTERVAL_MS) { + shouldSaveData = true; + } + } + + if (shouldSaveData) { + const { error: insertErr } = await supabase.from('sensor_logs').insert({ + device_id, + temperature: temp, + humidity: hum, + relay_state: relay_state ?? false, + mode: safeMode, // Simpan snapshot mode ESP32 saat data dikirim + }) + if (insertErr) { + console.error('[MQTT] Gagal simpan sensor log:', insertErr.message) + } else { + // Catat ke memori setelah berhasil insert + sensorLogCache.set(device_id, { + temp, + hum, + relay_state: relay_state ?? false, + mode: safeMode, + lastSavedAt: now + }); + } + } + + // Langkah 2: Baca threshold dari cache (hanya untuk memastikan cache tersimpan, logika auto diurus ESP32) + const threshold = await getCachedThreshold(device_id) + + // Langkah 3: Pengecekan Suhu & Kelembapan untuk Push Notification + // Anti-spam (cooldown 30 menit) kini diurus otomatis oleh sendNotification. + if (threshold) { + const alerts = [] + let isCold = false + let isHot = false + let isDry = false + + if (hum < threshold.hum_max) { + alerts.push(`Kelembapan saat ini ${hum}% (Batas minimal: ${threshold.hum_max}%)`) + isDry = true + } + if (temp > threshold.temp_max) { + alerts.push(`Suhu saat ini ${temp}°C (Batas maksimal: ${threshold.temp_max}°C)`) + isHot = true + } else if (temp < threshold.temp_min) { + alerts.push(`Suhu saat ini ${temp}°C (Batas minimal: ${threshold.temp_min}°C)`) + isCold = true + } + + if (alerts.length > 0) { + let alertMsg = '' + if (alerts.length >= 2) { + alertMsg = `Peringatan Kumbung! ${alerts.join(' dan ')}` + } else if (isCold) { + alertMsg = `Peringatan Dingin! ${alerts[0]}` + } else if (isDry) { + alertMsg = `Peringatan Kering! ${alerts[0]}` + } else if (isHot) { + alertMsg = `Peringatan Panas! ${alerts[0]}` + } + + // Cari user yang memiliki alat ini + const { data: device } = await supabase + .from('devices') + .select('claimed_by') + .eq('device_id', device_id) + .single() + + if (device && device.claimed_by) { + // Cooldown 30 menit (1800 detik) di-handle oleh sendNotification + sendNotification(device.claimed_by, 'Peringatan Sensor Kumbung ⚠️', alertMsg, 1800) + } + } + } + } catch (globalErr) { + console.error('[MQTT] Kesalahan tidak terduga saat memproses pesan:', globalErr.message) + } + }) + + // Event: Terjadi error pada koneksi MQTT + client.on('error', err => console.error('[MQTT] Error:', err.message)) + + // Event: Koneksi terputus — otomatis akan mencoba reconnect sesuai reconnectPeriod + client.on('offline', () => console.warn('[MQTT] Koneksi terputus, mencoba reconnect...')) +} + +/** + * Memutuskan koneksi MQTT secara aman (digunakan saat failover kembali ke standby). + */ +function disconnect() { + if (client) { + client.end(true, () => { + console.log('[MQTT] Terputus secara aman (dinonaktifkan oleh failover)') + }) + client = null + } +} + +/** + * Kirim nilai threshold terbaru ke ESP32 via MQTT. + * Dipanggil otomatis saat user update threshold melalui API. + * Sekaligus memperbarui cache agar nilai baru langsung efektif. + * + * @param {string} deviceId - ID perangkat target + * @param {number} tempMin - Batas minimum suhu (°C) + * @param {number} tempMax - Batas maksimum suhu (°C) + * @param {number} humMax - Batas maksimum kelembapan (%) + */ +function publishThreshold(deviceId, tempMin, tempMax, humMax) { + const payload = JSON.stringify({ + temp_min: tempMin, + temp_max: tempMax, + temp: tempMax, // backward compatibility + hum: humMax + }) + // retain: true → ESP32 yang baru connect akan langsung dapat nilai threshold terkini + if (client) { + client.publish(`${TOPIC_THRESHOLD_BASE}/${deviceId}`, payload, { qos: 1, retain: true }) + } else { + console.warn(`[MQTT] Terputus. Gagal publish threshold untuk ${deviceId} (Server Standby)`) + } + + // Perbarui cache langsung agar nilai baru efektif tanpa harus tunggu 30 detik + thresholdCache.set(deviceId, { temp_min: tempMin, temp_max: tempMax, hum_max: humMax, cachedAt: Date.now() }) + console.log(`[Cache] Threshold ${deviceId} diperbarui dari API`) +} + +/** + * Kirim perintah nyala atau mati ke relay ESP32. + * Topik yang digunakan bersifat dinamis per device: cmd/relay/{deviceId} + * Sehingga jika ada banyak device, perintah tidak tercampur. + * + * @param {string} deviceId - ID perangkat target + * @param {'ON'|'OFF'} state - Perintah yang dikirim + */ +function publishRelay(deviceId, state) { + if (client) { + client.publish(`${TOPIC_RELAY}/${deviceId}`, state, { qos: 1 }) + console.log(`[MQTT] Relay ${deviceId} → ${state}`) + } else { + console.warn(`[MQTT] Terputus. Gagal kirim perintah relay ${state} ke ${deviceId} (Server Standby)`) + } +} + +/** + * Kirim perintah ganti mode operasi ke ESP32. + * ESP32 akan berpindah ke mode target dan menjalankan logika yang sesuai. + * + * @param {string} deviceId - ID perangkat target + * @param {'auto'|'manual'|'offline'} mode - Mode yang ingin diaktifkan + */ +function publishMode(deviceId, mode) { + if (client) { + client.publish(`${TOPIC_MODE}/${deviceId}`, mode, { qos: 1 }) + console.log(`[MQTT] Mode ${deviceId} → ${mode}`) + } else { + console.warn(`[MQTT] Terputus. Gagal kirim perintah mode ${mode} ke ${deviceId} (Server Standby)`) + } +} + +/** + * Mengukur latensi RTT (Round Trip Time) antara server backend ini dengan MQTT Broker HiveMQ Cloud. + * @returns {Promise} Latensi RTT dalam milidetik + */ +function measureLatency() { + return new Promise((resolve, reject) => { + if (!client || !client.connected) { + return reject(new Error('Klien MQTT tidak terhubung ke broker')) + } + + const pingId = Math.random().toString(36).substring(7) + const startTime = Date.now() + + // Batasi tunggu maksimal 5 detik + const timeout = setTimeout(() => { + pendingPings.delete(pingId) + reject(new Error('Pengujian latensi MQTT timeout (5 detik)')) + }, 5000) + + pendingPings.set(pingId, (endTime) => { + clearTimeout(timeout) + resolve(endTime - startTime) + }) + + const payload = JSON.stringify({ pingId }) + client.publish('latency/test/ping', payload, { qos: 1 }, (err) => { + if (err) { + clearTimeout(timeout) + pendingPings.delete(pingId) + reject(err) + } + }); + }); +} + +module.exports = { connect, disconnect, publishThreshold, publishRelay, publishMode, measureLatency } \ No newline at end of file diff --git a/backend-jamur-update/src/queues/irrigationQueue.js b/backend-jamur-update/src/queues/irrigationQueue.js new file mode 100644 index 0000000..dd66648 --- /dev/null +++ b/backend-jamur-update/src/queues/irrigationQueue.js @@ -0,0 +1,34 @@ +/** + * ============================================================================= + * IRRIGATION QUEUE — Antrian Penyiraman + * ============================================================================= + * File ini mendefinisikan "antrian" penyiraman menggunakan BullMQ. + * + * Analogi sederhana: + * Bayangkan antrian ini seperti "daftar tugas" yang disimpan di Redis. + * Saat kamu minta "siram sekarang" atau "jadwalkan siram jam 6 pagi", + * permintaan itu tidak langsung dieksekusi — tapi dulu didaftarkan ke antrian ini. + * Nanti si Worker (irrigationWorker.js) yang akan mengambil dan mengeksekusinya. + * + * Kenapa pakai antrian dan tidak langsung eksekusi saja? + * - Lebih aman: kalau server mati tiba-tiba, job tidak hilang karena tersimpan di Redis. + * - Lebih teratur: tidak ada dua job siram yang jalan bersamaan secara tidak terkontrol. + * - Mendukung jadwal (cron): BullMQ bisa menjalankan tugas berulang sesuai pola waktu. + * + * File ini di-import oleh: + * - routes/schedule.js — untuk mendaftarkan jadwal baru atau siram manual + * - irrigationWorker.js — untuk tahu ke queue mana ia harus "mendengarkan" + * ============================================================================= + */ + +const { Queue } = require('bullmq') + +// Koneksi ke Redis menggunakan URL dari .env +// Redis berfungsi sebagai "otak" BullMQ: tempat menyimpan semua data antrian +const connection = { url: process.env.REDIS_URL } + +// Buat queue dengan nama 'irrigation' +// Nama ini harus sama persis dengan yang dipakai di Worker! +const irrigationQueue = new Queue('irrigation', { connection }) + +module.exports = { irrigationQueue, connection } \ No newline at end of file diff --git a/backend-jamur-update/src/queues/irrigationWorker.js b/backend-jamur-update/src/queues/irrigationWorker.js new file mode 100644 index 0000000..9661094 --- /dev/null +++ b/backend-jamur-update/src/queues/irrigationWorker.js @@ -0,0 +1,103 @@ +/** + * ============================================================================= + * IRRIGATION WORKER — Eksekutor Penyiraman + * ============================================================================= + * File ini adalah "pekerja" yang secara aktif memantau antrian irrigationQueue + * dan mengeksekusi setiap job penyiraman yang masuk. + * + * Cara kerjanya: + * 1. Worker terus "mendengarkan" antrian 'irrigation' di Redis. + * 2. Saat ada job masuk (baik dari jadwal cron maupun trigger manual), + * Worker langsung mengambil dan menjalankannya. + * 3. Pertama, Worker kirim perintah relay ON ke ESP32 via MQTT -> pompa menyala. + * 4. Worker menunggu selama `durationSeconds` detik (waktu penyiraman). + * 5. Setelah waktu habis, Worker kirim perintah relay OFF -> pompa mati. + * + * Penting: Worker harus diinisialisasi SETELAH MQTT sudah terkoneksi, + * karena worker butuh fungsi publishRelay dari mqttClient. + * Urutan startup diatur di src/index.js. + * ============================================================================= + */ + +const { Worker } = require('bullmq') +const { connection } = require('./irrigationQueue') +const { publishRelay } = require('../mqtt/mqttClient') +const { sendNotification } = require('../utils/notification') +const supabase = require('../supabase/client') + +/** + * Definisi Worker untuk queue 'irrigation'. + * Setiap kali ada job masuk, fungsi async di bawah ini akan dijalankan. + * + * @param {Object} job - Objek job dari BullMQ + * @param {string} job.data.deviceId - ID perangkat ESP32 yang akan disiram + * @param {number} job.data.durationSeconds - Lama penyiraman dalam detik + */ +const worker = new Worker('irrigation', async (job) => { + const { deviceId, durationSeconds } = job.data + const nowUTC = new Date().toISOString() + const nowWIB = new Date(Date.now() + 7 * 60 * 60 * 1000).toISOString().replace('T', ' ').substring(0, 19) + + console.log(`[Worker] ============================================================`) + console.log(`[Worker] JOB DIMULAI — ID: ${job.id}`) + console.log(`[Worker] Device : ${deviceId}`) + console.log(`[Worker] Durasi : ${durationSeconds} detik`) + console.log(`[Worker] Waktu : ${nowWIB} WIB (${nowUTC} UTC)`) + console.log(`[Worker] Sumber : ${job.opts?.repeat?.cron ? 'Jadwal otomatis cron: ' + job.opts.repeat.cron : 'Siram manual'}`) + console.log(`[Worker] ============================================================`) + + // Langkah 1: Nyalakan pompa dengan kirim perintah ON ke relay ESP32 + publishRelay(deviceId, 'ON') + console.log(`[Worker] >> Relay ON dikirim ke ${deviceId}`) + + // Kirim Notifikasi OneSignal + try { + const { data: device } = await supabase + .from('devices') + .select('claimed_by') + .eq('device_id', deviceId) + .single() + + if (device && device.claimed_by) { + const isAuto = job.opts?.repeat?.cron + sendNotification( + device.claimed_by, + 'Penyiraman Dimulai 💦', + `Pompa menyala selama ${durationSeconds} detik via ${isAuto ? 'Jadwal Otomatis' : 'Sistem'}.` + ) + } + } catch (e) { + console.error('[Worker] Gagal mengirim notifikasi penyiraman:', e.message) + } + + // Langkah 2: Tunggu selama durasi yang ditentukan + // Ini tidak memblokir server karena berjalan di proses worker yang terpisah + await new Promise(resolve => setTimeout(resolve, durationSeconds * 1000)) + + // Langkah 3: Matikan pompa setelah durasi selesai + publishRelay(deviceId, 'OFF') + console.log(`[Worker] >> Relay OFF dikirim ke ${deviceId}`) + console.log(`[Worker] JOB SELESAI — ${deviceId} | durasi: ${durationSeconds}s`) + +}, { connection }) + +// ── Event Handlers ────────────────────────────────────────────── + +worker.on('completed', (job) => { + console.log(`[Worker] Job ${job.id} berhasil diselesaikan.`) +}) + +worker.on('failed', (job, err) => { + console.error(`[Worker] Job ${job?.id} GAGAL:`, err.message) + console.error(`[Worker] Stack:`, err.stack) +}) + +worker.on('error', (err) => { + console.error(`[Worker] Worker error:`, err.message) +}) + +worker.on('ready', () => { + console.log(`[Worker] Worker siap memproses antrian 'irrigation'`) +}) + +module.exports = worker \ No newline at end of file diff --git a/backend-jamur-update/src/queues/scheduleRestore.js b/backend-jamur-update/src/queues/scheduleRestore.js new file mode 100644 index 0000000..ea50f6a --- /dev/null +++ b/backend-jamur-update/src/queues/scheduleRestore.js @@ -0,0 +1,84 @@ +/** + * ============================================================================= + * SCHEDULE RESTORE — Pemulihan Jadwal Saat Server Start + * ============================================================================= + * Masalah yang dipecahkan: + * BullMQ menyimpan repeatable job di Redis, tapi jika terjadi: + * - Server restart (Railway redeploy) + * - Migrasi Redis + * - Format key berubah (misal upgrade BullMQ dari v4 ke v5) + * ...maka job di BullMQ bisa hilang atau tidak sinkron dengan database. + * + * Solusinya: Setiap kali server start, kita: + * 1. Hapus SEMUA repeatable job lama dari BullMQ (bersih-bersih) + * 2. Baca semua jadwal is_active=true dari Supabase + * 3. Daftarkan ulang ke BullMQ dengan format yang benar (BullMQ v5+: key 'cron') + * + * Ini menjamin sinkronisasi antara DB dan BullMQ setiap kali server hidup. + * ============================================================================= + */ + +const supabase = require('../supabase/client') +const { irrigationQueue } = require('./irrigationQueue') + +/** + * Restore semua jadwal aktif dari Supabase ke BullMQ. + * Dipanggil satu kali saat server startup di index.js. + */ +async function restoreSchedules() { + console.log('[Restore] Memulai pemulihan jadwal dari database...') + + try { + // Langkah 1: Bersihkan SEMUA repeatable job lama dari BullMQ + // Ini menghapus job yang mungkin tersimpan dengan format lama (pattern) + const existingRepeatables = await irrigationQueue.getRepeatableJobs() + console.log(`[Restore] Ditemukan ${existingRepeatables.length} job lama di BullMQ, membersihkan...`) + + for (const job of existingRepeatables) { + await irrigationQueue.removeRepeatableByKey(job.key) + } + console.log('[Restore] Job lama berhasil dihapus.') + + // Langkah 2: Ambil semua jadwal aktif dari Supabase + const { data: schedules, error } = await supabase + .from('schedules') + .select('*') + .eq('is_active', true) + + if (error) { + console.error('[Restore] Gagal mengambil jadwal dari DB:', error.message) + return + } + + if (!schedules || schedules.length === 0) { + console.log('[Restore] Tidak ada jadwal aktif untuk di-restore.') + return + } + + console.log(`[Restore] Mendaftarkan ulang ${schedules.length} jadwal aktif...`) + + // Langkah 3: Daftarkan ulang setiap jadwal ke BullMQ dengan format BullMQ v5+ + for (const schedule of schedules) { + try { + const job = await irrigationQueue.add( + 'irrigate', + { deviceId: schedule.device_id, durationSeconds: schedule.duration_s }, + { + repeat: { cron: schedule.cron }, // BullMQ v5+: key 'cron' + jobId: schedule.bull_job_id, + } + ) + console.log(`[Restore] ✓ Jadwal ${schedule.id} (${schedule.cron}) → Job ${job.id}`) + } catch (jobErr) { + console.error(`[Restore] ✗ Gagal restore jadwal ${schedule.id}:`, jobErr.message) + } + } + + console.log('[Restore] Pemulihan jadwal selesai.') + } catch (err) { + // Jangan sampai error di sini menghentikan server dari start + console.error('[Restore] Error tidak terduga saat restore jadwal:', err.message) + } +} + +module.exports = { restoreSchedules } diff --git a/backend-jamur-update/src/routes/device.js b/backend-jamur-update/src/routes/device.js new file mode 100644 index 0000000..eb1a043 --- /dev/null +++ b/backend-jamur-update/src/routes/device.js @@ -0,0 +1,146 @@ +/** + * ============================================================================= + * ROUTE: DEVICE — Klaim dan Manajemen Perangkat + * ============================================================================= + * File ini mengurus proses "pairing" antara akun user dan perangkat ESP32 fisik. + * + * Kenapa perlu proses klaim? + * Setiap ESP32 punya kode unik (claim_code) yang tercetak atau tertempel di box-nya. + * Saat user pertama kali daftar, mereka harus memasukkan kode ini untuk + * "mengklaim" perangkat tersebut sebagai milik mereka. + * Setelah diklaim, semua data dan perintah akan dikaitkan dengan device itu. + * + * Endpoint yang tersedia: + * - POST /api/device/claim → Klaim perangkat dengan kode unik + * - GET /api/device/my-device/:userId → Ambil info perangkat milik user + * ============================================================================= + */ + +const router = require('express').Router() +const supabase = require('../supabase/client') + +/** + * POST /api/device/claim + * ----------------------------------------------------------------------------- + * Menghubungkan perangkat ESP32 ke akun user berdasarkan kode klaim. + * + * Ada 4 pengecekan yang dilakukan secara berurutan: + * 1. Validasi input — pastikan claim_code dan user_id dikirim + * 2. Cari device — apakah kode ini terdaftar di database? + * 3. Cek kepemilikan — sudah diklaim orang lain atau belum? + * 4. Cek limit user — satu user hanya boleh punya satu device + * + * Body: { claim_code: string, user_id: string } + */ +router.post('/claim', async (req, res) => { + const { claim_code, user_id } = req.body + + // Pengecekan 1: Pastikan kedua field dikirim oleh client + if (!claim_code || !user_id) { + return res.status(400).json({ error: 'claim_code dan user_id wajib diisi' }) + } + + // Pengecekan 2: Cari device dengan kode ini di database + // claim_code selalu diubah ke huruf kapital agar tidak case-sensitive + const { data: device, error } = await supabase + .from('devices') + .select('id, device_id, claimed_by') + .eq('claim_code', claim_code.toUpperCase()) + .single() + + if (error || !device) { + return res.status(404).json({ error: 'Kode tidak ditemukan' }) + } + + // Pengecekan 3: Apakah device ini sudah diklaim oleh orang LAIN? + // Kalau yang mengklaim adalah user yang sama (misalnya retry), tetap diizinkan + if (device.claimed_by && device.claimed_by !== user_id) { + return res.status(409).json({ error: 'Device sudah diklaim oleh pengguna lain' }) + } + + // Pengecekan 4: Apakah user ini sudah punya device lain sebelumnya? + // Satu akun hanya boleh mengklaim satu perangkat + const { data: existing } = await supabase + .from('devices') + .select('device_id') + .eq('claimed_by', user_id) + .single() + + if (existing) { + return res.status(409).json({ error: 'Anda sudah memiliki device terdaftar' }) + } + + // Semua pengecekan lolos → Update database: tandai device sebagai milik user ini + const { data: updated } = await supabase + .from('devices') + .update({ claimed_by: user_id, claimed_at: new Date() }) + .eq('claim_code', claim_code.toUpperCase()) + .select('device_id, label, location') + .single() + + // 5. Buat data threshold default otomatis untuk device ini + // Mencegah error 500 saat user pertama kali mengatur batas sensor + if (updated?.device_id) { + const { error: thresholdErr } = await supabase + .from('thresholds') + .insert({ + device_id: updated.device_id, + temp_max: 30, + hum_max: 80, + }) + if (thresholdErr) { + console.warn('[Claim] Gagal membuat threshold default:', thresholdErr.message) + } else { + console.log(`[Claim] Threshold default dibuat untuk device: ${updated.device_id}`) + } + } + + // Kembalikan info device yang berhasil diklaim ke client (Flutter) + res.json({ message: 'Device berhasil diklaim', device: updated }) +}) + +/** + * GET /api/device/my-device/:userId + * ----------------------------------------------------------------------------- + * Mengambil informasi perangkat yang dimiliki oleh user tertentu. + * Biasanya dipanggil saat user login untuk mengetahui device_id mereka, + * lalu device_id itu digunakan di semua request API selanjutnya. + * + * Params: userId — UUID user dari Supabase Auth + */ +router.get('/my-device/:userId', async (req, res) => { + console.log(`[Device Route] Handler berjalan untuk user: ${req.params.userId}`) + const { data, error } = await supabase + .from('devices') + .select('device_id, label, location, is_online, last_seen') + .eq('claimed_by', req.params.userId) + .single() + + if (error) { + console.error('[Device Router] Gagal ambil my-device:', error.message) + return res.status(404).json({ error: 'Device tidak ditemukan' }) + } + res.json(data) +}) + +/** + * GET /api/device/status/:deviceId + * ----------------------------------------------------------------------------- + * Mengambil status keaktifan perangkat (is_online, last_seen) secara real-time. + * Params: deviceId — ID perangkat ESP32 + */ +router.get('/status/:deviceId', async (req, res) => { + const { data, error } = await supabase + .from('devices') + .select('is_online, last_seen') + .eq('device_id', req.params.deviceId) + .maybeSingle() + + if (error || !data) { + console.error('[Device Router] Gagal ambil status:', error ? error.message : 'Not found') + return res.status(404).json({ error: 'Device tidak ditemukan' }) + } + res.json(data) +}) + +module.exports = router \ No newline at end of file diff --git a/backend-jamur-update/src/routes/history.js b/backend-jamur-update/src/routes/history.js new file mode 100644 index 0000000..c1e64a8 --- /dev/null +++ b/backend-jamur-update/src/routes/history.js @@ -0,0 +1,109 @@ +/** + * ============================================================================= + * ROUTE: HISTORY — Riwayat Data Sensor + * ============================================================================= + * File ini mengurus pengambilan data historis dari sensor SHT31. + * Data historis berguna untuk: + * - Menampilkan grafik suhu & kelembapan di aplikasi Flutter + * - Melihat tren kondisi kumbung jamur dalam beberapa hari terakhir + * - Evaluasi apakah sistem otomasi bekerja dengan baik + * + * Endpoint yang tersedia: + * - GET /api/history/:deviceId → Data sensor terbaru (N data terakhir) + * - GET /api/history/:deviceId/daily → Rata-rata per hari (N hari terakhir) + * ============================================================================= + */ + +const router = require('express').Router() +const supabase = require('../supabase/client') + +/** + * GET /api/history/:deviceId?limit=100 + * ----------------------------------------------------------------------------- + * Mengambil data log sensor terbaru untuk sebuah device. + * Data diurutkan dari yang paling baru ke paling lama. + * + * Kenapa ada batas maksimum 500? + * Untuk mencegah query yang terlalu berat ke database, yang bisa memperlambat + * server dan menghabiskan kuota Supabase. Kalau butuh lebih banyak data, + * sebaiknya gunakan endpoint /daily dengan agregasi. + * + * Params: deviceId — ID perangkat + * Query: limit (opsional, default 100, maks 500) + * + * Contoh: GET /api/history/esp32-01?limit=50 + */ +router.get('/:deviceId', async (req, res) => { + console.log(`[History Route] Handler berjalan untuk device: ${req.params.deviceId}`) + // Math.min memastikan limit tidak melebihi 500, sekali pun user kirim nilai lebih besar + const limit = Math.min(parseInt(req.query.limit) || 100, 500) + + const { data, error } = await supabase + .from('sensor_logs') + .select('temperature, humidity, relay_state, mode, created_at') + .eq('device_id', req.params.deviceId) + .order('created_at', { ascending: false }) // Data terbaru di atas + .limit(limit) + + if (error) { + console.error('[History Router] Gagal ambil history:', error.message) + return res.status(500).json({ error: error.message }) + } + res.json(data) +}) + +/** + * GET /api/history/:deviceId/daily?days=7 + * ----------------------------------------------------------------------------- + * Mengambil data rata-rata suhu dan kelembapan per hari. + * Berguna untuk grafik tren jangka panjang tanpa data yang terlalu padat. + * + * Endpoint ini memanggil Stored Procedure (fungsi SQL) bernama 'get_daily_average' + * yang sudah didefinisikan di Supabase. Artinya proses agregasi dilakukan + * di sisi database (lebih efisien daripada di server Node.js). + * + * Params: deviceId — ID perangkat + * Query: days (opsional, default 7 hari) + * + * Contoh: GET /api/history/esp32-01/daily?days=14 + */ +router.get('/:deviceId/daily', async (req, res) => { + const { deviceId } = req.params + const days = parseInt(req.query.days) || 7 // default 7 hari terakhir + + // Panggil stored procedure di Supabase dengan parameter deviceId dan jumlah hari + const { data, error } = await supabase.rpc('get_daily_average', { + p_device_id: deviceId, + p_days: days, + }) + + if (error) return res.status(500).json({ error: error.message }) + res.json(data) +}) + +/** + * GET /api/history/:deviceId/hourly?days=7 + * ----------------------------------------------------------------------------- + * Mengambil rata-rata suhu dan kelembapan per jam. + * Digunakan untuk grafik harian yang menunjukkan data per jam di UI. + * Memanggil fungsi RPC 'get_hourly_average' di Supabase untuk performa yang lebih baik, + * terutama mengingat data sensor masuk sangat cepat (misal setiap 3 detik). + * + * Params: deviceId — ID perangkat + * Query: days (opsional, default 7 hari) + */ +router.get('/:deviceId/hourly', async (req, res) => { + const { deviceId } = req.params + const days = parseInt(req.query.days) || 7 + + // Panggil stored procedure di Supabase dengan parameter deviceId dan jumlah hari + const { data, error } = await supabase.rpc('get_hourly_average', { + p_device_id: deviceId, + p_days: days, + }) + + if (error) return res.status(500).json({ error: error.message }) + res.json(data) +}) + +module.exports = router \ No newline at end of file diff --git a/backend-jamur-update/src/routes/latency.js b/backend-jamur-update/src/routes/latency.js new file mode 100644 index 0000000..77b9683 --- /dev/null +++ b/backend-jamur-update/src/routes/latency.js @@ -0,0 +1,28 @@ +const router = require('express').Router() +const { measureLatency } = require('../mqtt/mqttClient') + +/** + * GET /api/latency + * ----------------------------------------------------------------------------- + * Mengukur RTT (Round-Trip Time) dari backend ke MQTT Broker (HiveMQ Cloud). + */ +router.get('/', async (req, res) => { + try { + const rtt = await measureLatency() + const oneWay = Math.round(rtt / 2) + res.json({ + success: true, + backend_to_broker_rtt_ms: rtt, + estimated_one_way_latency_ms: oneWay, + message: `Pengukuran berhasil. RTT: ${rtt} ms.` + }) + } catch (err) { + console.error('[Latency Route] Gagal mengukur latensi:', err.message) + res.status(500).json({ + success: false, + error: err.message + }) + } +}) + +module.exports = router diff --git a/backend-jamur-update/src/routes/mode.js b/backend-jamur-update/src/routes/mode.js new file mode 100644 index 0000000..b49d837 --- /dev/null +++ b/backend-jamur-update/src/routes/mode.js @@ -0,0 +1,151 @@ +/** + * ============================================================================= + * ROUTE: MODE — Kontrol Mode Operasi ESP32 + * ============================================================================= + * File ini mengurus pergantian mode operasi perangkat ESP32 secara remote. + * + * ESP32 mendukung 3 mode: + * - auto : Pompa menyala/mati otomatis berdasarkan pembacaan sensor SHT31 + * - manual : Pompa dikendalikan sepenuhnya oleh perintah dari aplikasi + * - offline : Mode darurat saat koneksi internet terputus (logika auto, tanpa MQTT publish) + * + * Cara Kerja: + * 1. Aplikasi Flutter memanggil POST /api/mode/:deviceId + * 2. Backend memvalidasi nilai mode + * 3. Backend mengirim perintah ke ESP32 via MQTT (cmd/mode/) + * 4. Backend menyimpan mode baru ke kolom current_mode di tabel devices + * 5. Backend mengirim notifikasi ke user bahwa mode telah berubah + * + * Endpoint yang tersedia: + * - GET /api/mode/:deviceId → Baca mode aktif saat ini dari database + * - POST /api/mode/:deviceId → Ubah mode operasi ESP32 + * ============================================================================= + */ + +const router = require('express').Router() +const supabase = require('../supabase/client') +const { publishMode } = require('../mqtt/mqttClient') +const { sendNotification } = require('../utils/notification') + +// Daftar nilai mode yang diizinkan (whitelist) +const VALID_MODES = ['auto', 'manual', 'offline'] + +// Label ramah untuk notifikasi +const MODE_LABELS = { + auto: 'AUTO 🤖 — Pompa dikendalikan sensor secara otomatis', + manual: 'MANUAL 🖐️ — Pompa dikendalikan langsung dari aplikasi', + offline: 'OFFLINE 📴 — Mode darurat tanpa koneksi cloud', +} + +/** + * GET /api/mode/:deviceId + * ----------------------------------------------------------------------------- + * Membaca mode operasi aktif dari database untuk sebuah device. + * Biasanya dipanggil saat aplikasi Flutter dibuka untuk menampilkan + * status mode yang sedang berjalan. + * + * Params: deviceId — ID perangkat, contoh: "esp32-new-05" + */ +router.get('/:deviceId', async (req, res) => { + const { data, error } = await supabase + .from('devices') + .select('device_id, current_mode, is_online, last_seen') + .eq('device_id', req.params.deviceId) + .single() + + if (error || !data) { + return res.status(404).json({ error: 'Device tidak ditemukan' }) + } + + res.json({ + device_id: data.device_id, + current_mode: data.current_mode ?? 'auto', // Fallback ke 'auto' jika kolom masih null + is_online: data.is_online, + last_seen: data.last_seen, + }) +}) + +/** + * POST /api/mode/:deviceId + * ----------------------------------------------------------------------------- + * Mengubah mode operasi ESP32 dan menyimpan perubahan ke database. + * + * Alur eksekusi: + * 1. Validasi nilai mode (hanya "auto", "manual", "offline" yang diterima) + * 2. Cek apakah device terdaftar dan dimiliki oleh seseorang + * 3. Kirim perintah mode baru ke ESP32 via MQTT + * 4. Update kolom current_mode di tabel devices di Supabase + * 5. Kirim notifikasi OneSignal ke pemilik device + * + * Params: deviceId — ID perangkat + * Body: { mode: "auto" | "manual" | "offline" } + */ +router.post('/:deviceId', async (req, res) => { + const { deviceId } = req.params + const { mode } = req.body + + // Validasi 1: Field mode wajib dikirim + if (!mode) { + return res.status(400).json({ error: 'Field "mode" wajib diisi' }) + } + + // Validasi 2: Nilai mode harus salah satu dari whitelist + if (!VALID_MODES.includes(mode)) { + return res.status(400).json({ + error: `Mode tidak valid. Nilai yang diizinkan: ${VALID_MODES.join(', ')}`, + }) + } + + // Ambil info device (dibutuhkan untuk validasi keberadaan + kirim notifikasi) + const { data: device, error: deviceErr } = await supabase + .from('devices') + .select('device_id, claimed_by, current_mode') + .eq('device_id', deviceId) + .single() + + if (deviceErr || !device) { + return res.status(404).json({ error: 'Device tidak ditemukan' }) + } + + // Kirim perintah ganti mode ke ESP32 via MQTT + // Selalu kirim, karena ESP32 mungkin sebelumnya sedang "cooldown" dan mengabaikan perintah + publishMode(deviceId, mode) + + // Simpan mode baru ke database hanya jika berbeda + if (device.current_mode !== mode) { + const { error: updateErr } = await supabase + .from('devices') + .update({ current_mode: mode }) + .eq('device_id', deviceId) + + if (updateErr) { + return res.status(500).json({ error: 'Gagal menyimpan mode ke database' }) + } + } + + // Kirim notifikasi ke pemilik device hanya jika mode benar-benar berubah + if (device.claimed_by && device.current_mode !== mode) { + try { + sendNotification( + device.claimed_by, + 'Mode Kumbung Berubah 🔄', + `Mode diubah ke ${MODE_LABELS[mode]}` + ) + } catch (notifErr) { + // Jangan sampai gagal notifikasi membatalkan seluruh respons + console.error('[Mode] Gagal kirim notifikasi:', notifErr.message) + } + } + + console.log(`[Mode] ${deviceId} → ${device.current_mode} → ${mode} (Forced MQTT Publish)`) + + res.json({ + message: device.current_mode === mode + ? `Perintah mode ${mode} dikirim ulang ke perangkat` + : `Mode berhasil diubah ke ${mode}`, + mode, + changed: device.current_mode !== mode, + }) +}) + +module.exports = router diff --git a/backend-jamur-update/src/routes/schedule.js b/backend-jamur-update/src/routes/schedule.js new file mode 100644 index 0000000..4f96e1c --- /dev/null +++ b/backend-jamur-update/src/routes/schedule.js @@ -0,0 +1,264 @@ +/** + * ============================================================================= + * ROUTE: SCHEDULE — Jadwal & Kontrol Penyiraman + * ============================================================================= + * File ini adalah "pusat kontrol" untuk semua hal yang berhubungan dengan + * penyiraman: jadwal terjadwal, trigger manual, hingga menghentikan pompa. + * + * Konsep penting: Hybrid antara Database dan BullMQ + * Semua jadwal disimpan di DUA tempat sekaligus: + * 1. Database Supabase → untuk menyimpan data permanen (label, cron, durasi, dll) + * 2. BullMQ (Redis) → untuk mengeksekusi jadwal tepat waktu + * + * Kenapa dua tempat? Karena BullMQ menjalankan jadwal berdasarkan waktu nyata, + * tapi jika server di-restart, semua data BullMQ hilang. Dengan menyimpan di + * Supabase, jadwal bisa di-restore saat server dinyalakan kembali. + * + * Endpoint yang tersedia: + * - GET /api/schedule/:deviceId → Daftar semua jadwal + * - POST /api/schedule/:deviceId → Buat jadwal baru + * - DELETE /api/schedule/:id → Hapus jadwal + * - POST /api/schedule/:deviceId/now → Siram sekarang (sekali jalan) + * - POST /api/schedule/:deviceId/stop → Hentikan pompa sekarang + * - PATCH /api/schedule/:id/toggle → Aktifkan atau nonaktifkan jadwal + * ============================================================================= + */ + +const router = require('express').Router() +const supabase = require('../supabase/client') +const { irrigationQueue } = require('../queues/irrigationQueue') +const { publishRelay } = require('../mqtt/mqttClient') + +/** + * GET /api/schedule/:deviceId + * ----------------------------------------------------------------------------- + * Mengambil semua jadwal penyiraman yang dimiliki oleh sebuah device. + * Diurutkan berdasarkan waktu pembuatan (paling lama di atas). + * Biasanya dipanggil saat aplikasi Flutter membuka halaman "Jadwal". + * + * Params: deviceId — ID perangkat + */ +router.get('/:deviceId', async (req, res) => { + const { data, error } = await supabase + .from('schedules') + .select('*') + .eq('device_id', req.params.deviceId) + .order('created_at') + + if (error) return res.status(500).json({ error: error.message }) + res.json(data) +}) + +/** + * POST /api/schedule/:deviceId + * ----------------------------------------------------------------------------- + * Membuat jadwal penyiraman baru yang akan berulang sesuai pola cron. + * + * Yang terjadi saat endpoint ini dipanggil: + * 1. Validasi input (cron dan duration_s wajib ada) + * 2. Daftarkan job repeatable ke BullMQ (yang akan mengeksekusinya nanti) + * 3. Simpan data jadwal ke Supabase beserta ID job dari BullMQ + * + * Format Cron: "menit jam hari bulan hari-minggu" + * Contoh: + * - "0 6 * * *" → Setiap hari jam 06:00 + * - "30 17 * * *" → Setiap hari jam 17:30 + * - "0 6,17 * * *" → Setiap hari jam 06:00 dan 17:00 + * + * Params: deviceId — ID perangkat + * Body: { label: string, cron: string, duration_s: number } + */ +router.post('/:deviceId', async (req, res) => { + const { label, cron, duration_s } = req.body + const deviceId = req.params.deviceId + + // Validasi: cron pattern dan durasi penyiraman wajib dikirim + if (!cron || !duration_s) { + return res.status(400).json({ error: 'cron dan duration_s wajib diisi' }) + } + + // Validasi format cron (minimal harus ada 5 bagian yang dipisahkan spasi) + if (cron.split(' ').length !== 5) { + return res.status(400).json({ error: 'Format cron tidak valid' }) + } + + // Langkah 1: Daftarkan job berulang ke BullMQ menggunakan pola cron + // PENTING: BullMQ v5+ menggunakan key 'cron' bukan 'pattern' (breaking change) + const jobId = `schedule-${deviceId}-${Date.now()}` + const job = await irrigationQueue.add( + 'irrigate', + { deviceId, durationSeconds: duration_s }, + { + repeat: { cron }, // BullMQ v5+: gunakan 'cron', bukan 'pattern' + jobId, // ID unik untuk tiap jadwal + } + ) + + // Langkah 2: Simpan jadwal ke database beserta bull_job_id untuk referensi nanti + // bull_job_id dibutuhkan saat ingin menghapus atau toggle jadwal + const { data, error } = await supabase + .from('schedules') + .insert({ device_id: deviceId, label, cron, duration_s, bull_job_id: job.id }) + .select() + .single() + + if (error) return res.status(500).json({ error: error.message }) + res.status(201).json(data) +}) + +/** + * DELETE /api/schedule/:id + * ----------------------------------------------------------------------------- + * Menghapus jadwal secara permanen dari database DAN dari antrian BullMQ. + * + * Penting: Harus hapus dari keduanya! Kalau hanya hapus dari database tapi + * tidak dari BullMQ, job tetap akan jalan di waktu yang sudah dijadwalkan. + * + * Params: id — UUID jadwal dari tabel schedules di Supabase + */ +router.delete('/:id', async (req, res) => { + // Langkah 1: Ambil detail jadwal dulu untuk mendapat bull_job_id dan cron pattern + // Kedua info ini dibutuhkan untuk menghapus job dari BullMQ + const { data: schedule, error: fetchErr } = await supabase + .from('schedules') + .select('bull_job_id, cron, device_id') + .eq('id', req.params.id) + .single() + + if (fetchErr) return res.status(404).json({ error: 'Jadwal tidak ditemukan' }) + + // Langkah 2: Hapus job dari BullMQ agar tidak dieksekusi lagi + // BullMQ v5+: gunakan 'cron', bukan 'pattern' + await irrigationQueue.removeRepeatable('irrigate', { + cron: schedule.cron, + jobId: schedule.bull_job_id, + }) + + // Langkah 3: Hapus record dari database Supabase + await supabase.from('schedules').delete().eq('id', req.params.id) + + res.json({ message: 'Jadwal dihapus' }) +}) + +/** + * POST /api/schedule/:deviceId/now + * ----------------------------------------------------------------------------- + * Memicu penyiraman SEKARANG JUGA tanpa membuat jadwal permanen. + * Cocok untuk penyiraman darurat atau percobaan manual. + * + * Job ini tidak akan berulang (tidak pakai cron), langsung diproses Worker + * segera setelah request masuk. + * + * Endpoint ini dilindungi rate limiter ketat (5x/menit) yang didefinisikan + * di index.js untuk mencegah penyiraman berlebihan. + * + * Params: deviceId — ID perangkat + * Body: { duration_s: number } — opsional, default 30 detik + */ +router.post('/:deviceId/now', async (req, res) => { + const { duration_s = 30 } = req.body // Default durasi 30 detik jika tidak dikirim + + // Tambahkan job satu kali ke antrian (tanpa repeat) dengan delay 0 (langsung) + await irrigationQueue.add( + 'irrigate', + { deviceId: req.params.deviceId, durationSeconds: duration_s }, + { delay: 0 } // langsung diproses tanpa jeda + ) + + res.json({ message: `Siram manual ${duration_s}s dijadwalkan` }) +}) + +/** + * POST /api/schedule/:deviceId/stop + * ----------------------------------------------------------------------------- + * Menghentikan pompa secara paksa dan instan via MQTT. + * Berguna jika user ingin menghentikan penyiraman sebelum waktunya habis. + * + * Catatan: Ini mengirim perintah OFF langsung ke relay, bukan membatalkan job + * di BullMQ. Jadi jika ada job yang sedang berjalan, Worker masih akan + * menunggu sampai durasi habis baru kirim OFF lagi (tidak berbahaya, hanya redundan). + * + * Params: deviceId — ID perangkat + */ +router.post('/:deviceId/stop', async (req, res) => { + const { deviceId } = req.params + + // Kirim perintah OFF langsung ke relay ESP32 via MQTT + publishRelay(deviceId, 'OFF') + + // Catat event ini ke tabel sensor_logs sebagai audit trail + await supabase.from('sensor_logs').insert({ + device_id: deviceId, + event: 'manual_stop', + note: 'Pompa dimatikan manual', + }) + + res.json({ message: 'Pompa dimatikan' }) +}) + +/** + * PATCH /api/schedule/:id/toggle + * ----------------------------------------------------------------------------- + * Mengaktifkan atau menonaktifkan jadwal tanpa menghapusnya. + * Ini lebih nyaman bagi user daripada harus delete lalu create ulang. + * + * Cara kerjanya: + * - Jika jadwal sedang AKTIF → hapus job dari BullMQ, update is_active = false + * - Jika jadwal sedang NON-AKTIF → daftarkan ulang job ke BullMQ, update is_active = true + * + * Data di database tetap tersimpan dalam kedua kondisi, hanya status is_active yang berubah. + * + * Params: id — UUID jadwal dari tabel schedules + */ +router.patch('/:id/toggle', async (req, res) => { + // Ambil data jadwal saat ini untuk mengetahui status is_active dan detail cron-nya + const { data: schedule, error: fetchErr } = await supabase + .from('schedules') + .select('is_active, cron, duration_s, device_id, bull_job_id') + .eq('id', req.params.id) + .single() + + if (fetchErr) return res.status(404).json({ error: 'Jadwal tidak ditemukan' }) + + // Status baru adalah kebalikan dari status saat ini + const newStatus = !schedule.is_active + + if (newStatus) { + // Jadwal akan DIAKTIFKAN: daftarkan kembali job ke BullMQ + // BullMQ v5+: gunakan 'cron', bukan 'pattern' + const job = await irrigationQueue.add( + 'irrigate', + { deviceId: schedule.device_id, durationSeconds: schedule.duration_s }, + { + repeat: { cron: schedule.cron }, // BullMQ v5+: key 'cron' + jobId: schedule.bull_job_id, // gunakan ID yang sama agar referensi tidak berubah + } + ) + console.log(`[Schedule] Job ${job.id} diaktifkan kembali`) + } else { + // Jadwal akan DINONAKTIFKAN: hapus job dari BullMQ (tapi data DB tetap ada) + // BullMQ v5+: gunakan 'cron', bukan 'pattern' + await irrigationQueue.removeRepeatable('irrigate', { + cron: schedule.cron, + jobId: schedule.bull_job_id, + }) + console.log(`[Schedule] Job ${schedule.bull_job_id} dinonaktifkan`) + } + + // Update kolom is_active di database Supabase + const { data, error } = await supabase + .from('schedules') + .update({ is_active: newStatus }) + .eq('id', req.params.id) + .select() + .single() + + if (error) return res.status(500).json({ error: error.message }) + + res.json({ + message: newStatus ? 'Jadwal diaktifkan' : 'Jadwal dinonaktifkan', + data, + }) +}) + +module.exports = router \ No newline at end of file diff --git a/backend-jamur-update/src/routes/threshold.js b/backend-jamur-update/src/routes/threshold.js new file mode 100644 index 0000000..7a84127 --- /dev/null +++ b/backend-jamur-update/src/routes/threshold.js @@ -0,0 +1,93 @@ +/** + * ============================================================================= + * ROUTE: THRESHOLD — Pengaturan Batas Suhu & Kelembapan + * ============================================================================= + * File ini berfungsi mengatur "batas" yang menentukan kapan relay otomatis + * menyala atau mati. + * + * Cara kerjanya: + * Saat sensor SHT31 di ESP32 membaca data, backend akan membandingkan nilainya + * dengan threshold yang tersimpan di sini: + * - Jika suhu ATAU kelembapan melewati batas → relay ON + * - Jika keduanya di bawah batas → relay OFF + * + * Endpoint yang tersedia: + * - GET /api/threshold/:deviceId → Ambil threshold aktif untuk device tertentu + * - POST /api/threshold/:deviceId → Update threshold (langsung efektif ke ESP32) + * ============================================================================= + */ + +const router = require('express').Router() +const supabase = require('../supabase/client') +const { publishThreshold } = require('../mqtt/mqttClient') + +/** + * GET /api/threshold/:deviceId + * ----------------------------------------------------------------------------- + * Mengambil nilai threshold yang sedang aktif untuk sebuah device. + * Biasanya dipanggil saat aplikasi Flutter pertama kali dibuka untuk + * menampilkan pengaturan saat ini kepada user. + * + * Params: deviceId — ID perangkat, contoh: "esp32-01" + */ +router.get('/:deviceId', async (req, res) => { + const { data, error } = await supabase + .from('thresholds') + .select('*') + .eq('device_id', req.params.deviceId) + .single() + + if (error) return res.status(404).json({ error: error.message }) + res.json(data) +}) + +/** + * POST /api/threshold/:deviceId + * ----------------------------------------------------------------------------- + * Mengubah nilai threshold untuk sebuah device. + * + * Yang terjadi saat endpoint ini dipanggil: + * 1. Validasi input (temp_max dan hum_max wajib ada) + * 2. Update nilai di database Supabase + * 3. Kirim nilai baru ke ESP32 via MQTT (langsung efektif!) + * 4. Update cache in-memory agar tidak perlu tunggu 30 detik untuk efektif + * + * Jadi user tidak perlu reload atau menunggu — perubahan langsung terasa. + * + * Params: deviceId — ID perangkat + * Body: { temp_max: number, hum_max: number } + */ +router.post('/:deviceId', async (req, res) => { + const { temp_min, temp_max, hum_max } = req.body + + // Validasi: kedua nilai threshold (temp_max & hum_max) wajib dikirim dan bukan null/undefined + if (temp_max === undefined || temp_max === null || hum_max === undefined || hum_max === null) { + return res.status(400).json({ error: 'temp_max dan hum_max wajib diisi' }) + } + + // Jika temp_min tidak dikirim (misalnya request dari Flutter versi lama), beri nilai default 20.0 + const finalTempMin = (temp_min === undefined || temp_min === null) ? 20.0 : temp_min; + + // Simpan nilai baru ke database + const { data, error } = await supabase + .from('thresholds') + .update({ + temp_min: finalTempMin, + temp_max, + hum_max, + updated_at: new Date() + }) + .eq('device_id', req.params.deviceId) + .select() + .single() + + if (error) return res.status(500).json({ error: error.message }) + + // Kirim nilai threshold baru langsung ke ESP32 via MQTT + // Sekaligus memperbarui cache agar langsung efektif tanpa tunggu TTL + publishThreshold(req.params.deviceId, finalTempMin, temp_max, hum_max) + + res.json({ message: 'Threshold diupdate', data }) +}) + +module.exports = router \ No newline at end of file diff --git a/backend-jamur-update/src/supabase/client.js b/backend-jamur-update/src/supabase/client.js new file mode 100644 index 0000000..21171ad --- /dev/null +++ b/backend-jamur-update/src/supabase/client.js @@ -0,0 +1,27 @@ +/** + * ============================================================================= + * SUPABASE CLIENT + * ============================================================================= + * File ini punya satu tugas: bikin "jembatan" koneksi ke database Supabase. + * + * Kenapa pakai Service Key, bukan Anon Key? + * - Anon Key dipakai di sisi client (Flutter/browser), aksesnya terbatas oleh + * Row Level Security (RLS). + * - Service Key dipakai di sisi backend (server ini), karena punya akses + * penuh tanpa dibatasi RLS. Makanya JANGAN PERNAH bocorkan key ini ke publik! + * + * Cara pakainya: cukup import file ini dari mana saja di proyek ini. + * Contoh: const supabase = require('../supabase/client') + * ============================================================================= + */ + +const { createClient } = require('@supabase/supabase-js') + +// Inisialisasi koneksi ke Supabase menggunakan URL dan Service Key +// yang diambil dari file .env (supaya tidak hardcode di kode) +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_KEY // pakai service key di backend, bukan anon key +) + +module.exports = supabase \ No newline at end of file diff --git a/backend-jamur-update/src/utils/failoverManager.js b/backend-jamur-update/src/utils/failoverManager.js new file mode 100644 index 0000000..107ad26 --- /dev/null +++ b/backend-jamur-update/src/utils/failoverManager.js @@ -0,0 +1,145 @@ +/** + * ============================================================================= + * FAILOVER MANAGER — Pengendali Mode Backup & Utama + * ============================================================================= + * Berfungsi untuk mengelola status server saat dijalankan di VPS (sebagai backup) + * atau di Railway (sebagai primary). + * + * Cara kerjanya: + * 1. Jika IS_BACKUP_SERVER !== 'true' (Primary / Railway): + * - Langsung aktifkan semua service background (MQTT, Worker, Scheduler, Offline Detector). + * + * 2. Jika IS_BACKUP_SERVER === 'true' (Backup / VPS): + * - Jangan aktifkan service background pada startup. + * - Jalankan pemantauan berkala (ping) ke PRIMARY_SERVER_URL. + * - Jika ping sukses (Primary hidup) -> Pastikan service background mati/nonaktif. + * - Jika ping gagal/timeout (Primary mati) -> Aktifkan semua service background. + * - Jika ping kembali sukses -> Matikan kembali service background. + * ============================================================================= + */ + +const { connect, disconnect } = require('../mqtt/mqttClient') +const worker = require('../queues/irrigationWorker') +const { restoreSchedules } = require('../queues/scheduleRestore') +const { startOfflineDetector, stopOfflineDetector } = require('../jobs/offlineDetector') + +const IS_BACKUP_SERVER = process.env.IS_BACKUP_SERVER === 'true' +const PRIMARY_SERVER_URL = process.env.PRIMARY_SERVER_URL // Contoh: https://xxx.up.railway.app +const PING_INTERVAL_MS = parseInt(process.env.FAILOVER_PING_INTERVAL_MS) || 10000 // default 10s + +let isFailoverActive = false // Apakah backup server sedang melayani background tasks +let pingIntervalId = null + +async function pingPrimary() { + if (!PRIMARY_SERVER_URL) { + console.error('[Failover] PRIMARY_SERVER_URL tidak disetel! Tidak bisa memantau primary server.') + return false + } + + let timeoutId = null + try { + const controller = new AbortController() + timeoutId = setTimeout(() => controller.abort(), 5000) // 5s timeout + + const response = await fetch(PRIMARY_SERVER_URL, { + method: 'GET', + signal: controller.signal + }) + + return response.ok // true jika status 200-299 + } catch (err) { + // Bisa error network atau timeout + return false + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + } +} + +async function activateBackupServices() { + if (isFailoverActive) return + isFailoverActive = true + console.log('[Failover] ⚠️ PRIMARY SERVER TERDETEKSI DOWN! Mengaktifkan layanan backup lokal...') + + try { + // 1. Hubungkan ke MQTT Broker + connect() + + // 2. Aktifkan Worker BullMQ + await worker.resume() + console.log('[Failover] Worker BullMQ di-resume.') + + // 3. Restore Jadwal dari DB ke BullMQ + await restoreSchedules() + + // 4. Jalankan offline detector + startOfflineDetector() + + console.log('[Failover] ✅ Layanan backup berhasil diaktifkan dan siap melayani.') + } catch (err) { + console.error('[Failover] Gagal mengaktifkan beberapa layanan backup:', err.message) + } +} + +async function deactivateBackupServices() { + if (!isFailoverActive) return + isFailoverActive = false + console.log('[Failover] 💚 PRIMARY SERVER KEMBALI ONLINE! Menonaktifkan layanan backup lokal ke standby...') + + try { + // 1. Putuskan hubungan MQTT + disconnect() + + // 2. Pause Worker BullMQ + await worker.pause() + console.log('[Failover] Worker BullMQ di-pause.') + + // 3. Matikan offline detector + stopOfflineDetector() + + console.log('[Failover] ✅ Layanan backup dinonaktifkan kembali ke mode standby.') + } catch (err) { + console.error('[Failover] Gagal menonaktifkan beberapa layanan backup:', err.message) + } +} + +async function initFailover() { + if (!IS_BACKUP_SERVER) { + console.log('[Failover] Berjalan sebagai PRIMARY SERVER (Railway). Mengaktifkan semua service...') + // Mode Primary: Langsung nyalakan semuanya + connect() + // BullMQ worker default-nya aktif saat dibuat, jadi tidak perlu resume. + await restoreSchedules() + startOfflineDetector() + return + } + + console.log('[Failover] Berjalan sebagai BACKUP SERVER (VPS) dalam mode Standby.') + console.log(`[Failover] Memantau primary server di: ${PRIMARY_SERVER_URL} setiap ${PING_INTERVAL_MS / 1000}s`) + + // Mode Backup: Pause worker terlebih dahulu agar tidak memproses antrian saat standby + try { + await worker.pause() + console.log('[Failover] Worker BullMQ di-pause (Standby).') + } catch (err) { + console.error('[Failover] Gagal mem-pause Worker BullMQ di startup:', err.message) + } + + // Jalankan monitoring loop + pingIntervalId = setInterval(async () => { + const isPrimaryAlive = await pingPrimary() + + if (isPrimaryAlive) { + if (isFailoverActive) { + await deactivateBackupServices() + } + } else { + if (!isFailoverActive) { + await activateBackupServices() + } + } + }, PING_INTERVAL_MS) +} + +module.exports = { initFailover } diff --git a/backend-jamur-update/src/utils/notification.js b/backend-jamur-update/src/utils/notification.js new file mode 100644 index 0000000..de436f4 --- /dev/null +++ b/backend-jamur-update/src/utils/notification.js @@ -0,0 +1,100 @@ +const Redis = require('ioredis'); + +const redis = process.env.REDIS_URL ? new Redis(process.env.REDIS_URL) : null; + +// Fallback anti-spam in-memory jika Redis tidak tersedia +const inMemoryCooldown = new Map(); + +/** + * Kirim push notification via OneSignal dengan perlindungan anti-spam bawaan. + * Setiap kombinasi (userId + title) diberi cooldown agar tidak bisa dikirim + * berulang kali dalam jangka waktu tertentu. + * + * @param {string} userId - ID user penerima (external_id OneSignal) + * @param {string} title - Judul notifikasi (juga dipakai sebagai kunci cooldown) + * @param {string} message - Isi pesan notifikasi + * @param {number} [cooldownSec=300] - Cooldown dalam detik (default: 5 menit) + */ +const sendNotification = async (userId, title, message, cooldownSec = 300) => { + const appId = process.env.ONESIGNAL_APP_ID; + const restApiKey = process.env.ONESIGNAL_REST_API_KEY; + + if (!appId || !restApiKey) { + console.warn('[Notif] OneSignal credentials missing. Skipping.'); + return; + } + + // Ensure targetIds is a flat array of unique strings + const targetIds = Array.isArray(userId) ? [...new Set(userId.flat())] : [userId]; + + if (targetIds.length === 0) return; + + // ── Anti-Spam Guard ────────────────────────────────────────────────────── + // Buat kunci unik berdasarkan siapa penerima dan judul notifikasinya. + // Ini mencegah notif "Penyiraman Dimulai" atau "Perangkat Offline" + // dikirim berkali-kali dalam waktu singkat. + const cooldownKey = `notif_cd:${targetIds.join(',')}:${title.replace(/\s+/g, '_').toLowerCase()}`; + + if (redis) { + const isOnCooldown = await redis.get(cooldownKey); + if (isOnCooldown) { + console.log(`[Notif] Cooldown aktif, skip: "${title}" → ${targetIds}`); + return; + } + // Set cooldown key di Redis + await redis.set(cooldownKey, '1', 'EX', cooldownSec); + } else { + // Fallback: gunakan in-memory Map jika Redis tidak ada + const expiresAt = inMemoryCooldown.get(cooldownKey); + if (expiresAt && Date.now() < expiresAt) { + console.log(`[Notif] Cooldown aktif (in-memory), skip: "${title}" → ${targetIds}`); + return; + } + inMemoryCooldown.set(cooldownKey, Date.now() + cooldownSec * 1000); + } + // ──────────────────────────────────────────────────────────────────────── + + const payload = { + app_id: appId, + include_aliases: { + external_id: targetIds + }, + target_channel: 'push', + headings: { en: title }, + contents: { en: message }, + // -- Konfigurasi Grouping & Stacking (Agar rapi seperti WhatsApp) -- + android_group: 'jamur_monitoring_group', // Mengelompokkan notifikasi di Android + thread_id: 'jamur_monitoring_group', // Mengelompokkan notifikasi di iOS + // Menimpa (replace) notifikasi lama yang jenisnya sama, agar tidak spam + collapse_id: title.replace(/\s+/g, '_').toLowerCase() + }; + + try { + const response = await fetch('https://onesignal.com/api/v1/notifications', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Basic ${restApiKey}` + }, + body: JSON.stringify(payload) + }); + + const result = await response.json(); + if (result.errors) { + // Jika error karena user belum login (invalid_aliases), jangan tampilkan error panjang + if (result.errors.invalid_aliases) { + console.warn(`[Notif] User belum register OneSignal (invalid_aliases):`, targetIds); + } else { + console.error('[Notif] OneSignal error:', result.errors); + } + } else { + console.log(`[Notif] Terkirim ke ${targetIds}: "${title}"`); + } + } catch (error) { + console.error('[Notif] Gagal kirim via OneSignal:', error.message); + } +}; + +module.exports = { + sendNotification +}; diff --git a/backend-jamur-update/test-latency.js b/backend-jamur-update/test-latency.js new file mode 100644 index 0000000..ab2af3b --- /dev/null +++ b/backend-jamur-update/test-latency.js @@ -0,0 +1,126 @@ +require('dotenv').config(); +const mqtt = require('mqtt'); + +console.log('=== HIVE MQTT LATENCY TESTER ==='); +console.log('Menghubungkan ke:', process.env.MQTT_BROKER_URL); + +const client = mqtt.connect(process.env.MQTT_BROKER_URL, { + port: parseInt(process.env.MQTT_PORT) || 8883, + username: process.env.MQTT_USERNAME, + password: process.env.MQTT_PASSWORD, + protocol: 'mqtts', + clientId: `latency-tester-${Date.now()}`, + clean: true, +}); + +const TOPIC_SENSOR = 'sensor/sht31'; +const TOPIC_RELAY_WILDCARD = 'cmd/relay/+'; +const TOPIC_PING = 'latency/test/ping'; + +// Map untuk mencatat waktu kirim perintah relay +const pendingCommands = new Map(); + +// Map untuk melacak waktu pengiriman ping loopback +const pingSentTimes = new Map(); +const pingPubAckTimes = new Map(); + +client.on('connect', () => { + console.log('✅ Terhubung ke HiveMQ Cloud!'); + console.log('--------------------------------------------------'); + console.log('1. Silakan lakukan aksi di Flutter app untuk tes latensi kontrol.'); + console.log('2. Atau tekan [ENTER] di terminal ini untuk tes ping.'); + console.log('--------------------------------------------------'); + + // Subscribe ke topik sensor, perintah, dan ping + client.subscribe([TOPIC_SENSOR, TOPIC_RELAY_WILDCARD, TOPIC_PING], (err) => { + if (err) console.error('❌ Gagal subscribe:', err.message); + }); +}); + +client.on('message', (topic, payload) => { + const now = Date.now(); + const timeStr = new Date().toLocaleTimeString(); + + // 1. Logika untuk Ping Loopback (Mengukur Backend -> MQTT dan MQTT -> Backend secara presisi) + if (topic === TOPIC_PING) { + try { + const data = JSON.parse(payload.toString()); + const { pingId } = data; + const t_receive = now; + + const t0 = pingSentTimes.get(pingId); + if (t0) { + const totalRTT = t_receive - t0; // Total waktu bolak-balik + const oneWayLatency = Math.round(totalRTT / 2); // Estimasi satu arah + + console.log(`\n[${timeStr}] ⚡ HASIL PENGUKURAN LATENSI:`); + console.log(` 🔸 1. Backend ➔ MQTT Broker (Kirim Perintah) : ${oneWayLatency} ms`); + console.log(` 🔸 2. MQTT Broker ➔ Backend (Terima Data ESP) : ${oneWayLatency} ms`); + console.log(` ➔ Total Round-Trip Time (RTT) : ${totalRTT} ms`); + console.log(`--------------------------------------------------`); + + // Bersihkan memori cache + pingSentTimes.delete(pingId); + pingPubAckTimes.delete(pingId); + } + } catch (e) {} + return; + } + + // 2. Jika menerima pesan perintah relay + if (topic.startsWith('cmd/relay/')) { + const deviceId = topic.split('/')[2]; + const state = payload.toString().trim(); + console.log(`[${timeStr}] 📤 Perintah [${state}] terkirim ke device: ${deviceId}`); + pendingCommands.set(deviceId, { + targetState: state === 'ON', + sentAt: now + }); + return; + } + + // 3. Jika menerima data sensor dari ESP32 + if (topic === TOPIC_SENSOR) { + try { + const data = JSON.parse(payload.toString()); + const deviceId = data.device_id || 'esp32-01'; + const relayState = data.relay_state ?? data.relay ?? false; + + console.log(`[${timeStr}] 📥 Data sensor masuk dari ${deviceId} (Suhu: ${data.temp || data.temperature}°C | Hum: ${data.hum || data.humidity}% | Relay: ${relayState})`); + + // Cek apakah ada perintah relay yang sedang ditunggu responnya + const pending = pendingCommands.get(deviceId); + if (pending && pending.targetState === relayState) { + const latency = now - pending.sentAt; + console.log(`🔥 [Control RTT] Perintah dikirim hingga status terkonfirmasi: ${latency} ms`); + console.log(` └─ Estimasi Latensi Kirim Perintah (Backend ➔ ESP32): ${Math.round(latency / 2)} ms`); + pendingCommands.delete(deviceId); + } + } catch (e) { + console.log(`[${timeStr}] 📥 Data sensor masuk (Bukan JSON):`, payload.toString()); + } + } +}); + +client.on('error', (err) => { + console.error('❌ MQTT Error:', err.message); +}); + +// Setup agar saat menekan Enter di terminal akan memicu publish ping ke HiveMQ +process.stdin.on('data', () => { + if (client.connected) { + const pingId = Math.random().toString(36).substring(7); + const t0 = Date.now(); + pingSentTimes.set(pingId, t0); + + // Gunakan QoS 1 agar mendapatkan callback PUBACK dari broker saat pesan sukses diterima broker + client.publish(TOPIC_PING, JSON.stringify({ pingId }), { qos: 1 }, (err) => { + if (!err) { + const t_puback = Date.now(); + pingPubAckTimes.set(pingId, t_puback); + } else { + console.error('❌ Gagal publish ping:', err.message); + } + }); + } +});