require('dotenv').config(); const http = require('http'); const cron = require('node-cron'); const admin = require('firebase-admin'); const PORT = Number(process.env.PORT || 3000); const TZ = process.env.TZ || 'Asia/Jakarta'; const SCHEDULER_ENABLED = String(process.env.SCHEDULER_ENABLED || 'true') === 'true'; const COMPACT_MODE = String(process.env.COMPACT_MODE || 'true') === 'true'; const RIWAYAT_MAX_RECORDS = Number(process.env.RIWAYAT_MAX_RECORDS || 500); const SCHEDULER_RUNS_RETAIN_DAYS = Number(process.env.SCHEDULER_RUNS_RETAIN_DAYS || 14); function getEnvOrThrow(key) { const value = process.env[key]; if (!value) { throw new Error(`Missing env var: ${key}`); } return value; } // PERBAIKAN: function parseServiceAccountFromEnv() { const rawJson = process.env.FIREBASE_SERVICE_ACCOUNT_JSON; if (rawJson) { const parsed = JSON.parse(rawJson); return { projectId: parsed.project_id, clientEmail: parsed.client_email, privateKey: String(parsed.private_key || '').replace(/\\n/g, '\n'), }; } // Panggil NAMA VARIABEL yang didaftarkan di Railway return { projectId: getEnvOrThrow('FIREBASE_PROJECT_ID'), clientEmail: getEnvOrThrow('FIREBASE_CLIENT_EMAIL'), privateKey: getEnvOrThrow('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'), }; } function initFirebase() { const { projectId, clientEmail, privateKey } = parseServiceAccountFromEnv(); // Panggil NAMA VARIABEL untuk URL database const databaseURL = getEnvOrThrow('FIREBASE_DATABASE_URL'); admin.initializeApp({ credential: admin.credential.cert({ projectId, clientEmail, privateKey, }), databaseURL, }); console.log('[init] Firebase Admin connected:', projectId); } function getJakartaNow() { return new Date(new Date().toLocaleString('en-US', { timeZone: TZ })); } function formatDateKey(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function formatHHMM(date) { return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; } function toInt(value) { const n = Number(value); return Number.isFinite(n) ? n : 0; } function toDate(value) { const d = new Date(String(value || '')); return Number.isNaN(d.getTime()) ? null : d; } function normalizeKandangKey(kandangId, kandangNama) { const name = String(kandangNama || '').toLowerCase(); if (name.includes('kandang 1') || name.includes('kandang1') || name.includes('kandang_1')) return 'kandang1'; if (name.includes('kandang 2') || name.includes('kandang2') || name.includes('kandang_2')) return 'kandang2'; const rawId = String(kandangId || '').toLowerCase(); const rawNama = String(kandangNama || '').toLowerCase(); const merged = `${rawId} ${rawNama}`; const compact = merged.replace(/[^a-z0-9]/g, ''); if (compact.includes('kandang1')) return 'kandang1'; if (compact.includes('kandang2')) return 'kandang2'; return 'lainnya'; } function parseDurasiMs(durasiRaw) { const text = String(durasiRaw || '').toLowerCase().trim(); if (!text) return 30 * 60 * 1000; const number = Number.parseInt(text.replace(/[^0-9]/g, ''), 10); if (!Number.isFinite(number) || number <= 0) return 30 * 60 * 1000; if ( text.includes('jam') || text.includes('hour') || text.includes('hours') || text.includes('hr') ) { return number * 60 * 60 * 1000; } return number * 60 * 1000; } function isAllKandangSchedule(jadwal) { const kandangId = String(jadwal.kandangId || '').toLowerCase().trim(); const kandangNama = String(jadwal.kandangNama || '').toLowerCase().trim(); return ( kandangId === 'all' || kandangId === 'global' || kandangId === 'semua' || kandangNama.includes('global') || kandangNama.includes('semua') || kandangNama.includes('all') ); } function resolveTargetKandangIds(jadwal, kandangMap) { if (isAllKandangSchedule(jadwal)) { const ids = Object.keys(kandangMap || {}); if (ids.length > 0) return ids; return ['kandang1', 'kandang2']; } const kandangId = String(jadwal.kandangId || '').trim(); if (!kandangId) return []; return [kandangId]; } function detectJenisPanen(jadwal, jadwalOrder) { // Prioritas 1: baca langsung dari Firebase const tipe = String( jadwal.tipeJadwal || jadwal.tipe_jadwal || jadwal.keterangan || '' ) .toLowerCase() .trim(); if (tipe.includes('pagi')) { return 'pagi'; } if (tipe.includes('sore')) { return 'sore'; } // Fallback untuk data lama if (jadwalOrder === 0) { return 'pagi'; } return 'sore'; } function resolveInfraPath(kandangId, kandangData) { if (kandangData && kandangData.infraPath) { const path = String(kandangData.infraPath).toLowerCase(); if (path.includes('2')) return 'infra2'; if (path.includes('1')) return 'infra1'; return path; } if (kandangData && kandangData.nama) { const nama = String(kandangData.nama).toLowerCase(); if (nama.includes('2') || nama.includes('dua')) return 'infra2'; if (nama.includes('1') || nama.includes('satu')) return 'infra1'; } const id = String(kandangId || '').toLowerCase(); if (id.length < 15) { if (id.includes('2')) return 'infra2'; if (id.includes('1')) return 'infra1'; } return 'infra1'; } async function acquireRunLock(todayKey, lockKey) { const lockRef = admin.database().ref(`scheduler_runs/${todayKey}/${lockKey}`); const tx = await lockRef.transaction((current) => { if (current) return current; return { executedAt: new Date().toISOString(), source: 'railway-scheduler', }; }); return tx.committed; } async function updateRiwayatSummary({ kandangId, kandangNama, jumlahTelur, dateKey }) { const summaryRef = admin.database().ref('riwayat/summary'); const kandangKey = normalizeKandangKey(kandangId, kandangNama); const totalAdd = toInt(jumlahTelur); await summaryRef.transaction((current) => { const nowData = current || {}; const lastDate = String(nowData.last_reset_date || ''); let telurHariIni = toInt(nowData.telur_hari_ini); let kandang1 = toInt(nowData.kandang1_hari_ini); let kandang2 = toInt(nowData.kandang2_hari_ini); let totalTelur = toInt(nowData.total_telur); if (lastDate !== dateKey) { telurHariIni = 0; kandang1 = 0; kandang2 = 0; } telurHariIni += totalAdd; totalTelur += totalAdd; if (kandangKey === 'kandang1') kandang1 += totalAdd; if (kandangKey === 'kandang2') kandang2 += totalAdd; return { ...nowData, telur_hari_ini: telurHariIni, kandang1_hari_ini: kandang1, kandang2_hari_ini: kandang2, total_telur: totalTelur, last_reset_date: dateKey, updated_at: new Date().toISOString(), }; }); } async function resetSensorAfterEvening(todayKey, nowHHMM) { const lockKey = `evening_sensor_reset_${nowHHMM.replace(':', '')}`; const gotLock = await acquireRunLock(todayKey, lockKey); if (!gotLock) { console.log('[skip] Sensor & snapshot reset sudah dieksekusi untuk menit ini'); return; } await admin.database().ref('data').update({ infra1: 0, infra2: 0, last_reset_by_scheduler: new Date().toISOString(), }); // PERINTAH RESET KE ESP32 await admin.database().ref('kontrol').update({ reset_counter: true }); await admin.database().ref(`panen_snapshot/${todayKey}`).remove(); console.log('[ok] Sensor infra1/infra2 dan snapshot panen di-reset otomatis setelah jadwal sore'); } async function writeRiwayat({ kandangId, kandangNama, jumlahTelur, jam, jenisPanen, sensorSnapshot, panenSebelumnya, catatan, dateKey, }) { const riwayatRef = admin.database().ref('riwayat/records').push(); const now = new Date(); const jumlah = toInt(jumlahTelur); const sensor = toInt(sensorSnapshot); await riwayatRef.set({ id: `railway_${Date.now()}`, kandang_id: kandangId, kandang_nama: kandangNama, jumlah_telur: jumlah, tanggal_panen: now.toISOString(), jam, jenis_panen: jenisPanen, sensor_snapshot: sensor, panen_sebelumnya: panenSebelumnya == null ? null : Number(panenSebelumnya), catatan, }); await updateRiwayatSummary({ kandangId, kandangNama, jumlahTelur: jumlah, dateKey, }); } async function shouldRunMaintenance() { const lockRef = admin.database().ref('maintenance/migration_v2_global_alias'); const tx = await lockRef.transaction((current) => { if (current && current.done === true) return current; return { done: true, done_at: new Date().toISOString(), source: 'railway-scheduler', }; }); return tx.committed; } function looksLikeLegacyRecord(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; return 'kandang_id' in value && 'tanggal_panen' in value; } function buildKandangNameMap(kandangMap) { const map = {}; for (const kandangId of Object.keys(kandangMap || {})) { const kandang = kandangMap[kandangId]; map[kandangId] = String((kandang && kandang.nama) || kandangId); } return map; } function parseDateKeyFromIso(iso) { const date = new Date(String(iso || '')); if (Number.isNaN(date.getTime())) return null; return formatDateKey(date); } async function inferTargetForGlobalRecord(record, kandangMap) { const dateKey = parseDateKeyFromIso(record.tanggal_panen); if (!dateKey) return null; const jenis = String(record.jenis_panen || '').toLowerCase(); const sessionKey = jenis === 'sore' ? 'sore' : 'pagi'; const sessionSnapRef = admin.database().ref(`panen_snapshot/${dateKey}/${sessionKey}`); const sessionSnap = await sessionSnapRef.get(); if (!sessionSnap.exists() || typeof sessionSnap.val() !== 'object') return null; const snapData = sessionSnap.val(); const candidates = []; for (const kandangId of Object.keys(kandangMap || {})) { if (!Object.prototype.hasOwnProperty.call(snapData, kandangId)) continue; const snapVal = toInt(snapData[kandangId]); if (jenis === 'sore') { const deltaKey = `delta_${kandangId}`; const deltaVal = toInt(snapData[deltaKey]); if (deltaVal === toInt(record.jumlah_telur) || snapVal === toInt(record.sensor_snapshot)) { candidates.push(kandangId); } } else if (snapVal === toInt(record.sensor_snapshot) || snapVal === toInt(record.jumlah_telur)) { candidates.push(kandangId); } } return candidates.length === 1 ? candidates[0] : null; } async function migrateLegacyRiwayatData(kandangMap) { const riwayatRef = admin.database().ref('riwayat'); const rootSnap = await riwayatRef.get(); if (!rootSnap.exists() || typeof rootSnap.val() !== 'object') { return; } const root = rootSnap.val(); const recordsRef = admin.database().ref('riwayat/records'); const summaryRef = admin.database().ref('riwayat/summary'); const kandangNameMap = buildKandangNameMap(kandangMap); if (!root.summary && (root.telur_hari_ini != null || root.total_telur != null)) { await summaryRef.update({ telur_hari_ini: toInt(root.telur_hari_ini), total_telur: toInt(root.total_telur), last_reset_date: String(root.last_reset_date || formatDateKey(getJakartaNow())), kandang1_hari_ini: toInt(root.kandang1_hari_ini), kandang2_hari_ini: toInt(root.kandang2_hari_ini), updated_at: new Date().toISOString(), migrated_from_legacy: true, }); } const cleanupUpdates = {}; for (const [legacyKey, value] of Object.entries(root)) { if (legacyKey === 'records' || legacyKey === 'summary') continue; if (!looksLikeLegacyRecord(value)) { // Hapus field summary lama atau node lama non-record. cleanupUpdates[legacyKey] = null; continue; } const record = { ...value }; const kandangIdRaw = String(record.kandang_id || '').toLowerCase(); const kandangNamaRaw = String(record.kandang_nama || '').toLowerCase(); const isGlobal = kandangIdRaw === 'global' || kandangIdRaw === 'all' || kandangIdRaw === 'semua' || kandangNamaRaw.includes('global') || kandangNamaRaw.includes('semua') || kandangNamaRaw.includes('all'); if (!isGlobal) { await recordsRef.push().set({ ...record, migrated_from_legacy: true, migrated_at: new Date().toISOString(), }); cleanupUpdates[legacyKey] = null; continue; } const inferredKandangId = await inferTargetForGlobalRecord(record, kandangMap); if (inferredKandangId) { await recordsRef.push().set({ ...record, kandang_id: inferredKandangId, kandang_nama: kandangNameMap[inferredKandangId] || inferredKandangId, migrated_from_global: true, migrated_at: new Date().toISOString(), }); cleanupUpdates[legacyKey] = null; continue; } // Fallback: jika tidak bisa infer unik, split ke semua kandang aktif. const kandangIds = Object.keys(kandangMap || {}); if (kandangIds.length === 0) { cleanupUpdates[legacyKey] = null; continue; } const total = toInt(record.jumlah_telur); const base = Math.floor(total / kandangIds.length); let remainder = total % kandangIds.length; for (const kandangId of kandangIds) { const porsi = base + (remainder > 0 ? 1 : 0); if (remainder > 0) remainder -= 1; await recordsRef.push().set({ ...record, kandang_id: kandangId, kandang_nama: kandangNameMap[kandangId] || kandangId, jumlah_telur: porsi, migrated_from_global_split: true, migrated_at: new Date().toISOString(), }); } cleanupUpdates[legacyKey] = null; } if (Object.keys(cleanupUpdates).length > 0) { await riwayatRef.update(cleanupUpdates); } } async function normalizeRecordsNode() { const nestedRef = admin.database().ref('riwayat/records/records'); const nestedSnap = await nestedRef.get(); if (!nestedSnap.exists() || typeof nestedSnap.val() !== 'object') return; const recordsRef = admin.database().ref('riwayat/records'); const nestedData = nestedSnap.val(); for (const [key, value] of Object.entries(nestedData)) { if (!value || typeof value !== 'object') continue; await recordsRef.child(key).set(value); } await nestedRef.remove(); console.log('[maintenance] Flattened riwayat/records/records into riwayat/records'); } async function pruneOldRiwayatRecords(maxRecords) { if (!Number.isFinite(maxRecords) || maxRecords <= 0) return; const recordsRef = admin.database().ref('riwayat/records'); const snap = await recordsRef.get(); if (!snap.exists() || typeof snap.val() !== 'object') return; const recordsData = snap.val(); const entries = Object.entries(recordsData) .filter(([_, value]) => value && typeof value === 'object') .map(([key, value]) => { const date = toDate(value.tanggal_panen); return { key, time: date ? date.getTime() : 0 }; }) .sort((a, b) => b.time - a.time); if (entries.length <= maxRecords) return; const removeUpdates = {}; for (const item of entries.slice(maxRecords)) { removeUpdates[item.key] = null; } await recordsRef.update(removeUpdates); console.log(`[maintenance] Pruned ${entries.length - maxRecords} old riwayat records (keep ${maxRecords})`); } async function pruneOldSchedulerRuns(retainDays) { if (!Number.isFinite(retainDays) || retainDays < 1) return; const runsRef = admin.database().ref('scheduler_runs'); const snap = await runsRef.get(); if (!snap.exists() || typeof snap.val() !== 'object') return; const runs = snap.val(); const now = getJakartaNow(); const cutoff = new Date(now.getTime() - retainDays * 24 * 60 * 60 * 1000); const removeUpdates = {}; for (const dateKey of Object.keys(runs)) { const date = toDate(`${dateKey}T00:00:00+07:00`); if (!date) continue; if (date < cutoff) { removeUpdates[dateKey] = null; } } if (Object.keys(removeUpdates).length > 0) { await runsRef.update(removeUpdates); console.log(`[maintenance] Pruned old scheduler_runs older than ${retainDays} days`); } } async function runCompactMaintenance() { await normalizeRecordsNode(); await pruneOldRiwayatRecords(RIWAYAT_MAX_RECORDS); await pruneOldSchedulerRuns(SCHEDULER_RUNS_RETAIN_DAYS); } async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey, jadwalOrder, totalJadwal) { const jam = String(jadwal.jam || '09:00'); const jenisPanen = detectJenisPanen(jadwal, jadwalOrder); const durasiMs = parseDurasiMs(jadwal.durasi); console.log( `[info] ${jadwalId}: trigger motor dilewati di worker (durasi jadwal ${Math.round( durasiMs / 1000, )} detik), aktuator dikendalikan EPS/main.cpp`, ); const targetKandangIds = resolveTargetKandangIds(jadwal, kandangMap); if (targetKandangIds.length === 0) { console.log(`[skip] ${jadwalId}: target kandang tidak ditemukan`); return; } for (const kandangId of targetKandangIds) { const lockKey = `${jenisPanen}_${kandangId}_${jam.replace(/:/g, '')}`; const gotLock = await acquireRunLock(todayKey, lockKey); if (!gotLock) { console.log(`[skip] ${jadwalId}/${kandangId}: slot ${jenisPanen} pada jam ${jam} sudah dieksekusi hari ini`); continue; } const kandangData = kandangMap[kandangId] || null; const kandangNama = String( (kandangData && kandangData.nama) || jadwal.kandangNama || kandangId || 'Kandang', ); const infraPath = resolveInfraPath(kandangId, kandangData); const sensorValue = Number(dataSensor[infraPath] || 0); if (jenisPanen === 'pagi') { await admin.database().ref(`panen_snapshot/${todayKey}/pagi`).update({ [kandangId]: sensorValue, timestamp: new Date().toISOString(), }); await writeRiwayat({ kandangId, kandangNama, jumlahTelur: sensorValue, jam, jenisPanen: 'pagi', sensorSnapshot: sensorValue, panenSebelumnya: null, catatan: 'Auto-capture PAGI dari Railway scheduler', dateKey: todayKey, }); console.log(`[ok] Pagi ${kandangNama}: ${sensorValue} telur (${infraPath})`); continue; } const pagiSnapRef = admin.database().ref(`panen_snapshot/${todayKey}/pagi/${kandangId}`); const pagiSnap = await pagiSnapRef.get(); const nilaiPagi = pagiSnap.exists() ? Number(pagiSnap.val() || 0) : 0; const delta = sensorValue >= nilaiPagi ? (sensorValue - nilaiPagi) : sensorValue; await admin.database().ref(`panen_snapshot/${todayKey}/sore`).update({ [kandangId]: sensorValue, [`delta_${kandangId}`]: delta, timestamp: new Date().toISOString(), }); await writeRiwayat({ kandangId, kandangNama, jumlahTelur: delta, jam, jenisPanen: 'sore', sensorSnapshot: sensorValue, panenSebelumnya: nilaiPagi, catatan: `Auto-capture SORE dari Railway scheduler (delta: ${sensorValue} - ${nilaiPagi})`, dateKey: todayKey, }); console.log(`[ok] Sore ${kandangNama}: ${delta} telur (${sensorValue}-${nilaiPagi})`); } } async function runTick() { const now = getJakartaNow(); const nowHHMM = formatHHMM(now); const todayKey = formatDateKey(now); const db = admin.database(); const [jadwalSnap, dataSnap, kandangSnap] = await Promise.all([ db.ref('kontrol/penjadwalan').get(), db.ref('data').get(), db.ref('kontrol/kandang').get(), ]); if (!jadwalSnap.exists()) { console.log('[tick] Tidak ada data kontrol/penjadwalan'); return; } const jadwalMap = jadwalSnap.val() || {}; const dataSensor = dataSnap.exists() ? dataSnap.val() : {}; const kandangMap = kandangSnap.exists() ? kandangSnap.val() : {}; // Sort jadwal by ID untuk menentukan urutan: penjadwalan1 = pagi, penjadwalan2+ = sore const sortedJadwals = Object.entries(jadwalMap) .map(([id, jadwal]) => [id, jadwal]) .sort((a, b) => { const numA = parseInt(a[0].replace('penjadwalan', '')) || 999; const numB = parseInt(b[0].replace('penjadwalan', '')) || 999; return numA - numB; }); // Map untuk deteksi urutan const jadwalOrderMap = {}; sortedJadwals.forEach(([id, jadwal], index) => { jadwalOrderMap[id] = index; }); const aktifSekarang = sortedJadwals.filter(([_, jadwal]) => { if (!jadwal || jadwal.aktif !== true) return false; const jam = String(jadwal.jam || '').slice(0, 5); return jam === nowHHMM; }); if (aktifSekarang.length === 0) { console.log(`[tick] ${nowHHMM} tidak ada jadwal aktif`); return; } console.log(`[tick] ${nowHHMM} eksekusi ${aktifSekarang.length} jadwal`); let hasEveningRun = false; for (const [jadwalId, jadwal] of aktifSekarang) { try { await runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey, jadwalOrderMap[jadwalId], sortedJadwals.length); if (detectJenisPanen(jadwal, jadwalOrderMap[jadwalId]) === 'sore') { hasEveningRun = true; } } catch (e) { console.error(`[error] Jadwal ${jadwalId} gagal:`, e.message); } } if (hasEveningRun) { await resetSensorAfterEvening(todayKey, nowHHMM); } } function startHealthServer() { const server = http.createServer((req, res) => { if (req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, service: 'telurku-railway-scheduler' })); return; } res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('TelurKu Railway Scheduler is running'); }); server.listen(PORT, () => { console.log(`[init] Health server listening on :${PORT}`); }); } async function bootstrap() { initFirebase(); startHealthServer(); const kandangSnap = await admin.database().ref('kontrol/kandang').get(); const kandangMap = kandangSnap.exists() ? kandangSnap.val() : {}; const runMaintenance = await shouldRunMaintenance(); if (runMaintenance) { try { await migrateLegacyRiwayatData(kandangMap); console.log('[init] Legacy riwayat migration completed'); } catch (e) { console.error('[error] Legacy riwayat migration failed:', e.message); } } if (COMPACT_MODE) { try { await runCompactMaintenance(); console.log('[init] Compact maintenance completed'); } catch (e) { console.error('[error] Compact maintenance failed:', e.message); } } if (!SCHEDULER_ENABLED) { console.log('[init] Scheduler disabled by SCHEDULER_ENABLED=false'); return; } // Run once at startup for visibility, then every minute. try { await runTick(); } catch (e) { console.error('[error] Initial tick failed:', e.message); } cron.schedule( '* * * * *', async () => { try { await runTick(); } catch (e) { console.error('[error] Tick failed:', e.message); } }, { timezone: TZ }, ); console.log(`[init] Scheduler active (timezone=${TZ})`); } bootstrap().catch((e) => { console.error('[fatal] bootstrap failed:', e.message); process.exit(1); });