Initial commit: Railway Worker for ApsGo IoT System

This commit is contained in:
Wizznu 2026-02-11 07:40:33 +07:00
commit 8da7b66494
13 changed files with 1481 additions and 0 deletions

13
.env.example Normal file
View File

@ -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

27
.env.template Normal file
View File

@ -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

27
.gitignore vendored Normal file
View File

@ -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

148
README.md Normal file
View File

@ -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

130
SETUP_FIREBASE.md Normal file
View File

@ -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)

54
check-queue.js Normal file
View File

@ -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();

115
debug.js Normal file
View File

@ -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();

93
extract-credentials.js Normal file
View File

@ -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);
});

30
package.json Normal file
View File

@ -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"
}
}

13
railway.json Normal file
View File

@ -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"
}
}

149
test-firebase-connection.js Normal file
View File

@ -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();

146
test-firebase-direct.js Normal file
View File

@ -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();

536
worker.js Normal file
View File

@ -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);