765 lines
23 KiB
JavaScript
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);
|
|
}); |