TKK_E32231105/railway-scheduler/worker.js

765 lines
23 KiB
JavaScript

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