Initial commit: Railway Worker for ApsGo IoT System
This commit is contained in:
commit
8da7b66494
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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);
|
||||||
Loading…
Reference in New Issue