/** * 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 */ // ==================== SUPPRESS FIREBASE WARNINGS ==================== // Completely suppress Firebase SDK warnings process.env.FIREBASE_DATABASE_EMULATOR_HOST = undefined; process.env.FIRESTORE_EMULATOR_HOST = undefined; // Override console.warn to filter Firebase warnings const originalWarn = console.warn; console.warn = function(...args) { const message = args.join(' '); // Suppress specific Firebase warnings if (message.includes('FIREBASE WARNING') || message.includes('@firebase/database') || message.includes('firebase/database')) { return; // Silent - don't log } originalWarn.apply(console, args); }; // ==================== IMPORTS ==================== 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'; // Firebase paths configuration const FIREBASE_PATHS = { kontrol: 'kontrol_1', // Main kontrol path (ubah ke 'kontrol' jika perlu) aktuator: 'aktuator', data: 'data', history: 'history', }; 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: 60000, // Check jadwal setiap 60 detik (reduced from 30s) sensorDebounce: 120000, // 2 menit minimum antar penyiraman per pot scheduleGraceMs: parseInt(process.env.SCHEDULE_GRACE_MS || '15000', 10), // Toleransi keterlambatan trigger scheduleMaxCatchupMs: parseInt(process.env.SCHEDULE_MAX_CATCHUP_MS || '300000', 10), // Maksimal catch-up 5 menit }, }; console.log('๐Ÿš€ Starting ApsGo Railway Worker...'); console.log(`๐Ÿ“ก Firebase Project: ${config.firebase.projectId}`); console.log(`๐Ÿ”ฅ Firebase DB URL: ${config.firebase.databaseURL}`); console.log(`๐Ÿ“ฆ Redis: ${config.redis.host}:${config.redis.port}`); console.log(`โฐ Timezone: ${process.env.TZ} (Current: ${new Date().toLocaleString('id-ID', {timeZone: 'Asia/Jakarta'})})`); console.log(`๐Ÿ“ Kontrol Path: /${FIREBASE_PATHS.kontrol}`); // ==================== 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(); // Add error handlers for Firebase database db.ref('.info/connected').on('value', (snap) => { if (snap.val() === true) { console.log('๐Ÿ”Œ Firebase realtime connection active'); } }); // ==================== 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 PER-THRESHOLD (not per-pot!) untuk prevent spam const lastThresholdTime = {}; // ==================== WATERING WORKER ==================== const wateringWorker = new Worker( 'watering', async (job) => { const { type, potNumbers, pompaAir, pompaPupuk, duration, scheduleId, thresholdId, smartMode, sensorData } = job.data; console.log(`\n๐Ÿ’ง Processing Job: ${job.id}`); console.log(` Type: ${type}`); console.log(` Pots: [${potNumbers.join(', ')}]`); console.log(` Mode: ${smartMode ? 'SMART (auto-stop at target)' : 'FIXED'}`); console.log(` Duration: ${duration}s ${smartMode ? '(max)' : ''}`); if (sensorData) { console.log(` Target: ${sensorData.batasBawah}% โ†’ ${sensorData.batasAtas}%`); } 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(', ')); console.log(' ๐Ÿ“Œ Firebase path: aktuator'); console.log(' ๐Ÿ“ Updates:', JSON.stringify(updates, null, 2)); await updateFirebaseSmart('aktuator', updates); console.log(` ๐Ÿš€ ALL VALVES STARTED SIMULTANEOUSLY: ${Object.keys(updates).filter(k => k.startsWith('mosvet_')).join(', ')}`); // SMART MODE: Monitor sensor and stop pots TOGETHER when they reach target if (smartMode && sensorData && sensorData.batasAtas) { const targetSoil = sensorData.batasAtas; const maxDuration = duration * 1000; // Convert to ms const startTime = Date.now(); // Track which pots are still actively watering let activePots = [...potNumbers]; console.log(` ๐ŸŽฏ SMART MODE: Monitoring ${activePots.length} pots, target ${targetSoil}%...`); console.log(` โšก Valves will stop TOGETHER when pots reach target (checked every 2s)`); while (activePots.length > 0 && Date.now() - startTime < maxDuration) { await sleep(2000); // Check every 2 seconds try { const currentSensorData = await readFirebaseSmart('data'); if (currentSensorData) { const elapsed = Math.floor((Date.now() - startTime) / 1000); const potsToStop = []; // Check ALL active pots and collect which ones reached target for (const pot of activePots) { const soilKey = `soil_${pot}`; const currentValue = parseInt(currentSensorData[soilKey]) || 0; if (currentValue >= targetSoil) { console.log(` โœ… [${elapsed}s] POT ${pot}: ${currentValue}% >= ${targetSoil}% - TARGET REACHED!`); potsToStop.push(pot); } else { console.log(` โณ [${elapsed}s] POT ${pot}: ${currentValue}% < ${targetSoil}% - continuing...`); } } // Stop ALL pots that reached target TOGETHER (not one-by-one!) if (potsToStop.length > 0) { const stopUpdates = {}; for (const pot of potsToStop) { stopUpdates[`mosvet_${pot + 2}`] = false; } await updateFirebaseSmart('aktuator', stopUpdates); console.log(` ๐Ÿ”ด STOPPED TOGETHER: ${Object.keys(stopUpdates).join(', ')} (Pots: [${potsToStop.join(', ')}])`); // Remove stopped pots from active list activePots = activePots.filter(p => !potsToStop.includes(p)); } if (activePots.length === 0) { console.log(` ๐ŸŽ‰ All pots reached target! Smart watering complete.`); } else { console.log(` ๐Ÿ“ Still watering: [${activePots.join(', ')}]`); } } } catch (sensorError) { console.warn(` โš ๏ธ Failed to read sensor: ${sensorError.message}`); } } // If any pots still active after max duration (timeout), stop them now TOGETHER if (activePots.length > 0) { console.log(` โฑ๏ธ Max duration ${duration}s reached. Force stopping remaining pots: [${activePots.join(', ')}]`); const timeoutStops = {}; for (const pot of activePots) { timeoutStops[`mosvet_${pot + 2}`] = false; } await updateFirebaseSmart('aktuator', timeoutStops); console.log(` ๐Ÿ”ด Force stopped TOGETHER: ${Object.keys(timeoutStops).join(', ')}`); } // Finally, stop pumps const pumpStop = {}; if (pompaAir) pumpStop['mosvet_1'] = false; if (pompaPupuk) pumpStop['mosvet_2'] = false; if (Object.keys(pumpStop).length > 0) { await updateFirebaseSmart('aktuator', pumpStop); console.log(' ๐Ÿ”ด Pumps stopped:', Object.keys(pumpStop).join(', ')); } console.log(' โœ… Smart mode completed, now logging history...'); } else { // FIXED MODE: Wait for fixed duration 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 all at once (FIXED mode only) const offUpdates = {}; for (const key in updates) { offUpdates[key] = false; } console.log(' ๐Ÿ”ด Turning OFF:', Object.keys(offUpdates).join(', ')); await updateFirebaseSmart('aktuator', offUpdates); console.log(' โœ… Turn OFF completed, now logging history...'); } // Log history await logHistory(type, potNumbers, duration); console.log(' โœ… History logged successfully'); // Update last watering time PER-THRESHOLD (not per-pot!) if (thresholdId) { lastThresholdTime[thresholdId] = Date.now(); console.log(` โฐ Cooldown set for ${thresholdId} (2 minutes)`); } 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 updateFirebaseSmart('aktuator', { 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, lockDuration: 900000, // 15 minutes max job time (duration 600s + 5min buffer) 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 = {}; let lastSchedulerTickAt = null; // Counter untuk tracking berapa kali check dilakukan let checkCounter = 0; let consecutiveFirebaseErrors = 0; let sdkSuccessCount = 0; let restFallbackCount = 0; // Smart fallback: Skip SDK jika sudah gagal berturut-turut 3x const SKIP_SDK_THRESHOLD = 3; const RESET_THRESHOLD_AFTER = 50; // Reset counter setelah 50 check (50 menit) // Helper function untuk Firebase fetch dengan timeout async function fetchWithTimeout(ref, timeoutMs = 5000) { return Promise.race([ ref.once('value'), new Promise((_, reject) => setTimeout(() => reject(new Error('Firebase fetch timeout')), timeoutMs) ) ]); } // Helper: Fetch with timeout wrapper (for REST API calls) async function fetchWithTimeout2(url, options = {}, timeoutMs = 8000) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { ...options, signal: controller.signal }); clearTimeout(timeout); return response; } catch (error) { clearTimeout(timeout); if (error.name === 'AbortError') { throw new Error('REST API timeout'); } throw error; } } // Fallback: Fetch via Firebase REST API (lebih reliable) async function fetchKontrolViaREST() { const url = `${config.firebase.databaseURL}/${FIREBASE_PATHS.kontrol}.json`; console.log(` [DEBUG] Trying REST API: ${url}`); const response = await fetchWithTimeout2(url, { method: 'GET', headers: { 'Content-Type': 'application/json' } }, 8000); if (!response.ok) { throw new Error(`REST API failed: ${response.status} ${response.statusText}`); } const data = await response.json(); console.log(' [DEBUG] REST API successful!'); return data; } // Smart fetch: Try SDK first, fallback to REST if needed async function fetchKontrolSmart() { // Smart fallback: Skip SDK if it failed 3+ times consecutively const shouldSkipSDK = consecutiveFirebaseErrors >= SKIP_SDK_THRESHOLD; if (shouldSkipSDK) { console.log(' [SMART] Skipping SDK (3+ consecutive failures), using REST API directly...'); try { const data = await fetchKontrolViaREST(); restFallbackCount++; // Reset counter setelah threshold untuk retry SDK if (restFallbackCount >= RESET_THRESHOLD_AFTER) { console.log(' [SMART] Resetting SDK retry counter...'); consecutiveFirebaseErrors = 0; restFallbackCount = 0; } return data; } catch (restError) { console.error(' โŒ REST API failed:', restError.message); throw new Error('REST API failed'); } } // Normal flow: Try SDK first try { console.log(' [DEBUG] Attempting SDK fetch...'); const snapshot = await fetchWithTimeout(db.ref(FIREBASE_PATHS.kontrol), 5000); consecutiveFirebaseErrors = 0; // Reset error counter restFallbackCount = 0; // Reset fallback counter sdkSuccessCount++; return snapshot.val(); } catch (sdkError) { console.warn(' โš ๏ธ SDK fetch failed, trying REST API...'); consecutiveFirebaseErrors++; try { const data = await fetchKontrolViaREST(); restFallbackCount++; // Log peringatan jika SDK terus gagal if (consecutiveFirebaseErrors === SKIP_SDK_THRESHOLD) { console.warn(` ๐Ÿšจ SDK failed ${SKIP_SDK_THRESHOLD}x consecutively! Will use REST API directly for next ${RESET_THRESHOLD_AFTER} checks.`); } return data; } catch (restError) { console.error(' โŒ REST API also failed:', restError.message); throw new Error('Both SDK and REST API failed'); } } } // Helper: Update Firebase via REST API (PATCH for merge update) async function updateFirebaseViaREST(path, updates) { const url = `${config.firebase.databaseURL}/${path}.json`; console.log(` [DEBUG] REST API PATCH: ${url}`); const response = await fetchWithTimeout2(url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }, 8000); if (!response.ok) { throw new Error(`REST PATCH failed: ${response.status}`); } const result = await response.json(); console.log(' [DEBUG] REST API update successful!'); return result; } // Helper: Update with timeout wrapper async function updateWithTimeout(ref, updates, timeoutMs = 5000) { return Promise.race([ ref.update(updates), new Promise((_, reject) => setTimeout(() => reject(new Error('Firebase update timeout')), timeoutMs) ) ]); } // Smart update: Try SDK first, fallback to REST if timeout async function updateFirebaseSmart(path, updates) { const updateStr = JSON.stringify(updates); console.log(` [UPDATE START] Path: ${path}, Data: ${updateStr}`); // If SDK is consistently failing, skip it for updates too const shouldSkipSDK = consecutiveFirebaseErrors >= SKIP_SDK_THRESHOLD; if (shouldSkipSDK) { console.log(` [UPDATE] Using REST API directly (SDK disabled)`); try { await updateFirebaseViaREST(path, updates); console.log(` โœ… [UPDATE] REST API successful!`); return true; } catch (restError) { console.error(` โŒ [UPDATE] REST failed: ${restError.message}`); throw new Error('REST update failed'); } } try { console.log(` [UPDATE] Step 1: Attempting SDK update...`); await updateWithTimeout(db.ref(path), updates, 5000); console.log(` โœ… [UPDATE] Step 2: SDK update successful!`); return true; } catch (sdkError) { console.warn(` โš ๏ธ [UPDATE] Step 2: SDK failed (${sdkError.message}), trying REST API...`); try { console.log(` [UPDATE] Step 3: Attempting REST API...`); await updateFirebaseViaREST(path, updates); console.log(` โœ… [UPDATE] Step 4: REST API successful!`); return true; } catch (restError) { console.error(` โŒ [UPDATE] Step 4: REST failed (${restError.message}) - BOTH METHODS FAILED!`); throw new Error('Both SDK and REST update failed'); } } } // Helper: Set Firebase via REST API (PUT for overwrite) async function setFirebaseViaREST(path, data) { const url = `${config.firebase.databaseURL}/${path}.json`; const response = await fetchWithTimeout2(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }, 8000); if (!response.ok) { throw new Error(`REST PUT failed: ${response.status}`); } return await response.json(); } // Helper: Set with timeout wrapper async function setWithTimeout(ref, data, timeoutMs = 5000) { return Promise.race([ ref.set(data), new Promise((_, reject) => setTimeout(() => reject(new Error('Firebase set timeout')), timeoutMs) ) ]); } // Smart set: Try SDK first, fallback to REST if timeout async function setFirebaseSmart(path, data) { const dataStr = JSON.stringify(data); console.log(` [SET START] Path: ${path}, Data: ${dataStr.substring(0,100)}...`); // If SDK is consistently failing, skip it const shouldSkipSDK = consecutiveFirebaseErrors >= SKIP_SDK_THRESHOLD; if (shouldSkipSDK) { console.log(` [SET] Using REST API directly (SDK disabled)`); try { await setFirebaseViaREST(path, data); console.log(` โœ… [SET] REST API successful!`); return true; } catch (restError) { console.error(` โŒ [SET] REST failed: ${restError.message}`); throw new Error('REST set failed'); } } try { console.log(` [SET] Step 1: Attempting SDK set...`); await setWithTimeout(db.ref(path), data, 5000); console.log(` โœ… [SET] Step 2: SDK set successful!`); return true; } catch (sdkError) { console.warn(` โš ๏ธ [SET] Step 2: SDK failed (${sdkError.message}), trying REST API...`); try { console.log(` [SET] Step 3: Attempting REST API...`); await setFirebaseViaREST(path, data); console.log(` โœ… [SET] Step 4: REST API successful!`); return true; } catch (restError) { console.error(` โŒ [SET] Step 4: REST failed - BOTH METHODS FAILED!`); throw new Error('Both SDK and REST set failed'); } } } // Smart read: Try SDK first, fallback to REST if timeout (for any path) async function readFirebaseSmart(path) { const shouldSkipSDK = consecutiveFirebaseErrors >= SKIP_SDK_THRESHOLD; if (shouldSkipSDK) { console.log(' [READ] Using REST API directly (SDK disabled)'); const url = `${config.firebase.databaseURL}/${path}.json`; const response = await fetchWithTimeout2(url, { method: 'GET', headers: { 'Content-Type': 'application/json' } }, 8000); if (!response.ok) { throw new Error(`REST GET failed: ${response.status}`); } return await response.json(); } try { const snapshot = await fetchWithTimeout(db.ref(path), 5000); return snapshot.val(); } catch (sdkError) { const url = `${config.firebase.databaseURL}/${path}.json`; const response = await fetchWithTimeout2(url, { method: 'GET', headers: { 'Content-Type': 'application/json' } }, 8000); if (!response.ok) { throw new Error(`REST GET failed: ${response.status}`); } return await response.json(); } } // Parse HH:mm menjadi Date hari ini (timezone proses mengikuti process.env.TZ) function parseScheduleTimeToday(scheduleTime, now) { if (typeof scheduleTime !== 'string' || !/^\d{2}:\d{2}$/.test(scheduleTime)) { return null; } const [hourStr, minuteStr] = scheduleTime.split(':'); const hour = Number(hourStr); const minute = Number(minuteStr); if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { return null; } const scheduledAt = new Date(now); scheduledAt.setHours(hour, minute, 0, 0); return scheduledAt; } function isScheduleInTriggerWindow(scheduleTime, now, windowStartMs, windowEndMs) { const scheduleDate = parseScheduleTimeToday(scheduleTime, now); if (!scheduleDate) { return { match: false, scheduleMs: null }; } const scheduleMs = scheduleDate.getTime(); const match = scheduleMs >= windowStartMs && scheduleMs <= windowEndMs; return { match, scheduleMs }; } async function checkScheduledWatering() { checkCounter++; console.log(`\n๐Ÿ”Ž [DEBUG] checkScheduledWatering() called - Counter: ${checkCounter}`); try { console.log(' [DEBUG] Fetching Firebase /kontrol...'); // Use smart fetch (SDK with REST fallback) const kontrolConfig = await fetchKontrolSmart(); console.log(` [DEBUG] Kontrol config received:`, kontrolConfig ? 'EXISTS' : 'NULL'); if (kontrolConfig) { console.log(` [DEBUG] Kontrol data:`, JSON.stringify(kontrolConfig, null, 2)); } const now = new Date(); const checkEndMs = now.getTime(); const currentTime = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`; const currentSeconds = now.getSeconds(); const dateKey = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`; // Trigger window untuk mencegah jadwal terlewat karena delay polling/API. const graceMs = config.worker.scheduleGraceMs; const maxCatchupMs = config.worker.scheduleMaxCatchupMs; const fallbackStartMs = checkEndMs - config.worker.checkInterval; const baselineStartMs = lastSchedulerTickAt || fallbackStartMs; const boundedStartMs = Math.max(baselineStartMs, checkEndMs - maxCatchupMs); const triggerWindowStartMs = boundedStartMs - graceMs; const triggerWindowEndMs = checkEndMs; // ๐Ÿ” VERBOSE LOG: Log setiap check untuk memastikan fungsi berjalan console.log(`\nโฑ๏ธ CHECK #${checkCounter}: ${currentTime}:${currentSeconds.toString().padStart(2, '0')} | Mode: ${kontrolConfig?.waktu ? 'โœ…' : 'โŒ'}`); // Detect all schedules (jadwal_1, jadwal_2, jadwal_3, ...) const allSchedules = kontrolConfig ? Object.keys(kontrolConfig).filter(key => key.startsWith('jadwal_')) : []; // Log detail setiap 3 menit ATAU jika menit habis dibagi 5 if (checkCounter % 3 === 0 || now.getMinutes() % 5 === 0) { console.log(` ๐Ÿ“… Date: ${dateKey}`); console.log(` ๐Ÿ• Current: ${currentTime} (${now.toLocaleString('id-ID', {timeZone: 'Asia/Jakarta'})})`); console.log(` ๐ŸชŸ Trigger window: ${new Date(triggerWindowStartMs).toLocaleTimeString('id-ID')} - ${new Date(triggerWindowEndMs).toLocaleTimeString('id-ID')}`); console.log(` Mode Waktu: ${kontrolConfig?.waktu ? 'โœ… ENABLED' : 'โŒ DISABLED'}`); console.log(` ๐Ÿ“Š API Stats: SDK=${sdkSuccessCount} | REST=${restFallbackCount} | Errors=${consecutiveFirebaseErrors}`); console.log(` ๐Ÿ“‹ Total Jadwal: ${allSchedules.length}`); if (kontrolConfig?.waktu && allSchedules.length > 0) { allSchedules.forEach(scheduleKey => { const schedule = kontrolConfig[scheduleKey]; if (schedule && typeof schedule === 'object') { const isActive = schedule.aktif !== false; // Default true if not specified const waktu = schedule.waktu || 'not set'; const potAktif = schedule.pot_aktif || []; const isMatch = waktu === currentTime; console.log(` ${isActive ? 'โœ…' : 'โŒ'} ${scheduleKey}: ${waktu} โ†’ Pot [${potAktif.join(', ')}] ${isMatch ? '๐Ÿ”” MATCH!' : ''}`); } }); } // Legacy support: Log old format if exists if (kontrolConfig?.waktu_1 || kontrolConfig?.waktu_2) { console.log(` [LEGACY] waktu_1: ${kontrolConfig.waktu_1 || 'not set'}`); console.log(` [LEGACY] waktu_2: ${kontrolConfig.waktu_2 || 'not set'}`); } } if (!kontrolConfig || !kontrolConfig.waktu) { // Waktu mode disabled console.log(` [DEBUG] Exiting early - kontrolConfig: ${kontrolConfig ? 'exists' : 'null'}, waktu: ${kontrolConfig?.waktu}`); return; } // NEW: Dynamic schedule checking - supports jadwal_1, jadwal_2, ... jadwal_N for (const scheduleKey of allSchedules) { const schedule = kontrolConfig[scheduleKey]; // Validate schedule structure if (!schedule || typeof schedule !== 'object') { console.log(` โš ๏ธ ${scheduleKey}: Invalid structure, skipping`); continue; } // Check if schedule is active (default: true if not specified) const isActive = schedule.aktif !== false; if (!isActive) { continue; // Skip disabled schedules } // Check if time matches const scheduleWaktu = schedule.waktu; const { match: isInWindow, scheduleMs } = isScheduleInTriggerWindow( scheduleWaktu, now, triggerWindowStartMs, triggerWindowEndMs ); if (!isInWindow) { continue; // Belum/terlalu lama lewat untuk window ini } if (scheduleMs !== null) { const delayedSec = Math.max(0, Math.floor((triggerWindowEndMs - scheduleMs) / 1000)); if (delayedSec > 0) { console.log(` โฑ๏ธ ${scheduleKey}: Triggered with ${delayedSec}s delay (within tolerance)`); } } // Extract schedule config const potAktif = schedule.pot_aktif || []; const durasi = schedule.durasi || 60; const pompaAir = schedule.pompa_air !== false; // Default true const pompaPupuk = schedule.pompa_pupuk || false; // Default false // Validate pot_aktif if (!Array.isArray(potAktif) || potAktif.length === 0) { console.log(` โš ๏ธ ${scheduleKey}: No active pots defined, skipping`); continue; } // Create unique job key const normalizedScheduleTime = scheduleWaktu.replace(':', '_'); const jobKey = `${scheduleKey}_${dateKey}_${normalizedScheduleTime}`; if (!lastScheduleCheck[jobKey]) { console.log(`\n๐Ÿ• ${scheduleKey.toUpperCase()} TRIGGERED: ${currentTime}`); console.log(` ๐ŸŽฏ Pot aktif: [${potAktif.join(', ')}]`); console.log(` โฑ๏ธ Durasi: ${durasi}s`); console.log(` ๐Ÿ’ง Pompa Air: ${pompaAir ? 'ON' : 'OFF'}`); console.log(` ๐ŸŒฟ Pompa Pupuk: ${pompaPupuk ? 'ON' : 'OFF'}`); try { await wateringQueue.add( scheduleKey, { type: `waktu_${scheduleKey}`, potNumbers: potAktif, pompaAir: pompaAir, pompaPupuk: pompaPupuk, duration: durasi, scheduleId: jobKey, }, { jobId: jobKey, removeOnComplete: true, } ); lastScheduleCheck[jobKey] = true; console.log(` โœ… Successfully added to queue: ${jobKey}`); // Check queue status const queueStatus = await wateringQueue.getJobCounts(); console.log(` ๐Ÿ“Š Queue status: ${queueStatus.active} active, ${queueStatus.waiting} waiting`); } catch (queueError) { console.error(` โŒ Failed to add ${scheduleKey} to queue:`, queueError.message); } } else { console.log(` โญ๏ธ ${scheduleKey} already triggered: ${jobKey}`); } } // LEGACY SUPPORT: Check old format (waktu_1, waktu_2) untuk backward compatibility const legacy1Window = isScheduleInTriggerWindow( kontrolConfig.waktu_1, now, triggerWindowStartMs, triggerWindowEndMs ); if (legacy1Window.match) { const legacyTimeKey = kontrolConfig.waktu_1.replace(':', '_'); const scheduleKey = `legacy_jadwal_1_${dateKey}_${legacyTimeKey}`; if (!lastScheduleCheck[scheduleKey]) { console.log(`\n๐Ÿ• [LEGACY] JADWAL 1 TRIGGERED: ${currentTime}`); console.log(` ๐ŸŽฏ Using legacy format (all pots)`); try { 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(` โœ… Successfully added legacy jadwal_1 to queue`); } catch (queueError) { console.error(` โŒ Failed to add legacy jadwal_1:`, queueError.message); } } } const legacy2Window = isScheduleInTriggerWindow( kontrolConfig.waktu_2, now, triggerWindowStartMs, triggerWindowEndMs ); if (legacy2Window.match) { const legacyTimeKey = kontrolConfig.waktu_2.replace(':', '_'); const scheduleKey = `legacy_jadwal_2_${dateKey}_${legacyTimeKey}`; if (!lastScheduleCheck[scheduleKey]) { console.log(`\n๐Ÿ•‘ [LEGACY] JADWAL 2 TRIGGERED: ${currentTime}`); console.log(` ๐ŸŽฏ Using legacy format (all pots)`); try { 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(` โœ… Successfully added legacy jadwal_2 to queue`); } catch (queueError) { console.error(` โŒ Failed to add legacy jadwal_2:`, queueError.message); } } } // Cleanup old schedule checks (> 2 menit) 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); console.error('[DEBUG] Error type:', error.constructor.name); console.error('[DEBUG] Stack trace:', error.stack); if (error.message === 'Firebase fetch timeout') { console.error('โš ๏ธ Firebase is not responding! Network or connection issue.'); console.error(' This could be:'); console.error(' - Slow network connection'); console.error(' - Firebase Realtime DB throttling'); console.error(' - Security rules blocking access'); } // Continue running - don't crash worker } finally { lastSchedulerTickAt = Date.now(); } } // Run check setiap 60 detik setInterval(async () => { try { await checkScheduledWatering(); } catch (error) { console.error('โŒ Error in scheduled check interval:', error.message); console.error(error.stack); } }, config.worker.checkInterval); console.log(`โœ… Waktu Mode scheduler started (check every ${config.worker.checkInterval / 1000}s)`); // Jalankan check pertama kali setelah 8 detik (setelah diagnostic selesai) setTimeout(async () => { try { console.log('\n๐Ÿš€ Running first schedule check immediately...'); console.log('[DEBUG] About to call checkScheduledWatering()...'); await checkScheduledWatering(); console.log('[DEBUG] checkScheduledWatering() returned'); console.log('โœ… First check completed successfully'); } catch (error) { console.error('โŒ First check failed:', error.message); console.error('[DEBUG] Error stack:', error.stack); } }, 8000); // ==================== SENSOR MODE (THRESHOLD MONITORING) ==================== let sensorCheckCounter = 0; // Core sensor check logic (shared by listener and polling) async function checkSensorThresholds() { sensorCheckCounter++; try { // Fetch sensor data using smart method (REST fallback) const sensorData = await readFirebaseSmart('data'); if (!sensorData) { console.log('โš ๏ธ Sensor data is null/empty - ESP32 might not be sending data'); return; } // Fetch kontrol config using smart method const kontrolConfig = await fetchKontrolSmart(); if (!kontrolConfig) { console.log('โš ๏ธ Kontrol config is null'); return; } // Check if sensor mode is enabled (using 'otomatis' field, not deprecated 'sensor') if (!kontrolConfig.otomatis) { if (sensorCheckCounter % 10 === 0) { console.log(`โš ๏ธ Sensor mode DISABLED (otomatis=false). Skipping threshold check.`); } return; } // Detect all threshold_* nodes const allThresholds = Object.keys(kontrolConfig).filter(key => key.startsWith('threshold_')); // Log sensor check (ALWAYS LOG untuk debugging) console.log(`\n๐ŸŒก๏ธ SENSOR CHECK #${sensorCheckCounter} | Total Thresholds: ${allThresholds.length}`); console.log(` ๐Ÿ“Š Sensor Data:`, JSON.stringify(sensorData, null, 2)); if (allThresholds.length === 0) { console.log(' โš ๏ธ No thresholds configured'); return; } // Process each threshold for (const thresholdKey of allThresholds) { const threshold = kontrolConfig[thresholdKey]; console.log(`\n ๐Ÿ” Checking ${thresholdKey}:`); console.log(` Config:`, JSON.stringify(threshold, null, 2)); // Skip if threshold is not active or invalid if (!threshold || !threshold.aktif) { console.log(` โŒ Skipped: ${!threshold ? 'Not found' : 'Not active (aktif=false)'}`); continue; } // Check THRESHOLD cooldown (not per-pot!) const lastTime = lastThresholdTime[thresholdKey]; if (lastTime && Date.now() - lastTime < config.worker.sensorDebounce) { const remainingSeconds = Math.ceil((config.worker.sensorDebounce - (Date.now() - lastTime)) / 1000); console.log(` โณ ${thresholdKey}: Cooldown active (${remainingSeconds}s remaining) - skipping entire threshold`); continue; } const batasBawah = threshold.batas_bawah || 30; const batasAtas = threshold.batas_atas || 70; const durasi = threshold.durasi || 600; const smartMode = threshold.smart_mode === true; const potAktif = threshold.pot_aktif || []; const pompaAir = threshold.pompa_air === true; const pompaPupuk = threshold.pompa_pupuk === true; // Collect pots that need watering in this threshold const potsNeedWatering = []; const potDetails = []; // Check each pot in this threshold for (const potNumber of potAktif) { if (potNumber < 1 || potNumber > 5) { console.log(` โš ๏ธ POT ${potNumber}: Invalid pot number (must be 1-5)`); continue; } const soilKey = `soil_${potNumber}`; const soilValue = parseInt(sensorData[soilKey]) || 0; console.log(` ๐ŸŒฑ POT ${potNumber} (${soilKey}): ${soilValue}% | Threshold: ${batasBawah}-${batasAtas}%`); console.log(` โ†’ Raw value: ${sensorData[soilKey]} | Parsed: ${soilValue} | Check: ${soilValue} < ${batasBawah} = ${soilValue < batasBawah}`); // NEW: Check if ABOVE upper threshold - skip if too wet! if (soilValue >= batasAtas) { console.log(` โœ… POT ${potNumber}: SKIP (${soilValue}% >= ${batasAtas}% - sudah basah!)`); continue; } // Check if below lower threshold if (soilValue < batasBawah) { console.log(` ๐Ÿšจ POT ${potNumber} KERING! ${soilValue}% < ${batasBawah}%`); // Add to watering list (cooldown already checked at threshold level) potsNeedWatering.push(potNumber); potDetails.push({ pot: potNumber, value: soilValue }); } else { console.log(` โœ… POT ${potNumber}: OK (${soilValue}% >= ${batasBawah}%)`); } } // NEW: Create SINGLE job for ALL pots that need watering in this threshold if (potsNeedWatering.length > 0) { console.log(`\n๐ŸŒก๏ธ THRESHOLD TRIGGERED: ${thresholdKey.toUpperCase()}`); console.log(` Pots needing water: [${potsNeedWatering.join(', ')}]`); potDetails.forEach(p => console.log(` - POT ${p.pot}: ${p.value}% < ${batasBawah}%`)); console.log(` Mode: ${smartMode ? 'Smart (monitor until ' + batasAtas + '%)' : 'Fixed (' + durasi + 's)'}`); console.log(` Pumps: Air=${pompaAir}, Pupuk=${pompaPupuk}`); const jobId = `${thresholdKey}-${Date.now()}`; await wateringQueue.add( thresholdKey, { type: 'sensor_threshold', potNumbers: potsNeedWatering, // ALL pots in 1 job! pompaAir: pompaAir, pompaPupuk: pompaPupuk, duration: durasi, scheduleId: jobId, thresholdId: thresholdKey, smartMode: smartMode, sensorData: { batasBawah, batasAtas, mode: smartMode ? 'smart' : 'fixed', potValues: potDetails }, }, { jobId, removeOnComplete: true, priority: 1, // Higher priority for sensor-triggered } ); console.log(` ๐Ÿ“Œ Added to queue: ${jobId}`); console.log(` ๐Ÿ”„ ${thresholdKey} will execute simultaneously for ALL pots`); console.log(` โฐ After completion, ${thresholdKey} cooldown = 2 minutes (other thresholds can still run)`); } } } catch (error) { console.error('โŒ Error in sensor threshold check:', error.message); } } // Setup sensor monitoring with BOTH listener (SDK) and polling (fallback) async function setupSensorMonitoring() { console.log('๐ŸŒก๏ธ ==================== SENSOR MODE ENABLED ===================='); console.log('โœ… Sensor Mode (Threshold System) monitoring started'); console.log('๐Ÿ“ Primary: Polling every 30 seconds (REST API)'); console.log('๐Ÿ“ Backup: Firebase listener on /data (if SDK works)'); console.log('๐Ÿ“ Config path: /' + FIREBASE_PATHS.kontrol); console.log('โฑ๏ธ Debounce time: 2 minutes between watering per pot'); console.log('================================================================\n'); // METHOD 1: Polling (RELIABLE - uses REST API) // Check sensor threshold every 30 seconds setInterval(async () => { try { await checkSensorThresholds(); } catch (error) { console.error('โŒ Polling sensor check failed:', error.message); } }, 30000); // 30 seconds // Run first check immediately setTimeout(async () => { console.log('๐Ÿš€ Running first sensor check...'); try { await checkSensorThresholds(); console.log('โœ… First sensor check completed'); } catch (error) { console.error('โŒ First sensor check failed:', error.message); } }, 10000); // 10 seconds after startup // METHOD 2: Firebase Listener (BACKUP - might not work if SDK fails) try { db.ref('data').on('value', async (snapshot) => { console.log('๐Ÿ”” Firebase listener triggered (SDK working!)'); // Call the same check function await checkSensorThresholds(); }, (error) => { console.error('โŒ Firebase listener error:', error.message); }); console.log('โœ… Firebase listener attached (backup method)'); } catch (error) { console.log('โš ๏ธ Firebase listener failed to attach (will rely on polling)'); } } 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 with timeout const sensorData = await readFirebaseSmart('data') || {}; await setFirebaseSmart(`history/${dateKey}/${timeKey}`, { 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 30 menit (independent from watering) const autoLogJob = new cron.CronJob('*/30 * * * *', async () => { try { const sensorData = await readFirebaseSmart('data'); 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 setFirebaseSmart(`history/${dateKey}/${timeKey}`, { 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 30 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 = 10; const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); const historyData = await readFirebaseSmart('history'); 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) { // Use REST API DELETE with timeout wrapper const url = `${config.firebase.databaseURL}/history/${dateKey}.json`; await fetchWithTimeout2(url, { method: 'DELETE' }, 10000); 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)); } // ==================== MANUAL TEST FUNCTIONS ==================== // ๐Ÿงช Test scheduler sekarang juga (untuk debugging) async function testSchedulerNow() { try { console.log('\n๐Ÿงช MANUAL TEST: Triggering test watering job NOW...'); const now = new Date(); const testJobId = `manual-test-${now.getTime()}`; await wateringQueue.add( 'manual-test', { type: 'manual_test', potNumbers: [1], // Test dengan 1 pot saja pompaAir: true, pompaPupuk: false, duration: 10, // 10 detik test scheduleId: testJobId, }, { jobId: testJobId, removeOnComplete: true, priority: 10, // Highest priority } ); console.log(`โœ… Test job added: ${testJobId}`); console.log(' Watch for job processing logs...'); } catch (error) { console.error('โŒ Test scheduler failed:', error.message); } } // ๐Ÿ” Check Firebase aktuator node structure async function checkAktuatorNode() { try { console.log('\n๐Ÿ” CHECKING AKTUATOR NODE...'); const aktuatorData = await readFirebaseSmart('aktuator'); if (!aktuatorData) { console.log('โŒ Aktuator node NOT FOUND in Firebase!'); console.log(' Creating default aktuator structure...'); await setFirebaseSmart('aktuator', { mosvet_1: false, // Pompa Air mosvet_2: false, // Pompa Pupuk mosvet_3: false, // Pot 1 mosvet_4: false, // Pot 2 mosvet_5: false, // Pot 3 mosvet_6: false, // Pot 4 mosvet_7: false, // Pot 5 mosvet_8: false, // Pengaduk }); console.log('โœ… Aktuator node created with defaults'); } else { console.log('โœ… Aktuator node exists:'); for (const key in aktuatorData) { console.log(` ${key}: ${aktuatorData[key]}`); } // Validate all required mosvets exist const required = ['mosvet_1', 'mosvet_2', 'mosvet_3', 'mosvet_4', 'mosvet_5', 'mosvet_6', 'mosvet_7', 'mosvet_8']; const missing = required.filter(key => !(key in aktuatorData)); if (missing.length > 0) { console.log(`โš ๏ธ Missing mosvets: ${missing.join(', ')}`); console.log(' Adding missing mosvets...'); const updates = {}; missing.forEach(key => updates[key] = false); await updateFirebaseSmart('aktuator', updates); console.log('โœ… Missing mosvets added'); } } } catch (error) { console.error('โŒ Aktuator check failed:', error.message); } } // ๐Ÿ• Show current time in multiple formats async function showCurrentTime() { try { const now = new Date(); console.log('\n๐Ÿ• CURRENT TIME ANALYSIS:'); console.log(` Server Local: ${now.toString()}`); console.log(` Asia/Jakarta: ${now.toLocaleString('id-ID', {timeZone: 'Asia/Jakarta'})}`); console.log(` ISO: ${now.toISOString()}`); console.log(` Unix: ${now.getTime()}`); console.log(` TZ Env: ${process.env.TZ}`); console.log(` HH:MM Format: ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`); // Check Firebase kontrol waktu console.log('[DEBUG] Fetching kontrol for time analysis...'); const snapshot = await fetchWithTimeout(db.ref(FIREBASE_PATHS.kontrol), 10000); const kontrolConfig = snapshot.val(); console.log('[DEBUG] Kontrol fetch successful'); if (kontrolConfig) { console.log('\n๐Ÿ“‹ FIREBASE KONTROL:'); console.log(` Mode Waktu: ${kontrolConfig.waktu ? 'ENABLED โœ…' : 'DISABLED โŒ'}`); console.log(` Waktu 1: ${kontrolConfig.waktu_1 || 'not set'}`); console.log(` Waktu 2: ${kontrolConfig.waktu_2 || 'not set'}`); console.log(` Durasi 1: ${kontrolConfig.durasi_1 || 'not set'}s`); console.log(` Durasi 2: ${kontrolConfig.durasi_2 || 'not set'}s`); } else { console.log('\nโŒ Firebase kontrol node is empty!'); } } catch (error) { console.error('โŒ Time check failed:', error.message); } } // ==================== HEALTH CHECK ==================== async function healthCheck() { try { // Check Firebase connection (skip SDK, just check via config) const firebaseOk = config.firebase.databaseURL ? true : false; // Check Redis connection await redis.ping(); // Check queue const queueStatus = await wateringQueue.getJobCounts(); console.log('\n๐Ÿ’š HEALTH CHECK:'); console.log(` Firebase: ${firebaseOk ? 'โœ…' : 'โŒ'} 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); // ==================== PREVENT CRASHES ==================== // Handle uncaught exceptions process.on('uncaughtException', (error) => { console.error('โŒ Uncaught Exception:', error.message); console.error(error.stack); // Don't exit - try to keep worker running console.log('โš ๏ธ Worker continuing despite error...'); }); // Handle unhandled promise rejections process.on('unhandledRejection', (reason, promise) => { console.error('โŒ Unhandled Rejection at:', promise); console.error('Reason:', reason); // Don't exit - try to keep worker running console.log('โš ๏ธ Worker continuing despite rejection...'); }); // ==================== 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 30 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); // ==================== KEEP-ALIVE MECHANISM ==================== // Heartbeat every 30 seconds to prevent Railway from stopping container setInterval(() => { const uptime = Math.floor(process.uptime()); const hours = Math.floor(uptime / 3600); const minutes = Math.floor((uptime % 3600) / 60); console.log(`๐Ÿ’“ Heartbeat: Worker alive for ${hours}h ${minutes}m`); }, 30000); // Verify Firebase connection on startup setTimeout(async () => { try { console.log('๐Ÿ” Verifying Firebase connection...'); console.log('[DEBUG] Testing Firebase read with timeout...'); const snapshot = await fetchWithTimeout(db.ref(FIREBASE_PATHS.kontrol), 10000); console.log('[DEBUG] Firebase read successful!'); const data = snapshot.val(); if (data) { console.log(`โœ… Firebase /${FIREBASE_PATHS.kontrol} readable - waktu mode:`, data.waktu ? 'ENABLED' : 'DISABLED'); if (data.waktu) { console.log(` ๐Ÿ“… Schedules: ${data.waktu_1 || 'none'} / ${data.waktu_2 || 'none'}`); } } else { console.log('โš ๏ธ Firebase /kontrol is empty - waiting for Flutter app to set schedule'); } } catch (error) { console.error('โŒ Firebase verification failed:', error.message); } }, 3000); // Run diagnostic checks on startup setTimeout(async () => { try { console.log('\n๐Ÿ”ง RUNNING DIAGNOSTIC CHECKS...'); await showCurrentTime(); await checkAktuatorNode(); console.log('\nโœ… Diagnostic checks completed'); console.log('\n๐Ÿ’ก TIP: To test scheduler manually, check the logs above for current time'); console.log(' Then set waktu_1 or waktu_2 in Firebase to match current time + 1 minute'); } catch (error) { console.error('โŒ Diagnostic checks failed:', error.message); console.error(error.stack); } }, 5000); // Auto-run test scheduler setiap 10 menit untuk memastikan worker alive setInterval(() => { const now = new Date(); // Run at :00, :10, :20, :30, :40, :50 if (now.getMinutes() % 10 === 0 && now.getSeconds() < 30) { showCurrentTime(); } }, 30000);