275 lines
8.9 KiB
JavaScript
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 };
|
|
}
|