From 8da7b66494ee53054d54e1a234f2e29b028ce9f5 Mon Sep 17 00:00:00 2001 From: Wizznu Date: Wed, 11 Feb 2026 07:40:33 +0700 Subject: [PATCH] Initial commit: Railway Worker for ApsGo IoT System --- .env.example | 13 + .env.template | 27 ++ .gitignore | 27 ++ README.md | 148 ++++++++++ SETUP_FIREBASE.md | 130 +++++++++ check-queue.js | 54 ++++ debug.js | 115 ++++++++ extract-credentials.js | 93 +++++++ package.json | 30 ++ railway.json | 13 + test-firebase-connection.js | 149 ++++++++++ test-firebase-direct.js | 146 ++++++++++ worker.js | 536 ++++++++++++++++++++++++++++++++++++ 13 files changed, 1481 insertions(+) create mode 100644 .env.example create mode 100644 .env.template create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SETUP_FIREBASE.md create mode 100644 check-queue.js create mode 100644 debug.js create mode 100644 extract-credentials.js create mode 100644 package.json create mode 100644 railway.json create mode 100644 test-firebase-connection.js create mode 100644 test-firebase-direct.js create mode 100644 worker.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0fdaccf --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Firebase Configuration +FIREBASE_PROJECT_ID=your-project-id +FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxxxx@your-project-id.iam.gserviceaccount.com +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYourPrivateKeyHere\n-----END PRIVATE KEY-----\n" +FIREBASE_DATABASE_URL=https://your-project-id-default-rtdb.firebaseio.com + +# Redis Configuration (Railway will auto-provide this) +REDIS_HOST=redis.railway.internal +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Or use REDIS_URL (Railway format) +# REDIS_URL=redis://default:password@redis.railway.internal:6379 diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..b07783e --- /dev/null +++ b/.env.template @@ -0,0 +1,27 @@ +# Railway Worker Environment Variables +# Copy this file to .env and fill with your Firebase credentials + +# ๐Ÿ”ฅ Firebase Configuration +# Get these from Firebase Console โ†’ Project Settings โ†’ Service Accounts โ†’ Generate Private Key + +FIREBASE_PROJECT_ID=project-ta-951b4 +FIREBASE_CLIENT_EMAIL= +FIREBASE_PRIVATE_KEY= +FIREBASE_DATABASE_URL=https://project-ta-951b4-default-rtdb.firebaseio.com + +# ๐Ÿ“ Instructions: +# 1. FIREBASE_CLIENT_EMAIL: +# Format: firebase-adminsdk-xxxxx@project-ta-951b4.iam.gserviceaccount.com +# +# 2. FIREBASE_PRIVATE_KEY: +# - Must be wrapped in quotes: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" +# - Keep the \n characters (they are important!) +# - Copy ENTIRE private_key value from downloaded JSON +# +# Example: +# FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEF...\n-----END PRIVATE KEY-----\n" + +# โš ๏ธ SECURITY WARNING: +# - NEVER commit .env file to git +# - NEVER share these credentials +# - This file is already in .gitignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c696efe --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock + +# Environment variables +.env +.env.local + +# Logs +logs/ +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Firebase service account +serviceAccount.json +firebase-key.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..677bb22 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# ApsGo Railway Worker + +Background worker service untuk sistem otomasi IoT ApsGo. Service ini berjalan 24/7 di cloud untuk menjalankan penjadwalan dan automation bahkan ketika aplikasi mobile ditutup atau handphone pengguna mati. + +## Features + +- โœ… **Waktu Mode**: Penjadwalan berdasarkan waktu (cron-based) +- โœ… **Sensor Mode**: Otomasi berdasarkan threshold kelembapan tanah +- โœ… **Auto History Logging**: Record data sensor setiap 10 menit +- โœ… **Redis Queue**: Prevent race conditions dan manage concurrent tasks +- โœ… **Graceful Shutdown**: Clean shutdown dengan safety turn-off semua aktuator +- โœ… **Health Monitoring**: Auto health check setiap 5 menit +- โœ… **Auto Cleanup**: Hapus history lama otomatis (retain 30 hari) + +## Tech Stack + +- **Node.js**: Runtime environment +- **Firebase Admin SDK**: Realtime Database integration +- **BullMQ**: Robust job queue dengan Redis +- **Redis**: In-memory database untuk queue dan caching +- **Cron**: Scheduled tasks + +## Setup Local Development + +1. Install dependencies: +```bash +npm install +``` + +2. Copy `.env.example` ke `.env` dan isi dengan credentials Firebase Anda: +```bash +cp .env.example .env +``` + +3. Setup Redis lokal (gunakan Docker): +```bash +docker run -d -p 6379:6379 redis:latest +``` + +4. Run worker: +```bash +npm run dev # Development mode dengan nodemon +# atau +npm start # Production mode +``` + +## Deploy to Railway + +Lihat file `DEPLOYMENT_GUIDE.md` untuk step-by-step deployment ke Railway. + +## Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `FIREBASE_PROJECT_ID` | Firebase project ID | โœ… | +| `FIREBASE_CLIENT_EMAIL` | Firebase service account email | โœ… | +| `FIREBASE_PRIVATE_KEY` | Firebase service account private key | โœ… | +| `FIREBASE_DATABASE_URL` | Firebase Realtime Database URL | โœ… | +| `REDIS_HOST` | Redis hostname | โœ… | +| `REDIS_PORT` | Redis port (default: 6379) | โŒ | +| `REDIS_PASSWORD` | Redis password (if required) | โŒ | + +## Architecture + +``` +Flutter App (Mobile) + โ†• +Firebase Realtime DB โ† ESP32/Hardware + โ†• +Railway Worker (This service) + โ†• + Redis Queue +``` + +## How It Works + +### Waktu Mode +- Worker check Firebase `/kontrol` setiap 30 detik +- Jika `waktu_1` atau `waktu_2` match dengan waktu sekarang, add job ke queue +- Job akan diprocess oleh worker untuk nyalakan pompa dan valve +- Setelah durasi selesai, otomatis matikan + +### Sensor Mode +- Worker listen ke Firebase `/data` secara realtime +- Jika `soil_X` < `batas_bawah`, trigger watering untuk pot tersebut +- Ada cooldown 2 menit per pot untuk prevent over-watering +- Support 2 mode: `fixed` (durasi tetap) dan `smart` (sampai mencapai batas_atas) + +### Safety Features +- Concurrency: 1 (hanya 1 job diprocess pada satu waktu) +- Debouncing: Minimum 2 menit antar penyiraman per pot +- Error handling: Jika error, otomatis turn OFF semua aktuator +- Graceful shutdown: Clean up resources saat restart/shutdown + +## Monitoring + +Worker akan log semua aktivitas ke console: +- โœ… Success operations +- โŒ Errors dengan details +- ๐Ÿ’ง Watering jobs progress +- ๐Ÿ“Š History logging +- ๐Ÿ’š Health check status + +Di Railway dashboard, Anda bisa: +- View logs realtime +- Monitor CPU/Memory usage +- Setup alerts untuk failures + +## Maintenance + +### Manual Queue Management + +Untuk clear queue (jika ada masalah): +```javascript +const { Queue } = require('bullmq'); +const Redis = require('ioredis'); + +const redis = new Redis(process.env.REDIS_URL); +const queue = new Queue('watering', { connection: redis }); + +// Clear all jobs +await queue.obliterate(); +``` + +### Database Cleanup + +History otomatis di-cleanup setiap hari jam 2 pagi, hanya retain 30 hari terakhir. + +## Troubleshooting + +### Worker tidak berjalan +1. Check environment variables +2. Check Firebase credentials +3. Check Redis connection + +### Job tidak diprocess +1. Check queue status di logs +2. Verify Firebase rules mengizinkan admin access +3. Check concurrency setting + +### Memory leak +- Worker menggunakan BullMQ yang sudah optimize untuk long-running process +- Auto cleanup completed jobs (retain last 100) +- Auto cleanup failed jobs (retain last 50) + +## License + +MIT diff --git a/SETUP_FIREBASE.md b/SETUP_FIREBASE.md new file mode 100644 index 0000000..0ad8a54 --- /dev/null +++ b/SETUP_FIREBASE.md @@ -0,0 +1,130 @@ +# โš™๏ธ SETUP RAILWAY WORKER - FIREBASE CREDENTIALS + +## ๐Ÿ”‘ Cara Mendapatkan Firebase Credentials + +### **Step 1: Download Service Account Key** + +1. Buka **[Firebase Console](https://console.firebase.google.com)** +2. Pilih project: **project-ta-951b4** +3. Klik โš™๏ธ icon (Settings) โ†’ **Project Settings** +4. Tab **Service Accounts** +5. Klik button **"Generate New Private Key"** +6. Klik **"Generate Key"** โ†’ File JSON akan terdownload + +--- + +### **Step 2: Extract Values dari JSON** + +Buka file JSON yang didownload (misalnya: `project-ta-951b4-xxxxx.json`) dengan text editor. + +**Copy values berikut:** + +```json +{ + "type": "service_account", + "project_id": "project-ta-951b4", โ† COPY INI + "client_email": "firebase-adminsdk-xxxxx@project-ta-951b4.iam.gserviceaccount.com", โ† COPY INI + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADA...\n-----END PRIVATE KEY-----\n", โ† COPY INI + ... +} +``` + +--- + +### **Step 3: Update File .env** + +Edit file: `railway-worker/.env` + +```env +FIREBASE_PROJECT_ID=project-ta-951b4 +FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxxxx@project-ta-951b4.iam.gserviceaccount.com +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADA...\n-----END PRIVATE KEY-----\n" +FIREBASE_DATABASE_URL=https://project-ta-951b4-default-rtdb.firebaseio.com +``` + +**โš ๏ธ PENTING untuk PRIVATE_KEY:** +- Harus dalam quotes `"` +- Harus ada `\n` (backslash-n), bukan newline sebenarnya +- Copy langsung dari JSON file (sudah format benar) + +--- + +### **Step 4: Test Lokal (Optional)** + +```powershell +cd railway-worker +node test-firebase-connection.js +``` + +**Expected output:** +``` +โœ… Firebase initialized +โœ… DATA FOUND! Structure: +โœ… DATA STRUCTURE VALID +``` + +--- + +### **Step 5: Set di Railway** + +**Railway Dashboard โ†’ Service myreppril โ†’ Variables tab:** + +Add 4 variables (tanpa quotes!): + +| Variable | Value | +|----------|-------| +| `FIREBASE_PROJECT_ID` | `project-ta-951b4` | +| `FIREBASE_CLIENT_EMAIL` | `firebase-adminsdk-xxxxx@project-ta-951b4.iam.gserviceaccount.com` | +| `FIREBASE_PRIVATE_KEY` | `-----BEGIN PRIVATE KEY-----\nMIIEvQIBADA...\n-----END PRIVATE KEY-----\n` | +| `FIREBASE_DATABASE_URL` | `https://project-ta-951b4-default-rtdb.firebaseio.com` | + +**Railway akan auto-redeploy setelah add variables!** + +--- + +## ๐Ÿš€ Setelah Setup + +### **Check Railway Logs:** + +Railway โ†’ Service myreppril โ†’ Tab **Logs** + +**Harus muncul:** +``` +๐Ÿš€ Starting ApsGo Railway Worker... +๐Ÿ“ก Firebase Project: project-ta-951b4 +โœ… Firebase Admin initialized +โœ… Redis connected +โœ… Waktu Mode scheduler started +``` + +--- + +## โš ๏ธ Troubleshooting + +### **Error: "Firebase initialization failed"** +- Check PRIVATE_KEY format (harus dengan `\n`) +- Pastikan tidak ada extra spaces atau quotes di Railway Variables +- Regenerate key jika perlu + +### **Error: "Redis connection failed"** +- Add Redis database di Railway (+ New โ†’ Database โ†’ Redis) +- Tunggu auto-inject variables + +### **No logs at all in Railway** +- Set Root Directory = `railway-worker` +- Check Deployments tab untuk build errors + +--- + +## ๐Ÿ“ž Next Steps + +Setelah .env file ready: +1. Test lokal: `node test-firebase-connection.js` +2. Set variables di Railway +3. Wait for auto-redeploy +4. Check Railway logs + +**Jika masih stuck, screenshot:** +- Railway Variables tab +- Railway Deployments status +- Error message (if any) diff --git a/check-queue.js b/check-queue.js new file mode 100644 index 0000000..ffa8379 --- /dev/null +++ b/check-queue.js @@ -0,0 +1,54 @@ +/** + * Script untuk cek status Redis Queue Railway + * Usage: node check-queue.js + */ + +require('dotenv').config(); +const { Queue } = require('bullmq'); +const Redis = require('ioredis'); + +const redis = new Redis({ + host: process.env.REDIS_HOST, + port: parseInt(process.env.REDIS_PORT), + password: process.env.REDIS_PASSWORD, + maxRetriesPerRequest: null, +}); + +const wateringQueue = new Queue('watering', { connection: redis }); + +async function checkQueue() { + try { + console.log('๐Ÿ” Checking Railway Queue Status...\n'); + + const waiting = await wateringQueue.getWaitingCount(); + const active = await wateringQueue.getActiveCount(); + const completed = await wateringQueue.getCompletedCount(); + const failed = await wateringQueue.getFailedCount(); + + console.log('๐Ÿ“Š Queue Statistics:'); + console.log(` โณ Waiting: ${waiting}`); + console.log(` โš™๏ธ Active: ${active}`); + console.log(` โœ… Completed: ${completed}`); + console.log(` โŒ Failed: ${failed}`); + + // Get recent jobs + console.log('\n๐Ÿ“‹ Recent Completed Jobs:'); + const completedJobs = await wateringQueue.getCompleted(0, 4); + + for (const job of completedJobs) { + console.log(`\n Job ID: ${job.id}`); + console.log(` Type: ${job.data.type}`); + console.log(` Pots: [${job.data.potNumbers.join(', ')}]`); + console.log(` Duration: ${job.data.duration}s`); + console.log(` Time: ${new Date(job.finishedOn).toLocaleString('id-ID')}`); + } + + console.log('\nโœ… Check complete!'); + process.exit(0); + } catch (error) { + console.error('โŒ Error:', error.message); + process.exit(1); + } +} + +checkQueue(); diff --git a/debug.js b/debug.js new file mode 100644 index 0000000..cc953bf --- /dev/null +++ b/debug.js @@ -0,0 +1,115 @@ +/** + * Debug Script untuk Railway Worker + * Jalankan script ini untuk debugging masalah scheduling + */ + +require('dotenv').config(); +const admin = require('firebase-admin'); + +// Setup Firebase +const config = { + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'), + databaseURL: process.env.FIREBASE_DATABASE_URL, +}; + +console.log('๐Ÿ” DEBUG: Railway Worker Troubleshooting'); +console.log('=========================================='); + +async function debugWorker() { + try { + // Test Firebase connection + console.log('1. Testing Firebase connection...'); + + admin.initializeApp({ + credential: admin.credential.cert(config), + databaseURL: config.databaseURL, + }); + + const db = admin.database(); + console.log('โœ… Firebase initialized'); + + // Test reading kontrol config + console.log('\n2. Reading kontrol configuration...'); + const snapshot = await db.ref('kontrol').once('value'); + const kontrolConfig = snapshot.val(); + + console.log('๐Ÿ“Š Current kontrol config:', JSON.stringify(kontrolConfig, null, 2)); + + // Check important fields + console.log('\n3. Validating schedule configuration...'); + if (!kontrolConfig) { + console.log('โŒ No kontrol config found! Flutter app needs to set schedule first.'); + return; + } + + const issues = []; + + if (!kontrolConfig.waktu) { + issues.push('โš ๏ธ waktu mode is disabled'); + } + + if (!kontrolConfig.waktu_1 && !kontrolConfig.waktu_2) { + issues.push('โŒ No jadwal waktu_1 or waktu_2 configured'); + } + + if (!kontrolConfig.durasi_1 && !kontrolConfig.durasi_2) { + issues.push('โŒ No durasi_1 or durasi_2 configured'); + } + + if (issues.length === 0) { + console.log('โœ… All configuration looks good!'); + + // Test current time vs scheduled time + console.log('\n4. Time check...'); + const now = new Date(); + const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; + console.log(`โฐ Current time: ${currentTime}`); + console.log(`๐Ÿ“… Jadwal 1: ${kontrolConfig.waktu_1 || 'Not set'}`); + console.log(`๐Ÿ“… Jadwal 2: ${kontrolConfig.waktu_2 || 'Not set'}`); + + if (kontrolConfig.waktu_1 === currentTime || kontrolConfig.waktu_2 === currentTime) { + console.log('๐ŸŽฏ MATCHED! Schedule should trigger now'); + } else { + console.log('โณ No schedule match at current time'); + } + + } else { + console.log('โŒ Configuration issues found:'); + issues.forEach(issue => console.log(` ${issue}`)); + } + + // Test Firebase write permission + console.log('\n5. Testing Firebase write permission...'); + await db.ref('debug').set({ + timestamp: Date.now(), + message: 'Railway Worker debug test' + }); + console.log('โœ… Firebase write permission OK'); + + console.log('\n๐ŸŽ‰ Debug completed!'); + + } catch (error) { + console.error('โŒ Debug failed:', error.message); + + if (error.message.includes('private_key')) { + console.error('\n๐Ÿ’ก Fix: Check FIREBASE_PRIVATE_KEY format'); + console.error(' - Should start with "-----BEGIN PRIVATE KEY-----"'); + console.error(' - Should have \\n properly escaped'); + console.error(' - Should be wrapped in quotes'); + } + + if (error.message.includes('projectId')) { + console.error('\n๐Ÿ’ก Fix: Check FIREBASE_PROJECT_ID'); + console.error(' - Should match your Firebase project ID'); + } + } finally { + if (admin.apps.length > 0) { + await admin.app().delete(); + } + process.exit(0); + } +} + +debugWorker(); \ No newline at end of file diff --git a/extract-credentials.js b/extract-credentials.js new file mode 100644 index 0000000..a8f1f68 --- /dev/null +++ b/extract-credentials.js @@ -0,0 +1,93 @@ +/** + * Helper script untuk extract Firebase credentials dari Service Account JSON + * dan generate .env file yang benar + */ + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +console.log('๐Ÿ”ฅ Firebase Credentials Extractor\n'); +console.log('This tool will help you create a valid .env file from Firebase Service Account JSON.\n'); + +rl.question('Enter path to your Firebase Service Account JSON file: ', (jsonPath) => { + try { + // Read and parse JSON + if (!fs.existsSync(jsonPath)) { + console.error('โŒ File not found:', jsonPath); + console.error('\nTip: Drag and drop the JSON file to this terminal to get its path.'); + process.exit(1); + } + + console.log('\n๐Ÿ“– Reading JSON file...'); + const jsonContent = fs.readFileSync(jsonPath, 'utf8'); + const credentials = JSON.parse(jsonContent); + + // Validate required fields + const required = ['project_id', 'private_key', 'client_email']; + const missing = required.filter(field => !credentials[field]); + + if (missing.length > 0) { + console.error('โŒ Missing required fields in JSON:', missing.join(', ')); + process.exit(1); + } + + console.log('โœ… JSON file parsed successfully'); + console.log(` Project ID: ${credentials.project_id}`); + console.log(` Client Email: ${credentials.client_email}`); + + // Extract database URL + const databaseURL = credentials.databaseURL || + `https://${credentials.project_id}-default-rtdb.firebaseio.com`; + + // Generate .env content + const envContent = `# Firebase Configuration +# Generated from Service Account JSON on ${new Date().toISOString()} + +FIREBASE_PROJECT_ID=${credentials.project_id} +FIREBASE_CLIENT_EMAIL=${credentials.client_email} +FIREBASE_PRIVATE_KEY="${credentials.private_key}" +FIREBASE_DATABASE_URL=${databaseURL} + +# Redis Configuration (Railway will auto-provide these) +# REDIS_HOST=localhost +# REDIS_PORT=6379 +# REDIS_PASSWORD= +`; + + // Write to .env + const envPath = path.join(__dirname, '.env'); + fs.writeFileSync(envPath, envContent); + + console.log('\nโœ… .env file created successfully!'); + console.log(` Location: ${envPath}`); + + console.log('\n๐Ÿ“‹ Next steps:'); + console.log(' 1. Test the connection:'); + console.log(' node test-firebase-direct.js'); + console.log(' 2. If successful, deploy to Railway'); + console.log(' 3. Add same variables to Railway Dashboard'); + + rl.close(); + + } catch (error) { + console.error('โŒ Error:', error.message); + + if (error instanceof SyntaxError) { + console.error('\n๐Ÿ’ก The file is not valid JSON. Make sure you downloaded the'); + console.error(' Service Account Key from Firebase Console correctly.'); + } + + rl.close(); + process.exit(1); + } +}); + +rl.on('close', () => { + process.exit(0); +}); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..c27a232 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "apsgo-railway-worker", + "version": "1.0.0", + "description": "Background worker for ApsGo IoT automation system", + "main": "worker.js", + "scripts": { + "start": "node worker.js", + "dev": "nodemon worker.js", + "debug": "node debug.js", + "test:firebase": "node test-firebase-direct.js", + "setup": "node extract-credentials.js", + "test": "echo \"No tests yet\" && exit 0" + }, + "keywords": ["iot", "automation", "firebase", "worker"], + "author": "ApsGo Team", + "license": "MIT", + "dependencies": { + "firebase-admin": "^12.0.0", + "bullmq": "^5.1.0", + "ioredis": "^5.3.2", + "dotenv": "^16.4.5", + "cron": "^3.1.6" + }, + "devDependencies": { + "nodemon": "^3.0.3" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..3237d4b --- /dev/null +++ b/railway.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS", + "buildCommand": "npm install" + }, + "deploy": { + "numReplicas": 1, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, + "startCommand": "npm start" + } +} diff --git a/test-firebase-connection.js b/test-firebase-connection.js new file mode 100644 index 0000000..e498fa9 --- /dev/null +++ b/test-firebase-connection.js @@ -0,0 +1,149 @@ +/** + * Script untuk TEST koneksi Firebase dan verify data penjadwalan + * Usage: node test-firebase-connection.js + * + * Script ini akan: + * 1. Connect ke Firebase + * 2. Baca data /kontrol/ + * 3. Display struktur data dan format + * 4. Verify apakah data penjadwalan ada + */ + +require('dotenv').config(); +const admin = require('firebase-admin'); + +console.log('๐Ÿ” Testing Firebase Connection & Data Structure...\n'); + +// Initialize Firebase +try { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'), + }), + databaseURL: process.env.FIREBASE_DATABASE_URL, + }); + console.log('โœ… Firebase initialized\n'); +} catch (error) { + console.error('โŒ Firebase initialization failed:', error.message); + process.exit(1); +} + +const db = admin.database(); + +async function testConnection() { + try { + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + console.log('๐Ÿ“Š READING /kontrol/ FROM FIREBASE'); + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'); + + const snapshot = await db.ref('kontrol').once('value'); + const kontrolData = snapshot.val(); + + if (!kontrolData) { + console.log('โŒ NO DATA FOUND at /kontrol/\n'); + console.log('๐Ÿ’ก Kemungkinan masalah:'); + console.log(' 1. Data belum di-set dari Flutter app'); + console.log(' 2. Firebase credentials salah'); + console.log(' 3. Database URL salah\n'); + return; + } + + console.log('โœ… DATA FOUND! Structure:\n'); + console.log(JSON.stringify(kontrolData, null, 2)); + console.log('\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + console.log('๐Ÿ” VALIDATING WAKTU MODE DATA'); + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'); + + // Validate Waktu Mode + const checks = { + 'waktu (mode enabled)': kontrolData.waktu, + 'waktu_1 (jadwal 1)': kontrolData.waktu_1, + 'waktu_2 (jadwal 2)': kontrolData.waktu_2, + 'durasi_1 (detik)': kontrolData.durasi_1, + 'durasi_2 (detik)': kontrolData.durasi_2, + }; + + let allValid = true; + for (const [key, value] of Object.entries(checks)) { + const exists = value !== undefined && value !== null; + const icon = exists ? 'โœ…' : 'โŒ'; + console.log(`${icon} ${key.padEnd(25)} = ${value ?? 'NOT SET'}`); + if (!exists) allValid = false; + } + + console.log('\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + console.log('โฐ CURRENT TIME vs SCHEDULE'); + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'); + + const now = new Date(); + const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; + + console.log(`๐Ÿ• Current time: ${currentTime}`); + console.log(`๐Ÿ“… Jadwal 1: ${kontrolData.waktu_1 || 'NOT SET'}`); + console.log(`๐Ÿ“… Jadwal 2: ${kontrolData.waktu_2 || 'NOT SET'}`); + + if (kontrolData.waktu_1 === currentTime) { + console.log('\n๐Ÿ”ฅ JADWAL 1 SHOULD TRIGGER NOW!'); + } else if (kontrolData.waktu_2 === currentTime) { + console.log('\n๐Ÿ”ฅ JADWAL 2 SHOULD TRIGGER NOW!'); + } else { + console.log('\nโณ No schedule at current time'); + } + + console.log('\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + console.log('๐ŸŽฏ DIAGNOSIS RESULT'); + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'); + + if (allValid && kontrolData.waktu === true) { + console.log('โœ… DATA STRUCTURE VALID'); + console.log('โœ… WAKTU MODE ENABLED'); + console.log('\n๐Ÿ’ก If Railway logs still empty, check:'); + console.log(' 1. Railway root directory = "railway-worker"'); + console.log(' 2. Environment variables di Railway'); + console.log(' 3. Railway deployment status (Logs tab)'); + console.log(' 4. Redis service is running\n'); + } else { + console.log('โŒ DATA STRUCTURE INCOMPLETE or MODE DISABLED\n'); + + if (!kontrolData.waktu) { + console.log('โš ๏ธ waktu = false โ†’ Mode waktu TIDAK AKTIF'); + console.log(' ๐Ÿ’ก Aktifkan dari Flutter app!\n'); + } + + if (!kontrolData.waktu_1 || !kontrolData.waktu_2) { + console.log('โš ๏ธ Jadwal belum di-set'); + console.log(' ๐Ÿ’ก Set jadwal dari Flutter app!\n'); + } + } + + // Check Sensor Mode + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + console.log('๐ŸŒก๏ธ SENSOR MODE DATA'); + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'); + + const sensorChecks = { + 'otomatis (mode enabled)': kontrolData.otomatis, + 'batas_bawah': kontrolData.batas_bawah, + 'batas_atas': kontrolData.batas_atas, + 'durasi_sensor': kontrolData.durasi_sensor, + }; + + for (const [key, value] of Object.entries(sensorChecks)) { + const exists = value !== undefined && value !== null; + const icon = exists ? 'โœ…' : 'โŒ'; + console.log(`${icon} ${key.padEnd(25)} = ${value ?? 'NOT SET'}`); + } + + console.log('\nโœ… Test complete!\n'); + process.exit(0); + + } catch (error) { + console.error('\nโŒ Error:', error.message); + console.error(error); + process.exit(1); + } +} + +testConnection(); diff --git a/test-firebase-direct.js b/test-firebase-direct.js new file mode 100644 index 0000000..796bdea --- /dev/null +++ b/test-firebase-direct.js @@ -0,0 +1,146 @@ +/** + * Test koneksi Firebase langsung dengan credentials + * Untuk memverifikasi Railway Worker bisa akses Firebase + */ + +require('dotenv').config(); +const admin = require('firebase-admin'); + +console.log('๐Ÿ” Testing Firebase Connection...\n'); + +// Check environment variables +console.log('1. Checking Environment Variables:'); +const requiredVars = ['FIREBASE_PROJECT_ID', 'FIREBASE_CLIENT_EMAIL', 'FIREBASE_PRIVATE_KEY', 'FIREBASE_DATABASE_URL']; +let allPresent = true; + +requiredVars.forEach(varName => { + const exists = !!process.env[varName]; + console.log(` ${exists ? 'โœ…' : 'โŒ'} ${varName}: ${exists ? 'Set' : 'MISSING'}`); + if (!exists) allPresent = false; +}); + +if (!allPresent) { + console.error('\nโŒ Missing environment variables!'); + console.error('๐Ÿ“‹ Create a .env file with:'); + console.error(' FIREBASE_PROJECT_ID=project-ta-951b4'); + console.error(' FIREBASE_CLIENT_EMAIL=your-service-account@project-ta-951b4.iam.gserviceaccount.com'); + console.error(' FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n"'); + console.error(' FIREBASE_DATABASE_URL=https://project-ta-951b4-default-rtdb.firebaseio.com'); + process.exit(1); +} + +async function testFirebase() { + try { + console.log('\n2. Initializing Firebase Admin SDK...'); + + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'), + }), + databaseURL: process.env.FIREBASE_DATABASE_URL, + }); + + console.log('โœ… Firebase Admin SDK initialized'); + + const db = admin.database(); + + console.log('\n3. Testing Firebase Read Permission...'); + const snapshot = await db.ref('kontrol').once('value'); + const kontrolData = snapshot.val(); + + if (!kontrolData) { + console.error('โŒ No data in /kontrol node!'); + console.error(' Make sure Flutter app has saved schedule configuration.'); + process.exit(1); + } + + console.log('โœ… Successfully read /kontrol data:'); + console.log(JSON.stringify(kontrolData, null, 2)); + + console.log('\n4. Validating Schedule Configuration...'); + + const issues = []; + + if (kontrolData.waktu !== true) { + issues.push('โŒ waktu mode is disabled (waktu: false)'); + } else { + console.log('โœ… Waktu mode is ENABLED'); + } + + if (!kontrolData.waktu_1 && !kontrolData.waktu_2) { + issues.push('โŒ No schedule times configured (waktu_1 and waktu_2 missing)'); + } else { + console.log(`โœ… Jadwal 1: ${kontrolData.waktu_1 || 'Not set'}`); + console.log(`โœ… Jadwal 2: ${kontrolData.waktu_2 || 'Not set'}`); + } + + if (!kontrolData.durasi_1 && !kontrolData.durasi_2) { + issues.push('โš ๏ธ No duration configured'); + } else { + console.log(`โœ… Durasi 1: ${kontrolData.durasi_1 || 0} seconds`); + console.log(`โœ… Durasi 2: ${kontrolData.durasi_2 || 0} seconds`); + } + + console.log('\n5. Current Time vs Schedule:'); + const now = new Date(); + const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; + console.log(`โฐ Current time: ${currentTime} (${now.toLocaleString('id-ID', {timeZone: 'Asia/Jakarta'})})`); + + if (kontrolData.waktu_1 === currentTime) { + console.log('๐ŸŽฏ MATCH! Jadwal 1 should trigger NOW!'); + } else if (kontrolData.waktu_2 === currentTime) { + console.log('๐ŸŽฏ MATCH! Jadwal 2 should trigger NOW!'); + } else { + console.log(`โณ No match. Next schedule:`); + if (kontrolData.waktu_1) console.log(` - Jadwal 1 at ${kontrolData.waktu_1}`); + if (kontrolData.waktu_2) console.log(` - Jadwal 2 at ${kontrolData.waktu_2}`); + } + + console.log('\n6. Testing Firebase Write Permission...'); + await db.ref('_test_worker').set({ + timestamp: Date.now(), + message: 'Railway Worker test write', + from: 'test-firebase-direct.js' + }); + console.log('โœ… Successfully wrote test data to Firebase'); + + // Cleanup + await db.ref('_test_worker').remove(); + console.log('โœ… Test data cleaned up'); + + if (issues.length > 0) { + console.log('\nโš ๏ธ Configuration Issues Found:'); + issues.forEach(issue => console.log(` ${issue}`)); + } else { + console.log('\nโœ… All checks passed! Configuration looks good.'); + console.log('\n๐Ÿ“‹ Next Steps:'); + console.log(' 1. Deploy Railway Worker with these same credentials'); + console.log(' 2. Check Railway logs for confirmation'); + console.log(' 3. Worker should trigger at scheduled times'); + } + + } catch (error) { + console.error('\nโŒ Error:', error.message); + + if (error.code === 'auth/invalid-credential') { + console.error('\n๐Ÿ’ก Fix: Check your Firebase Service Account credentials'); + console.error(' 1. Go to Firebase Console โ†’ Project Settings โ†’ Service Accounts'); + console.error(' 2. Generate new private key'); + console.error(' 3. Update .env file with new credentials'); + } else if (error.message.includes('PERMISSION_DENIED')) { + console.error('\n๐Ÿ’ก Fix: Check Firebase Realtime Database Rules'); + console.error(' 1. Go to Firebase Console โ†’ Realtime Database โ†’ Rules'); + console.error(' 2. Make sure service account has read/write access'); + } + + process.exit(1); + } finally { + if (admin.apps.length > 0) { + await admin.app().delete(); + } + } +} + +testFirebase(); \ No newline at end of file diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..ed522f4 --- /dev/null +++ b/worker.js @@ -0,0 +1,536 @@ +/** + * ApsGo Railway Worker + * Background service untuk automation scheduling 24/7 + * Features: + * - Waktu Mode: Scheduled watering by time + * - Sensor Mode: Automatic watering by soil moisture threshold + * - Redis Queue: Prevent race conditions & concurrent task management + * - Firebase Realtime DB: Sync dengan Flutter app dan ESP32 + */ + +require('dotenv').config(); +const admin = require('firebase-admin'); +const { Queue, Worker } = require('bullmq'); +const Redis = require('ioredis'); +const cron = require('cron'); + +// ==================== CONFIGURATION ==================== + +// Set timezone untuk Indonesia (UTC+7) +process.env.TZ = process.env.TZ || 'Asia/Jakarta'; + +const config = { + redis: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT) || 6379, + password: process.env.REDIS_PASSWORD || undefined, + maxRetriesPerRequest: null, // Required for BullMQ + }, + firebase: { + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'), + databaseURL: process.env.FIREBASE_DATABASE_URL, + }, + worker: { + concurrency: 1, // Process 1 job at a time (prevent race condition) + checkInterval: 30000, // Check jadwal setiap 30 detik + sensorDebounce: 120000, // 2 menit minimum antar penyiraman per pot + }, +}; + +console.log('๐Ÿš€ Starting ApsGo Railway Worker...'); +console.log(`๐Ÿ“ก Firebase Project: ${config.firebase.projectId}`); +console.log(`๐Ÿ“ฆ Redis: ${config.redis.host}:${config.redis.port}`); +console.log(`โฐ Timezone: ${process.env.TZ} (Current: ${new Date().toLocaleString('id-ID', {timeZone: 'Asia/Jakarta'})})`); + +// ==================== ENVIRONMENT VALIDATION ==================== + +const requiredEnvs = [ + 'FIREBASE_PROJECT_ID', + 'FIREBASE_CLIENT_EMAIL', + 'FIREBASE_PRIVATE_KEY', + 'FIREBASE_DATABASE_URL' +]; + +const missingEnvs = requiredEnvs.filter(env => !process.env[env]); +if (missingEnvs.length > 0) { + console.error('โŒ Missing required environment variables:'); + missingEnvs.forEach(env => console.error(` - ${env}`)); + console.error('\n๐Ÿ“‹ To fix this:'); + console.error('1. Go to Railway Dashboard'); + console.error('2. Select your worker service'); + console.error('3. Go to Variables tab'); + console.error('4. Add the missing variables'); + console.error('5. Redeploy'); + process.exit(1); +} + +console.log('โœ… All required environment variables are set'); + +// ==================== FIREBASE INITIALIZATION ==================== + +try { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: config.firebase.projectId, + clientEmail: config.firebase.clientEmail, + privateKey: config.firebase.privateKey, + }), + databaseURL: config.firebase.databaseURL, + }); + console.log('โœ… Firebase Admin initialized'); +} catch (error) { + console.error('โŒ Firebase initialization failed:', error.message); + process.exit(1); +} + +const db = admin.database(); + +// ==================== REDIS & QUEUE SETUP ==================== + +const redis = new Redis(config.redis); +const wateringQueue = new Queue('watering', { connection: redis }); + +redis.on('connect', () => console.log('โœ… Redis connected')); +redis.on('error', (err) => console.error('โŒ Redis error:', err.message)); + +// Track last watering time untuk prevent spam +const lastWateringTime = {}; + +// ==================== WATERING WORKER ==================== + +const wateringWorker = new Worker( + 'watering', + async (job) => { + const { type, potNumbers, pompaAir, pompaPupuk, duration, scheduleId } = job.data; + + console.log(`\n๐Ÿ’ง Processing Job: ${job.id}`); + console.log(` Type: ${type}`); + console.log(` Pots: [${potNumbers.join(', ')}]`); + console.log(` Duration: ${duration}s`); + + try { + // Prepare aktuator updates + const updates = {}; + if (pompaAir) updates['mosvet_1'] = true; + if (pompaPupuk) updates['mosvet_2'] = true; + + // Turn ON valves for selected pots + for (const pot of potNumbers) { + if (pot >= 1 && pot <= 5) { + updates[`mosvet_${pot + 2}`] = true; // pot 1 โ†’ mosvet_3, etc. + } + } + + // Turn ON + console.log(' ๐Ÿ”› Turning ON:', Object.keys(updates).join(', ')); + await db.ref('aktuator').update(updates); + + // Wait for duration with progress logging + const startTime = Date.now(); + const endTime = startTime + duration * 1000; + + while (Date.now() < endTime) { + const remaining = Math.ceil((endTime - Date.now()) / 1000); + if (remaining % 10 === 0 || remaining <= 5) { + console.log(` โณ ${remaining}s remaining...`); + } + await sleep(1000); + } + + // Turn OFF + const offUpdates = {}; + for (const key in updates) { + offUpdates[key] = false; + } + console.log(' ๐Ÿ”ด Turning OFF'); + await db.ref('aktuator').update(offUpdates); + + // Log history + await logHistory(type, potNumbers, duration); + + // Update last watering time + for (const pot of potNumbers) { + lastWateringTime[`pot_${pot}`] = Date.now(); + } + + console.log(` โœ… Job completed successfully`); + return { success: true, duration, pots: potNumbers }; + } catch (error) { + console.error(` โŒ Job failed:`, error.message); + + // Safety: Turn OFF everything + try { + await db.ref('aktuator').update({ + mosvet_1: false, + mosvet_2: false, + mosvet_3: false, + mosvet_4: false, + mosvet_5: false, + mosvet_6: false, + mosvet_7: false, + mosvet_8: false, // Pengaduk + }); + console.log(' ๐Ÿ›ก๏ธ Safety: All aktuators turned OFF'); + } catch (safetyError) { + console.error(' โš ๏ธ Safety OFF failed:', safetyError.message); + } + + throw error; + } + }, + { + connection: redis, + concurrency: config.worker.concurrency, + removeOnComplete: { count: 100 }, // Keep last 100 completed jobs + removeOnFail: { count: 50 }, // Keep last 50 failed jobs + } +); + +wateringWorker.on('completed', (job) => { + console.log(`โœ… Worker completed job ${job.id}`); +}); + +wateringWorker.on('failed', (job, err) => { + console.error(`โŒ Worker failed job ${job?.id}:`, err.message); +}); + +// ==================== WAKTU MODE (TIME SCHEDULER) ==================== + +let lastScheduleCheck = {}; + +async function checkScheduledWatering() { + try { + console.log(`\nโฐ [DEBUG] Checking scheduled watering... ${new Date().toISOString()}`); + + const snapshot = await db.ref('kontrol').once('value'); + const kontrolConfig = snapshot.val(); + + console.log('๐Ÿ“Š [DEBUG] Retrieved kontrol config:', JSON.stringify(kontrolConfig, null, 2)); + + if (!kontrolConfig || !kontrolConfig.waktu) { + // Waktu mode disabled + console.log('โน๏ธ [DEBUG] Waktu mode is disabled or no config found'); + return; + } + + const now = new Date(); + const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; + const dateKey = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`; + + console.log(`๐Ÿ• [DEBUG] Current time: ${currentTime}, Date: ${dateKey}`); + console.log(`๐Ÿ“… [DEBUG] Scheduled times - Jadwal 1: ${kontrolConfig.waktu_1}, Jadwal 2: ${kontrolConfig.waktu_2}`); + if (kontrolConfig.waktu_1 && kontrolConfig.waktu_1 === currentTime) { + const scheduleKey = `jadwal_1_${dateKey}_${currentTime}`; + + if (!lastScheduleCheck[scheduleKey]) { + console.log(`\n๐Ÿ• JADWAL 1 TRIGGERED: ${currentTime}`); + + await wateringQueue.add( + 'schedule-1', + { + type: 'waktu_jadwal_1', + potNumbers: [1, 2, 3, 4, 5], // All pots + pompaAir: true, + pompaPupuk: true, + duration: kontrolConfig.durasi_1 || 60, + scheduleId: scheduleKey, + }, + { + jobId: scheduleKey, + removeOnComplete: true, + } + ); + + lastScheduleCheck[scheduleKey] = true; + console.log(` ๐Ÿ“Œ Added to queue: ${scheduleKey}`); + } else { + console.log(`โš ๏ธ [DEBUG] Jadwal 1 already processed: ${scheduleKey}`); + } + } else if (kontrolConfig.waktu_1) { + console.log(`โฐ [DEBUG] Jadwal 1 time mismatch - Expected: ${kontrolConfig.waktu_1}, Current: ${currentTime}`); + } + + // Check Jadwal 2 + if (kontrolConfig.waktu_2 && kontrolConfig.waktu_2 === currentTime) { + const scheduleKey = `jadwal_2_${dateKey}_${currentTime}`; + + if (!lastScheduleCheck[scheduleKey]) { + console.log(`\n๐Ÿ•‘ JADWAL 2 TRIGGERED: ${currentTime}`); + + await wateringQueue.add( + 'schedule-2', + { + type: 'waktu_jadwal_2', + potNumbers: [1, 2, 3, 4, 5], // All pots + pompaAir: true, + pompaPupuk: true, + duration: kontrolConfig.durasi_2 || 60, + scheduleId: scheduleKey, + }, + { + jobId: scheduleKey, + removeOnComplete: true, + } + ); + + lastScheduleCheck[scheduleKey] = true; + console.log(` ๐Ÿ“Œ Added to queue: ${scheduleKey}`); + } + } + + // Cleanup old schedule checks (> 2 menit) + const twoMinutesAgo = Date.now() - 120000; + for (const key in lastScheduleCheck) { + if (key.includes(dateKey)) continue; // Keep today's + delete lastScheduleCheck[key]; + } + } catch (error) { + console.error('โŒ Error checking scheduled watering:', error.message); + } +} + +// Run check setiap 30 detik +setInterval(checkScheduledWatering, config.worker.checkInterval); +console.log(`โœ… Waktu Mode scheduler started (check every ${config.worker.checkInterval / 1000}s)`); + +// ==================== SENSOR MODE (THRESHOLD MONITORING) ==================== + +async function setupSensorMonitoring() { + console.log('โœ… Sensor Mode monitoring started'); + + db.ref('data').on('value', async (snapshot) => { + try { + const sensorData = snapshot.val(); + if (!sensorData) return; + + const configSnapshot = await db.ref('kontrol').once('value'); + const kontrolConfig = configSnapshot.val(); + + if (!kontrolConfig || !kontrolConfig.otomatis) { + // Sensor mode disabled + return; + } + + const batasBawah = kontrolConfig.batas_bawah || 40; + const batasAtas = kontrolConfig.batas_atas || 100; + const durasiSensor = kontrolConfig.durasi_sensor || 60; + const modeSensor = kontrolConfig.mode_sensor || 'fixed'; // 'fixed' or 'smart' + + // Check each pot + for (let i = 1; i <= 5; i++) { + const soilKey = `soil_${i}`; + const soilValue = parseInt(sensorData[soilKey]) || 0; + + if (soilValue < batasBawah) { + const potKey = `pot_${i}`; + const lastTime = lastWateringTime[potKey]; + + // Debounce: minimum 2 menit antar penyiraman + if (lastTime && Date.now() - lastTime < config.worker.sensorDebounce) { + const remainingSeconds = Math.ceil((config.worker.sensorDebounce - (Date.now() - lastTime)) / 1000); + console.log(`โณ POT ${i}: Cooldown active (${remainingSeconds}s remaining)`); + continue; + } + + console.log(`\n๐ŸŒก๏ธ SENSOR TRIGGERED: POT ${i}`); + console.log(` Soil moisture: ${soilValue}% < ${batasBawah}%`); + console.log(` Mode: ${modeSensor}, Duration: ${durasiSensor}s`); + + const jobId = `sensor-pot-${i}-${Date.now()}`; + await wateringQueue.add( + `sensor-pot-${i}`, + { + type: 'sensor_threshold', + potNumbers: [i], + pompaAir: true, + pompaPupuk: false, // No pupuk for sensor mode + duration: durasiSensor, + scheduleId: jobId, + sensorData: { soilValue, batasBawah, batasAtas, mode: modeSensor }, + }, + { + jobId, + removeOnComplete: true, + priority: 1, // Higher priority for sensor-triggered + } + ); + + console.log(` ๐Ÿ“Œ Added to queue: ${jobId}`); + } + } + } catch (error) { + console.error('โŒ Error in sensor monitoring:', error.message); + } + }); +} + +setupSensorMonitoring(); + +// ==================== HISTORY LOGGING ==================== + +async function logHistory(type, potNumbers, duration) { + try { + const now = new Date(); + const dateKey = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`; + const timeKey = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; + + // Get current sensor data + const sensorSnapshot = await db.ref('data').once('value'); + const sensorData = sensorSnapshot.val() || {}; + + await db.ref(`history/${dateKey}/${timeKey}`).set({ + timestamp: now.getTime(), + type: type, + pots: potNumbers, + duration: duration, + ...sensorData, + }); + + console.log(` ๐Ÿ“Š History logged: ${dateKey} ${timeKey}`); + } catch (error) { + console.error(' โš ๏ธ Failed to log history:', error.message); + } +} + +// ==================== PERIODIC HISTORY LOGGING ==================== + +// Auto-log sensor data setiap 10 menit (independent from watering) +const autoLogJob = new cron.CronJob('*/10 * * * *', async () => { + try { + const sensorSnapshot = await db.ref('data').once('value'); + const sensorData = sensorSnapshot.val(); + + if (sensorData) { + const now = new Date(); + const dateKey = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`; + const timeKey = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; + + await db.ref(`history/${dateKey}/${timeKey}`).set({ + timestamp: now.getTime(), + type: 'auto_log', + ...sensorData, + }); + + console.log(`๐Ÿ“Š Auto-logged sensor data: ${timeKey}`); + } + } catch (error) { + console.error('โŒ Auto-log failed:', error.message); + } +}); + +autoLogJob.start(); +console.log('โœ… Auto history logging started (every 10 minutes)'); + +// ==================== CLEANUP OLD HISTORY (DAILY) ==================== + +const cleanupJob = new cron.CronJob('0 2 * * *', async () => { + // Run daily at 2 AM + try { + console.log('\n๐Ÿงน Running history cleanup...'); + const daysToKeep = 30; + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + const historySnapshot = await db.ref('history').once('value'); + const historyData = historySnapshot.val(); + + if (historyData) { + let deletedCount = 0; + for (const dateKey in historyData) { + try { + const [year, month, day] = dateKey.split('-').map(Number); + const date = new Date(year, month - 1, day); + + if (date < cutoffDate) { + await db.ref(`history/${dateKey}`).remove(); + deletedCount++; + console.log(` ๐Ÿ—‘๏ธ Deleted: ${dateKey}`); + } + } catch (error) { + console.error(` โš ๏ธ Error deleting ${dateKey}:`, error.message); + } + } + console.log(`โœ… Cleanup completed: ${deletedCount} dates removed`); + } + } catch (error) { + console.error('โŒ Cleanup failed:', error.message); + } +}); + +cleanupJob.start(); +console.log('โœ… History cleanup scheduled (daily at 2 AM)'); + +// ==================== UTILITIES ==================== + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// ==================== HEALTH CHECK ==================== + +async function healthCheck() { + try { + // Check Firebase connection + await db.ref('.info/connected').once('value'); + + // Check Redis connection + await redis.ping(); + + // Check queue + const queueStatus = await wateringQueue.getJobCounts(); + + console.log('\n๐Ÿ’š HEALTH CHECK:'); + console.log(` Firebase: โœ… Connected`); + console.log(` Redis: โœ… Connected`); + console.log(` Queue: ${queueStatus.active} active, ${queueStatus.waiting} waiting`); + } catch (error) { + console.error('โค๏ธโ€๐Ÿฉน HEALTH CHECK FAILED:', error.message); + } +} + +// Run health check every 5 minutes +setInterval(healthCheck, 300000); + +// ==================== GRACEFUL SHUTDOWN ==================== + +async function shutdown() { + console.log('\n๐Ÿ›‘ Shutting down gracefully...'); + + try { + await wateringWorker.close(); + console.log('โœ… Worker closed'); + + await wateringQueue.close(); + console.log('โœ… Queue closed'); + + await redis.quit(); + console.log('โœ… Redis disconnected'); + + await admin.app().delete(); + console.log('โœ… Firebase disconnected'); + + process.exit(0); + } catch (error) { + console.error('โŒ Shutdown error:', error.message); + process.exit(1); + } +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +// ==================== STARTUP COMPLETE ==================== + +console.log('\nโœจ ApsGo Railway Worker is running!'); +console.log('๐Ÿ“Š Features enabled:'); +console.log(' โ€ข Waktu Mode (Time-based scheduling)'); +console.log(' โ€ข Sensor Mode (Threshold-based automation)'); +console.log(' โ€ข Auto History Logging (every 10 min)'); +console.log(' โ€ข History Cleanup (daily at 2 AM)'); +console.log(' โ€ข Health Check (every 5 min)'); +console.log('\n๐ŸŽฏ Worker is ready to process jobs...\n'); + +// Initial health check +setTimeout(healthCheck, 5000);