This repository has been archived on 2026-10-08. You can view files and clone it, but cannot push or open issues or pull requests.
allotment/packages/server/services/waterSchedules.js

275 lines
8.9 KiB
JavaScript

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