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.

299 lines
7.2 KiB
JavaScript

import { schedule } from "node-cron";
import { History, Patch } from "../db/models/index.js";
import { getLogger } from "../logger.js";
import {
formatPowerStateForClient,
getOrCreatePowerState,
} from "./powerState.js";
import { getOrCreateWaterTank } from "./waterTank.js";
const logger = getLogger("History");
/** Every minute on the minute (system local time). */
const HISTORY_CRON = "* * * * *";
/** @type {import("node-cron").ScheduledTask | null} */
let cronTask = null;
/** @type {Set<(payload: object) => void>} */
const powerStateListeners = new Set();
/** @type {Set<(payload: object) => void>} */
const waterTankListeners = new Set();
/** @type {Set<(payload: object) => void>} */
const patchListeners = new Set();
function numOrNull(v) {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/**
* @param {import("mongoose").Document | Record<string, unknown>} pow
*/
function snapshotPower(pow) {
const { updatedAt: _u, ...rest } = formatPowerStateForClient(pow);
return rest;
}
/**
* @param {{ start: Date | string | number, end: Date | string | number }} range
*/
function parseTimeRange(range) {
const { start, end } = range || {};
if (start == null || end == null) {
throw new Error("start and end are required");
}
const s = new Date(start);
const e = new Date(end);
if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) {
throw new Error("Invalid start or end date");
}
return { start: s, end: e };
}
function queryForRange(range) {
const { start, end } = parseTimeRange(range);
return {
sampledAt: { $gte: start, $lte: end },
};
}
function emitPower(payload) {
for (const fn of powerStateListeners) {
try {
fn(payload);
} catch (e) {
logger.error("powerState history listener error:", e);
}
}
}
function emitWaterTank(payload) {
for (const fn of waterTankListeners) {
try {
fn(payload);
} catch (e) {
logger.error("waterTank history listener error:", e);
}
}
}
function emitPatch(payload) {
for (const fn of patchListeners) {
try {
fn(payload);
} catch (e) {
logger.error("patch history listener error:", e);
}
}
}
export async function sampleHistoryOnce() {
logger.trace("sampleHistoryOnce: fetching patch/tank/power state");
const [patchDocs, tank, pow] = await Promise.all([
Patch.find().select("patchId deviceId soilTemperature soilHumidity").lean(),
getOrCreateWaterTank(),
getOrCreatePowerState(),
]);
logger.trace(
"sampleHistoryOnce: loaded",
patchDocs.length,
"patches",
"tank.level",
numOrNull(tank.level),
);
const sampledAt = new Date();
const patches = patchDocs.map((p) => ({
patchId: p.patchId,
deviceId: p.deviceId,
soilTemperature: numOrNull(p.soilTemperature),
soilHumidity: numOrNull(p.soilHumidity),
}));
const powerSnapshot = snapshotPower(pow);
const doc = await History.create({
sampledAt,
waterTank: { level: numOrNull(tank.level) },
power: powerSnapshot,
patches,
});
const id = doc._id.toString();
logger.debug(
"history sample created:",
id,
"sampledAt",
doc.sampledAt.toISOString(),
"patches",
patches.length,
"waterTank.level",
doc.waterTank?.level ?? null,
);
logger.trace(
"history sample detail:",
id,
"batteryPercent",
powerSnapshot?.battery?.percent ?? null,
"solarVoltage",
powerSnapshot?.solar?.voltage ?? null,
"loadVoltage",
powerSnapshot?.load?.voltage ?? null,
);
emitPower({
historyId: id,
sampledAt: doc.sampledAt,
power: doc.power,
});
emitWaterTank({
historyId: id,
sampledAt: doc.sampledAt,
waterTank: doc.waterTank,
});
emitPatch({
historyId: id,
sampledAt: doc.sampledAt,
patches: doc.patches,
});
return doc;
}
export function startHistorySampler() {
if (cronTask != null) return;
sampleHistoryOnce().catch((e) => {
logger.error("Initial history sample failed:", e.message ?? e);
});
cronTask = schedule(HISTORY_CRON, () => {
sampleHistoryOnce().catch((e) => {
logger.error("Periodic history sample failed:", e.message ?? e);
});
});
}
export function stopHistorySampler() {
if (cronTask != null) {
void cronTask.destroy();
cronTask = null;
}
}
/**
* @param {{ start: Date | string | number, end: Date | string | number }} range
*/
export async function getPowerStateHistory(range) {
const filter = queryForRange(range);
const rows = await History.find(filter)
.select("sampledAt power")
.sort({ sampledAt: 1 })
.lean();
return rows.map((r) => ({
historyId: r._id.toString(),
sampledAt: r.sampledAt,
power: r.power,
}));
}
/**
* @param {{ start: Date | string | number, end: Date | string | number }} range
*/
export async function getWaterTankHistory(range) {
const filter = queryForRange(range);
const rows = await History.find(filter)
.select("sampledAt waterTank")
.sort({ sampledAt: 1 })
.lean();
return rows.map((r) => ({
historyId: r._id.toString(),
sampledAt: r.sampledAt,
waterTank: r.waterTank,
}));
}
/**
* @param {string | { start: Date | string | number, end: Date | string | number }} patchIdOrRange
* @param {{ start: Date | string | number, end: Date | string | number }} [range]
* If `patchId` is omitted (call `getPatchHistory({ start, end })`), each row includes the full `patches` array for that sample.
*/
export async function getPatchHistory(patchIdOrRange, range) {
let patchId;
let r;
if (
patchIdOrRange != null &&
typeof patchIdOrRange === "object" &&
!Array.isArray(patchIdOrRange) &&
("start" in patchIdOrRange || "end" in patchIdOrRange)
) {
r = patchIdOrRange;
} else {
patchId =
patchIdOrRange == null || patchIdOrRange === ""
? undefined
: String(patchIdOrRange);
r = range;
}
const filter = queryForRange(r);
const rows = await History.find(filter)
.select("sampledAt patches")
.sort({ sampledAt: 1 })
.lean();
if (!patchId) {
return rows.map((row) => ({
historyId: row._id.toString(),
sampledAt: row.sampledAt,
patches: row.patches,
}));
}
return rows
.map((row) => {
const p = (row.patches || []).find((x) => x.patchId === patchId);
if (!p) return null;
return {
historyId: row._id.toString(),
sampledAt: row.sampledAt,
patchId: p.patchId,
deviceId: p.deviceId?.toString?.() ?? p.deviceId,
soilTemperature: p.soilTemperature,
soilHumidity: p.soilHumidity,
};
})
.filter(Boolean);
}
export function subscribePowerStateHistory(listener) {
if (typeof listener !== "function") return;
powerStateListeners.add(listener);
}
export function unsubscribePowerStateHistory(listener) {
powerStateListeners.delete(listener);
}
export function subscribeWaterTankHistory(listener) {
if (typeof listener !== "function") return;
waterTankListeners.add(listener);
}
export function unsubscribeWaterTankHistory(listener) {
waterTankListeners.delete(listener);
}
export function subscribePatchHistory(listener) {
if (typeof listener !== "function") return;
patchListeners.add(listener);
}
export function unsubscribePatchHistory(listener) {
patchListeners.delete(listener);
}