import mongoose from "mongoose"; import { Device, Image, Node, Patch } from "../db/models/index.js"; import config from "../config.js"; import { deleteOrphanImages } from "./images.js"; import { DEFAULT_PRODUCE_ICON, PRODUCE_ICON_IDS, } from "../constants/produceIcons.js"; import { getLogger } from "../logger.js"; const logger = getLogger("Patches"); const DEFAULT_ROWS = 3; const DEFAULT_COLUMNS = 8; const DEFAULT_GROWING_POSITION = 100; const DEFAULT_CURRENT_WITHERING = 0; const DEFAULT_CURRENT_LEAVES = 0; const DEFAULT_CURRENT_FLOWERS = 0; const DEFAULT_CURRENT_FRUITING = 0; const SUNLIGHT_VALUES = ["fullSun", "partialShade", "fullShade"]; const DEFAULT_SUNLIGHT = "fullSun"; const DEFAULT_SOIL_PH_MIN = 5.5; const DEFAULT_SOIL_PH_MAX = 6.5; const WATER_NEEDS_VALUES = ["low", "medium", "high"]; const DEFAULT_WATER_NEEDS = "medium"; const MAX_OFFSET_WEEKS = 520; const MIN_CAL_WEEK = 0; const MAX_CAL_WEEK = 52; const MIN_PHASE_GROWING = 0; const MAX_PHASE_GROWING = 100; const MIN_PHASE_LEAVES = 0; const MAX_PHASE_LEAVES = 6; const MIN_PHASE_FLOWERS = 0; const MAX_PHASE_FLOWERS = 3; const MAX_PATCH_IMAGES = 24; function clampInt(n, min, max) { const x = Math.floor(Number(n)); if (!Number.isFinite(x)) return min; return Math.min(max, Math.max(min, x)); } /** Prefer camelCase; accept legacy snake_case from stored documents. */ function durationWeeksFromLifecycle(lifecycle) { const v = lifecycle.durationWeeks ?? lifecycle.duration_weeks; return clampInt(v, 1, MAX_OFFSET_WEEKS); } function biennialOffsetsFromPhase(ph) { const start = ph?.startOffsetWeeks ?? ph?.start_offset_weeks; const end = ph?.endOffsetWeeks ?? ph?.end_offset_weeks; return { startOffsetWeeks: clampInt(start, 0, MAX_OFFSET_WEEKS), endOffsetWeeks: clampInt(end, 0, MAX_OFFSET_WEEKS), }; } function perennialWeeksFromPhase(ph) { const start = ph?.startWeek ?? ph?.start_week; const end = ph?.endWeek ?? ph?.end_week; return { startWeek: clampInt(start, MIN_CAL_WEEK, MAX_CAL_WEEK), endWeek: clampInt(end, MIN_CAL_WEEK, MAX_CAL_WEEK), }; } /** Lottie growth stage 0–100 per lifecycle phase (camelCase; legacy `growing_position`). */ function growingPositionFromPhase(ph) { const v = ph?.growingPosition ?? ph?.growing_position; const n = Number(v); if (!Number.isFinite(n)) return 0; return Math.min( MAX_PHASE_GROWING, Math.max(MIN_PHASE_GROWING, Math.round(n)), ); } /** Withering tint 0–100 for plant Lottie (default 0). */ function witheringFromPhase(ph) { const v = ph?.withering ?? ph?.withering_position; const n = Number(v); if (!Number.isFinite(n)) return MIN_PHASE_GROWING; return Math.min( MAX_PHASE_GROWING, Math.max(MIN_PHASE_GROWING, Math.round(n)), ); } function leavesFromPhase(ph) { const v = ph?.leaves ?? ph?.leaf_count; return clampInt(v, MIN_PHASE_LEAVES, MAX_PHASE_LEAVES); } function flowersFromPhase(ph) { const v = ph?.flowers ?? ph?.flower_count; return clampInt(v, MIN_PHASE_FLOWERS, MAX_PHASE_FLOWERS); } /** Fruit development 0–100 for plant Lottie (with flowers). */ function fruitingFromPhase(ph) { const v = ph?.fruiting; const n = Number(v); if (!Number.isFinite(n)) return MIN_PHASE_GROWING; return Math.min( MAX_PHASE_GROWING, Math.max(MIN_PHASE_GROWING, Math.round(n)), ); } /** * @param {unknown} lifecycle * @returns {{ type: 'biennial', durationWeeks: number, phases: object[] } | { type: 'perennial', recurring: boolean, phases: object[] } | null} */ export function sanitizeLifecycleForClient(lifecycle) { if (!lifecycle || typeof lifecycle !== "object") return null; const type = lifecycle.type; if (type === "biennial") { const phases = (Array.isArray(lifecycle.phases) ? lifecycle.phases : []).map( (ph) => { const { startOffsetWeeks, endOffsetWeeks } = biennialOffsetsFromPhase(ph); return { name: typeof ph?.name === "string" && ph.name.trim() ? ph.name.trim() : "phase", startOffsetWeeks, endOffsetWeeks, growingPosition: growingPositionFromPhase(ph), withering: witheringFromPhase(ph), leaves: leavesFromPhase(ph), flowers: flowersFromPhase(ph), fruiting: fruitingFromPhase(ph), }; }, ); return { type: "biennial", durationWeeks: Math.max(1, durationWeeksFromLifecycle(lifecycle)), phases, }; } if (type === "perennial") { const phases = (Array.isArray(lifecycle.phases) ? lifecycle.phases : []).map( (ph) => { const { startWeek, endWeek } = perennialWeeksFromPhase(ph); return { name: typeof ph?.name === "string" && ph.name.trim() ? ph.name.trim() : "phase", startWeek, endWeek, growingPosition: growingPositionFromPhase(ph), withering: witheringFromPhase(ph), leaves: leavesFromPhase(ph), flowers: flowersFromPhase(ph), fruiting: fruitingFromPhase(ph), }; }, ); return { type: "perennial", recurring: Boolean(lifecycle.recurring), phases, }; } return null; } /** * @param {unknown} raw * @returns {{ type: 'biennial', durationWeeks: number, phases: object[] } | { type: 'perennial', recurring: boolean, phases: object[] }} */ function parseLifecycleFromUpdate(raw) { if (!raw || typeof raw !== "object") { throw new Error("lifecycle must be an object"); } const type = raw.type; if (type === "biennial") { if (!Array.isArray(raw.phases) || raw.phases.length === 0) { throw new Error("biennial lifecycle requires at least one phase"); } const phases = raw.phases.map((ph, i) => { if (!ph || typeof ph !== "object") { throw new Error(`Invalid phase at index ${i}`); } const name = typeof ph.name === "string" ? ph.name.trim() : ""; if (!name) { throw new Error(`Phase ${i + 1}: name is required`); } const { startOffsetWeeks, endOffsetWeeks } = biennialOffsetsFromPhase(ph); return { name, startOffsetWeeks, endOffsetWeeks, growingPosition: growingPositionFromPhase(ph), withering: witheringFromPhase(ph), leaves: leavesFromPhase(ph), flowers: flowersFromPhase(ph), fruiting: fruitingFromPhase(ph), }; }); return { type: "biennial", durationWeeks: Math.max(1, durationWeeksFromLifecycle(raw)), phases, }; } if (type === "perennial") { if (!Array.isArray(raw.phases) || raw.phases.length === 0) { throw new Error("perennial lifecycle requires at least one phase"); } const phases = raw.phases.map((ph, i) => { if (!ph || typeof ph !== "object") { throw new Error(`Invalid phase at index ${i}`); } const name = typeof ph.name === "string" ? ph.name.trim() : ""; if (!name) { throw new Error(`Phase ${i + 1}: name is required`); } const { startWeek, endWeek } = perennialWeeksFromPhase(ph); return { name, startWeek, endWeek, growingPosition: growingPositionFromPhase(ph), withering: witheringFromPhase(ph), leaves: leavesFromPhase(ph), flowers: flowersFromPhase(ph), fruiting: fruitingFromPhase(ph), }; }); return { type: "perennial", recurring: Boolean(raw.recurring), phases, }; } throw new Error('lifecycle.type must be "biennial" or "perennial"'); } /** * @param {unknown} value * @returns {Date | null | undefined} undefined = omit update */ function parsePlantedAtUpdate(value) { if (value === undefined) return undefined; if (value === null || value === "") return null; const d = new Date(value); if (Number.isNaN(d.getTime())) { throw new Error("Invalid plantedAt date"); } return d; } export function normalizeProduceIcon(value) { if (value == null || typeof value !== "string") return DEFAULT_PRODUCE_ICON; const s = value.trim(); if (PRODUCE_ICON_IDS.includes(s)) return s; return DEFAULT_PRODUCE_ICON; } function normalizeSunlight(value) { if (value == null || typeof value !== "string") return DEFAULT_SUNLIGHT; const s = value.trim(); return SUNLIGHT_VALUES.includes(s) ? s : DEFAULT_SUNLIGHT; } function normalizeWaterNeeds(value) { if (value == null || typeof value !== "string") return DEFAULT_WATER_NEEDS; const s = value.trim(); return WATER_NEEDS_VALUES.includes(s) ? s : DEFAULT_WATER_NEEDS; } /** @param {unknown} value @param {number} fallback */ function parseSoilPh(value, fallback) { const n = Number(value); if (!Number.isFinite(n)) return fallback; return Math.min(14, Math.max(0, Math.round(n * 10) / 10)); } /** * @param {unknown} img * @returns {{ id: string, blurHash: string } | null} */ function formatClientImageEntry(img) { if (img == null) return null; if (typeof img === "object" && img !== null) { const oid = img._id ?? img.id; if (oid != null) { return { id: String(oid), blurHash: typeof img.blurHash === "string" ? img.blurHash : "", }; } } if (mongoose.Types.ObjectId.isValid(String(img))) { return { id: String(img), blurHash: "" }; } return null; } /** * @param {unknown} raw * @returns {Promise} */ async function normalizeImagesForUpdate(raw) { if (!Array.isArray(raw)) { throw new Error("images must be an array"); } if (raw.length > MAX_PATCH_IMAGES) { throw new Error(`At most ${MAX_PATCH_IMAGES} images per patch`); } const ids = []; for (const item of raw) { const s = typeof item === "string" ? item.trim() : String(item?.id ?? "").trim(); if (!s) continue; if (!mongoose.Types.ObjectId.isValid(s)) { throw new Error("Invalid image id"); } const exists = await Image.exists({ _id: s }); if (!exists) continue; ids.push(new mongoose.Types.ObjectId(s)); } return ids; } export function formatClientPatches(patchDocs) { return (patchDocs || []).map((p) => ({ id: p.patchId, name: p.name, description: p.description ?? "", icon: normalizeProduceIcon(p.icon), rows: p.rows ?? DEFAULT_ROWS, columns: p.columns ?? DEFAULT_COLUMNS, currentGrowingPosition: p.currentGrowingPosition ?? DEFAULT_GROWING_POSITION, currentWithering: p.currentWithering ?? DEFAULT_CURRENT_WITHERING, currentLeaves: p.currentLeaves ?? DEFAULT_CURRENT_LEAVES, currentFlowers: p.currentFlowers ?? DEFAULT_CURRENT_FLOWERS, currentFruiting: p.currentFruiting ?? DEFAULT_CURRENT_FRUITING, currentGrowingPhase: p.currentGrowingPhase != null && typeof p.currentGrowingPhase === "object" ? { name: String(p.currentGrowingPhase.name ?? ""), index: Number(p.currentGrowingPhase.index ?? 0), } : null, soilHumidity: p.soilHumidity, soilTemperature: p.soilTemperature, sunlight: normalizeSunlight(p.sunlight), soilPhMin: parseSoilPh( p.soilPhMin ?? p.soil_ph_min, DEFAULT_SOIL_PH_MIN, ), soilPhMax: parseSoilPh( p.soilPhMax ?? p.soil_ph_max, DEFAULT_SOIL_PH_MAX, ), waterNeeds: normalizeWaterNeeds(p.waterNeeds ?? p.water_needs), plantedAt: p.plantedAt ? new Date(p.plantedAt).toISOString() : null, lifecycle: sanitizeLifecycleForClient(p.lifecycle), images: (Array.isArray(p.images) ? p.images : []) .map(formatClientImageEntry) .filter(Boolean), updatedAt: p.updatedAt, })); } async function getDeviceForPatches(config) { const ttnApplicationId = config.device?.ttnApplicationId?.trim(); const ttnDeviceId = config.device?.ttnDeviceId?.trim(); if (!ttnApplicationId || !ttnDeviceId) return null; return Device.findOne({ ttnApplicationId, ttnDeviceId }); } /** * Ensures Patch documents exist for each configured id only. Name, soilHumidity, * and soilTemperature are never read from config — they live in MongoDB / UI / TTN. * Patches removed from config are deleted for the configured device. */ export async function syncPatchesFromConfig(config) { const device = await getDeviceForPatches(config); if (!device) { logger.warn( "No device for patch sync — ensure config.device matches a synced Device document", ); return; } if (!Array.isArray(config.patches) || config.patches.length === 0) { await Patch.deleteMany({ deviceId: device._id }); await Node.updateMany({ deviceId: device._id }, { $set: { patches: [] } }); return; } const ids = []; for (const entry of config.patches) { let patchId; if (typeof entry === "string") { patchId = entry.trim(); } else if (entry && typeof entry === "object") { patchId = String(entry.id ?? entry.patchId ?? "").trim(); } if (!patchId) { logger.warn("Skipping invalid patches entry (expected a string id or { id })"); continue; } ids.push(patchId); } for (const patchId of ids) { await Patch.updateOne( { deviceId: device._id, patchId }, { $setOnInsert: { deviceId: device._id, patchId, name: "", description: "", rows: DEFAULT_ROWS, columns: DEFAULT_COLUMNS, icon: DEFAULT_PRODUCE_ICON, soilHumidity: null, soilTemperature: null, sunlight: DEFAULT_SUNLIGHT, soilPhMin: DEFAULT_SOIL_PH_MIN, soilPhMax: DEFAULT_SOIL_PH_MAX, waterNeeds: DEFAULT_WATER_NEEDS, }, }, { upsert: true }, ); } await Patch.deleteMany({ deviceId: device._id, patchId: { $nin: ids }, }); await Node.updateMany( { deviceId: device._id }, { $pull: { patches: { $nin: ids } } }, ); } export async function updatePatchesFromUplinkPayload(dev, payload) { if (!Array.isArray(payload.patches)) return; for (const p of payload.patches) { if (!p || !p.id) continue; const $set = {}; if (p.soilTemperature != null) { $set.soilTemperature = Number(p.soilTemperature); } if (p.soilHumidity != null) { $set.soilHumidity = Number(p.soilHumidity); } if (Object.keys($set).length === 0) continue; await Patch.updateOne({ deviceId: dev._id, patchId: p.id }, { $set }); } } const MAX_GRID = 24; function parsePositiveInt(value, fallback) { const n = Math.floor(Number(value)); if (!Number.isFinite(n)) return fallback; return Math.min(MAX_GRID, Math.max(1, n)); } /** * Updates editable patch fields on the configured device only. * Only keys present on `updates` (not undefined) are written. * @param {string} patchId logical patch id (Mongo `patchId`) * @param {{ name?: unknown, description?: unknown, icon?: unknown, rows?: unknown, columns?: unknown, lifecycle?: unknown, plantedAt?: unknown, sunlight?: unknown, soilPhMin?: unknown, soilPhMax?: unknown, waterNeeds?: unknown, images?: unknown }} updates * @returns {Promise[0] | null>} */ export async function updatePatchForConfiguredDevice(patchId, updates = {}) { const device = await getDeviceForPatches(config); if (!device) { throw new Error("No device configured"); } const id = String(patchId ?? "").trim(); if (!id) { throw new Error("patchId required"); } const $set = {}; if (updates.name !== undefined) { const nameStr = typeof updates.name === "string" ? updates.name.trim() : updates.name == null ? "" : String(updates.name).trim(); $set.name = nameStr; } if (updates.description !== undefined) { const descStr = typeof updates.description === "string" ? updates.description.trim() : updates.description == null ? "" : String(updates.description).trim(); $set.description = descStr; } if (updates.icon !== undefined) { $set.icon = normalizeProduceIcon(updates.icon); } if (updates.rows !== undefined) { $set.rows = parsePositiveInt(updates.rows, DEFAULT_ROWS); } if (updates.columns !== undefined) { $set.columns = parsePositiveInt(updates.columns, DEFAULT_COLUMNS); } if (updates.lifecycle !== undefined) { $set.lifecycle = parseLifecycleFromUpdate(updates.lifecycle); } if (updates.plantedAt !== undefined) { $set.plantedAt = parsePlantedAtUpdate(updates.plantedAt); } if (updates.sunlight !== undefined) { $set.sunlight = normalizeSunlight(updates.sunlight); } if (updates.soilPhMin !== undefined || updates.soilPhMax !== undefined) { const needExisting = updates.soilPhMin === undefined || updates.soilPhMax === undefined; const existing = needExisting ? await Patch.findOne({ deviceId: device._id, patchId: id }).lean() : null; const prevMin = parseSoilPh( existing?.soilPhMin ?? existing?.soil_ph_min, DEFAULT_SOIL_PH_MIN, ); const prevMax = parseSoilPh( existing?.soilPhMax ?? existing?.soil_ph_max, DEFAULT_SOIL_PH_MAX, ); let min = updates.soilPhMin !== undefined ? parseSoilPh(updates.soilPhMin, prevMin) : prevMin; let max = updates.soilPhMax !== undefined ? parseSoilPh(updates.soilPhMax, prevMax) : prevMax; if (min > max) [min, max] = [max, min]; $set.soilPhMin = min; $set.soilPhMax = max; } if (updates.waterNeeds !== undefined) { $set.waterNeeds = normalizeWaterNeeds(updates.waterNeeds); } if (updates.images !== undefined) { $set.images = await normalizeImagesForUpdate(updates.images); } const populateImages = { path: "images", select: "blurHash contentType", }; if (Object.keys($set).length === 0) { const existing = await Patch.findOne({ deviceId: device._id, patchId: id, }) .populate(populateImages) .lean(); if (!existing) return null; try { await deleteOrphanImages(); } catch (e) { logger.error("deleteOrphanImages", e); } return formatClientPatches([existing])[0]; } const updated = await Patch.findOneAndUpdate( { deviceId: device._id, patchId: id }, { $set }, { new: true }, ) .populate(populateImages) .lean(); if (!updated) return null; try { await deleteOrphanImages(); } catch (e) { logger.error("deleteOrphanImages", e); } const { recalculateGrowingStateForLogicalPatch } = await import( "./patchGrowingState.js", ); const { patch: withGrowing } = await recalculateGrowingStateForLogicalPatch( device._id, id, ); return withGrowing ?? formatClientPatches([updated])[0]; }