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.

605 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import mongoose from "mongoose";
import { Device, Image, 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;
}
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<mongoose.Types.ObjectId[]>}
*/
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 });
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 },
});
}
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<ReturnType<typeof formatClientPatches>[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];
}