import { Patch, WaterSchedule } from "../db/models/index.js"; import { getConfiguredDevice } from "./devices.js"; /** Minutes from local midnight for timeline UI (proxy anchors until real sun times). */ export function timelineMinutesFromSchedule({ startAtType, offset }) { const o = Number(offset) || 0; const base = startAtType === "sunset" ? 18 * 60 : 12 * 60; let m = base + o; m %= 1440; if (m < 0) m += 1440; return m; } function endAtFromStartAndDuration(startAt, durationMinutes) { const when = startAt ? new Date(startAt) : null; if (!when || Number.isNaN(when.getTime())) return null; const d = Number(durationMinutes); const mins = Number.isFinite(d) && d > 0 ? d : 30; return new Date(when.getTime() + mins * 60 * 1000); } function normalizeWaterPower(raw) { if (raw === undefined || raw === null) return 100; const n = Number(raw); if (!Number.isFinite(n)) return 100; return Math.min(100, Math.max(0, n)); } function formatOne(doc) { const p = doc.patch; const patchObj = p && typeof p === "object" && p.patchId !== undefined ? p : null; const durationMinutes = doc.durationMinutes !== undefined && doc.durationMinutes !== null ? Number(doc.durationMinutes) : 30; const start = doc.startAt ? new Date(doc.startAt) : null; const endAtIso = doc.endAt ? new Date(doc.endAt).toISOString() : start && !Number.isNaN(start.getTime()) ? endAtFromStartAndDuration(start, durationMinutes)?.toISOString() ?? null : null; return { id: doc._id.toString(), patchId: patchObj?.patchId ?? null, patchName: patchObj?.name ?? "", startAt: doc.startAt ? new Date(doc.startAt).toISOString() : null, endAt: endAtIso, startAtType: doc.startAtType, offset: doc.offset ?? 0, durationMinutes: Number.isFinite(durationMinutes) ? durationMinutes : 30, targetSoilHumidityEnabled: Boolean(doc.targetSoilHumidityEnabled), targetSoilHumidity: doc.targetSoilHumidityEnabled && doc.targetSoilHumidity != null ? Number(doc.targetSoilHumidity) : null, waterPower: normalizeWaterPower(doc.waterPower), createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : null, updatedAt: doc.updatedAt ? new Date(doc.updatedAt).toISOString() : null, }; } const populatePatch = { path: "patch", select: "patchId name deviceId" }; export async function listWaterSchedulesForConfiguredDevice() { const device = await getConfiguredDevice(); if (!device) { return { waterSchedules: [] }; } const patchMongoIds = await Patch.find({ deviceId: device._id }) .select("_id") .lean(); const ids = patchMongoIds.map((x) => x._id); if (ids.length === 0) { return { waterSchedules: [] }; } const rows = await WaterSchedule.find({ patch: { $in: ids } }) .populate(populatePatch) .sort({ startAt: 1 }) .lean(); return { waterSchedules: rows.map(formatOne), }; } export async function createWaterSchedule({ patchId: logicalPatchId, startAt, startAtType, offset, durationMinutes, targetSoilHumidityEnabled, targetSoilHumidity, waterPower, }) { const id = String(logicalPatchId ?? "").trim(); if (!id) throw new Error("patchId required"); const device = await getConfiguredDevice(); if (!device) throw new Error("No device configured — check config.device"); const patch = await Patch.findOne({ deviceId: device._id, patchId: id }).lean(); if (!patch) throw new Error("Patch not found"); const t = startAtType === "sunset" || startAtType === "daylight" ? startAtType : null; if (!t) throw new Error("startAtType must be sunset or daylight"); const when = startAt ? new Date(startAt) : null; if (!when || Number.isNaN(when.getTime())) { throw new Error("startAt must be a valid date"); } const off = offset === undefined || offset === null ? 0 : Number(offset); if (!Number.isFinite(off)) throw new Error("offset must be a number"); const dur = durationMinutes === undefined || durationMinutes === null ? 30 : Number(durationMinutes); if (!Number.isFinite(dur) || dur < 1) { throw new Error("durationMinutes must be a positive number"); } const humidityOn = Boolean(targetSoilHumidityEnabled); let humidity = null; if (humidityOn) { const h = targetSoilHumidity === undefined || targetSoilHumidity === null ? NaN : Number(targetSoilHumidity); if (!Number.isFinite(h) || h < 0 || h > 100) { throw new Error("targetSoilHumidity must be between 0 and 100 when enabled"); } humidity = h; } const power = normalizeWaterPower(waterPower); const endAt = endAtFromStartAndDuration(when, dur); if (!endAt) throw new Error("Could not compute endAt"); const created = await WaterSchedule.create({ patch: patch._id, startAt: when, startAtType: t, offset: off, durationMinutes: dur, endAt, targetSoilHumidityEnabled: humidityOn, targetSoilHumidity: humidityOn ? humidity : null, waterPower: power, }); const populated = await WaterSchedule.findById(created._id) .populate(populatePatch) .lean(); return formatOne(populated); } export async function updateWaterSchedule({ scheduleId, patchId: logicalPatchId, startAt, startAtType, offset, durationMinutes, targetSoilHumidityEnabled, targetSoilHumidity, waterPower, }) { const sid = String(scheduleId ?? "").trim(); if (!sid) throw new Error("scheduleId required"); const device = await getConfiguredDevice(); if (!device) throw new Error("No device configured — check config.device"); const patchMongoIds = await Patch.find({ deviceId: device._id }) .select("_id") .lean(); const allowed = new Set(patchMongoIds.map((x) => x._id.toString())); const existing = await WaterSchedule.findById(sid).populate(populatePatch).lean(); if (!existing) throw new Error("Schedule not found"); const pref = existing.patch; const existingPid = pref && typeof pref === "object" && pref._id ? pref._id.toString() : String(pref); if (!allowed.has(existingPid)) throw new Error("Schedule not found"); const id = String(logicalPatchId ?? "").trim(); if (!id) throw new Error("patchId required"); const patch = await Patch.findOne({ deviceId: device._id, patchId: id }).lean(); if (!patch) throw new Error("Patch not found"); if (!allowed.has(patch._id.toString())) throw new Error("Patch not found"); const t = startAtType === "sunset" || startAtType === "daylight" ? startAtType : null; if (!t) throw new Error("startAtType must be sunset or daylight"); const when = startAt ? new Date(startAt) : null; if (!when || Number.isNaN(when.getTime())) { throw new Error("startAt must be a valid date"); } const off = offset === undefined || offset === null ? 0 : Number(offset); if (!Number.isFinite(off)) throw new Error("offset must be a number"); const dur = durationMinutes === undefined || durationMinutes === null ? 30 : Number(durationMinutes); if (!Number.isFinite(dur) || dur < 1) { throw new Error("durationMinutes must be a positive number"); } const humidityOn = Boolean(targetSoilHumidityEnabled); let humidity = null; if (humidityOn) { const h = targetSoilHumidity === undefined || targetSoilHumidity === null ? NaN : Number(targetSoilHumidity); if (!Number.isFinite(h) || h < 0 || h > 100) { throw new Error("targetSoilHumidity must be between 0 and 100 when enabled"); } humidity = h; } const power = normalizeWaterPower(waterPower); const endAt = endAtFromStartAndDuration(when, dur); if (!endAt) throw new Error("Could not compute endAt"); const updated = await WaterSchedule.findByIdAndUpdate( sid, { patch: patch._id, startAt: when, startAtType: t, offset: off, durationMinutes: dur, endAt, targetSoilHumidityEnabled: humidityOn, targetSoilHumidity: humidityOn ? humidity : null, waterPower: power, }, { new: true }, ) .populate(populatePatch) .lean(); if (!updated) throw new Error("Schedule not found"); return formatOne(updated); } export async function deleteWaterSchedule(scheduleId) { const sid = String(scheduleId ?? "").trim(); if (!sid) throw new Error("scheduleId required"); const device = await getConfiguredDevice(); if (!device) throw new Error("No device configured — check config.device"); const patchMongoIds = await Patch.find({ deviceId: device._id }) .select("_id") .lean(); const allowed = new Set(patchMongoIds.map((x) => x._id.toString())); const existing = await WaterSchedule.findById(sid).populate(populatePatch).lean(); if (!existing) throw new Error("Schedule not found"); const pref = existing.patch; const pid = pref && typeof pref === "object" && pref._id ? pref._id.toString() : String(pref); if (!allowed.has(pid)) throw new Error("Schedule not found"); await WaterSchedule.deleteOne({ _id: sid }); return { id: sid }; }