Skip to content

Commit 088cb16

Browse files
THUNDER PHUMclaude
andcommitted
fix(cron): never store a frozen Deye reading; health reports logger-offline; operator purge endpoint
Incident 2026-09-18 13:30 -> 09-22 16:20 BKK: the site's logger dropped off Deye Cloud and /station/latest kept re-serving its last reading (PV 2631 W) for 4 days; the cron stored it every 5 min (1,187 fabricated samples), Deye has no day totals for 09-19..21. - lib/freeze.ts isFrozenReading(readingTs, lastStoredReadingTs, now, staleS): unchanged device timestamp older than the stale window = no evidence; pollAndStore then writes no sample/daily, records last_poll_ok + frozen_since (first), still runs alerts (offline rule); on a fresh reading clears frozen_since and stores last_reading_ts - /api/_health: cronHealthy from max(last sample, last successful poll); inverterFrozenSince + summary note so a polling-but-not-storing collector is not reported as dead - POST /api/_purge?from&to[&confirm=1] (operator, dry-run default, <=31 d): deletes samples/device_samples in the window and drops day/battery caches; daily untouched - 5 tests (105 total) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent d9a527c commit 088cb16

3 files changed

Lines changed: 88 additions & 1 deletion

File tree

‎src/lib/__tests__/freeze.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, it, expect } from "vitest";
2+
import { isFrozenReading } from "../freeze";
3+
4+
const NOW = 1_790_000_000, STALE = 720;
5+
describe("isFrozenReading", () => {
6+
it("first ever poll: nothing stored → not frozen", () => expect(isFrozenReading(NOW - 60, null, NOW, STALE).frozen).toBe(false));
7+
it("reading advanced → not frozen even if old-ish", () => expect(isFrozenReading(NOW - 600, NOW - 900, NOW, STALE).frozen).toBe(false));
8+
it("same reading, still within the stale window → not frozen yet (one slow upload is fine)", () => {
9+
expect(isFrozenReading(NOW - 600, NOW - 600, NOW, STALE).frozen).toBe(false);
10+
});
11+
it("same reading and older than the window → FROZEN", () => {
12+
const r = isFrozenReading(NOW - 3600, NOW - 3600, NOW, STALE);
13+
expect(r.frozen).toBe(true); expect(r.reason).toContain("60 min");
14+
});
15+
it("no device timestamp → cannot judge → not frozen", () => expect(isFrozenReading(0, NOW - 3600, NOW, STALE).frozen).toBe(false));
16+
});

‎src/lib/freeze.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Frozen-reading detection — pure, unit-tested.
2+
//
3+
// When the site's data logger drops off Deye Cloud, /station/latest keeps
4+
// answering with the LAST reading it has (same watts, same SOC, same
5+
// lastUpdateTime) for hours or days. Storing that every 5 minutes fabricates a
6+
// flat 4-day "production curve" (observed 2026-09-18→22: PV 2,631 W × 96 h).
7+
// A reading is only new evidence if its device timestamp advanced past the one
8+
// we last stored; if it hasn't and it is older than the stale window, the poll
9+
// carries no information about the present and must not be written as a sample.
10+
export interface FreezeCheck { frozen: boolean; reason: string | null }
11+
export function isFrozenReading(readingTs: number, lastStoredReadingTs: number | null, nowS: number, staleS: number): FreezeCheck {
12+
if (!Number.isFinite(readingTs) || readingTs <= 0) return { frozen: false, reason: null }; // no device timestamp → cannot judge, keep old behaviour
13+
if (lastStoredReadingTs != null && readingTs <= lastStoredReadingTs && nowS - readingTs > staleS) {
14+
return { frozen: true, reason: `Deye reading unchanged since ${new Date(readingTs * 1000).toISOString()} (${Math.round((nowS - readingTs) / 60)} min)` };
15+
}
16+
return { frozen: false, reason: null };
17+
}

‎src/worker/index.ts‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { calibKwFrom } from "../lib/calib";
1111
import { observedChannelActivity, activeChannels } from "./battChannels";
1212
import { learnSky, accuracySummary } from "../lib/skylearn";
1313
import { batteryHealth, sohIdentity } from "../lib/battery";
14+
import { isFrozenReading } from "../lib/freeze";
1415
import { forecastDayKwh, DEFAULT_SKY } from "../lib/forecast";
1516

1617
// --- External endpoints + defaults — centralized, not scattered as inline literals.
@@ -160,6 +161,25 @@ async function pollAndStore(env: Env) {
160161
const l = await getLatest(env);
161162
const ts = Math.floor(Date.now() / 60000) * 60;
162163
const day = bkkDay();
164+
// Frozen reading (logger offline, Deye re-serving its last value): record that
165+
// the poll itself succeeded, but write NO sample/daily — see lib/freeze.ts.
166+
const lastReading = (await env.DB.prepare("SELECT v FROM meta WHERE k='last_reading_ts'").first()) as { v: string } | null;
167+
const fz = isFrozenReading(Number(l.updatedAt) || 0, lastReading ? Number(lastReading.v) : null, Math.floor(Date.now() / 1000), STALE_AFTER_S);
168+
if (fz.frozen) {
169+
console.warn("poll: frozen reading — not stored:", fz.reason);
170+
await env.DB.batch([
171+
env.DB.prepare("INSERT INTO meta (k,v) VALUES ('last_poll_ok',?) ON CONFLICT(k) DO UPDATE SET v=excluded.v").bind(String(ts)),
172+
env.DB.prepare("INSERT INTO meta (k,v) VALUES ('frozen_since',?) ON CONFLICT(k) DO UPDATE SET v=v").bind(String(l.updatedAt)), // keep the FIRST frozen timestamp
173+
env.DB.prepare("DELETE FROM meta WHERE k='last_poll_error'"),
174+
]);
175+
// Alerts still run so the offline rule can fire from the device snapshot.
176+
if (alertsConfigured(env)) {
177+
const dev = await buildDeviceData(env).catch(() => null);
178+
const sm = await getStationMeta(env).catch(() => null);
179+
await evaluateAlerts(env, { latest: null, dev, sun: null, capacityW: null, stationName: sm?.name }).catch((e) => console.error("alerts failed", e));
180+
}
181+
return l;
182+
}
163183
// Day totals unavailable (Deye history call failed): keep the live sample, but
164184
// do NOT write today's `daily` row (it would be zeros) and carry the last known
165185
// today-energies forward in the served cache instead of showing 0.
@@ -188,6 +208,9 @@ async function pollAndStore(env: Env) {
188208
// The poll succeeded, so any recorded cron failure is history (one statement
189209
// in the same batch — no extra round trip).
190210
env.DB.prepare("DELETE FROM meta WHERE k='last_poll_error'"),
211+
env.DB.prepare("DELETE FROM meta WHERE k='frozen_since'"),
212+
env.DB.prepare("INSERT INTO meta (k,v) VALUES ('last_poll_ok',?) ON CONFLICT(k) DO UPDATE SET v=excluded.v").bind(String(ts)),
213+
env.DB.prepare("INSERT INTO meta (k,v) VALUES ('last_reading_ts',?) ON CONFLICT(k) DO UPDATE SET v=excluded.v").bind(String(Number(l.updatedAt) || ts)),
191214
];
192215
if (l.totalsOk) stmts.push(
193216
env.DB.prepare(
@@ -570,17 +593,27 @@ app.get("/api/_health", async (c) => {
570593
if (pe && pe.v) { try { lastPollError = JSON.parse(pe.v); } catch {} }
571594
const lastTs = (s && s.m) || 0;
572595
const ageMin = lastTs ? Math.round((now - lastTs) / 60) : null;
573-
const healthy = lastTs > 0 && now - lastTs <= STALE_AFTER_S;
596+
// The collector is healthy if it POLLED recently, even when it (correctly)
597+
// refused to store a frozen reading; that case is reported separately.
598+
const pok = await first<{ v: string }>("SELECT v FROM meta WHERE k='last_poll_ok'");
599+
const lastPollOk = pok ? Number(pok.v) : 0;
600+
const fzRow = await first<{ v: string }>("SELECT v FROM meta WHERE k='frozen_since'");
601+
const frozenSince = fzRow ? Number(fzRow.v) : null;
602+
const healthy = Math.max(lastTs, lastPollOk) > 0 && now - Math.max(lastTs, lastPollOk) <= STALE_AFTER_S;
574603
const loginBad = !!login && login.fails > 0;
575604
const payload = {
576605
// ok mirrors cronHealthy so uptime monitors can key on status code / `ok`.
577606
ok: healthy,
578607
serverTime: new Date(now * 1000).toISOString(),
579608
cronHealthy: healthy,
580609
staleAfterSeconds: STALE_AFTER_S,
610+
lastPollOk: lastPollOk ? new Date(lastPollOk * 1000).toISOString() : null,
611+
// logger offline: Deye keeps re-serving one reading; the cron polls but stores nothing
612+
inverterFrozenSince: frozenSince ? new Date(frozenSince * 1000).toISOString() : null,
581613
lastPollError: lastPollError && { ...lastPollError, time: new Date(lastPollError.at * 1000).toISOString() },
582614
summary: lastTs
583615
? `cron เขียนล่าสุด ${ageMin} นาทีที่แล้ว · ${(s?.c || 0).toLocaleString()} แถว · ${healthy ? "ปกติ ✅" : "หยุด ⚠️"}` +
616+
(frozenSince ? ` · อินเวอร์เตอร์ไม่ส่งข้อมูลใหม่ตั้งแต่ ${new Date(frozenSince * 1000).toISOString()} (ไม่บันทึกค่าซ้ำ) 📡` : "") +
584617
(lastPollError ? ` · error ล่าสุด: ${lastPollError.msg}` : "") +
585618
(loginBad ? ` · ล็อกอิน Deye ล้มเหลว ${login!.fails} ครั้ง (${login!.msg}) ⛔` : "")
586619
: "ยังไม่มีข้อมูล cron",
@@ -1305,6 +1338,27 @@ app.post("/api/_backfill", async (c) => {
13051338
return c.json(r, r.failed ? 207 : 200);
13061339
});
13071340
// Operator: prove the alert channels work (sends a test message, returns statuses).
1341+
// Operator: remove samples/device_samples in a time window — for readings that
1342+
// were stored while the logger was offline (Deye re-serving one frozen value).
1343+
// Dry-run by default; ?confirm=1 deletes. Window capped at 31 days. `daily` is
1344+
// never touched (Deye's day totals are authoritative; missing days stay missing).
1345+
app.post("/api/_purge", async (c) => {
1346+
const env = c.env;
1347+
const from = Number(c.req.query("from")), to = Number(c.req.query("to"));
1348+
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) return c.json({ ok: false, error: "from/to must be unix seconds, from < to" }, 400);
1349+
if (to - from > 31 * 86400) return c.json({ ok: false, error: "window > 31 days" }, 400);
1350+
const confirm = c.req.query("confirm") === "1";
1351+
const cnt = async (t: string) => Number(((await env.DB.prepare(`SELECT COUNT(*) c FROM ${t} WHERE ts >= ? AND ts <= ?`).bind(from, to).first()) as any)?.c || 0);
1352+
const before = { samples: await cnt("samples"), device_samples: await cnt("device_samples") };
1353+
if (!confirm) return c.json({ ok: true, dryRun: true, from, to, wouldDelete: before });
1354+
await env.DB.batch([
1355+
env.DB.prepare("DELETE FROM samples WHERE ts >= ? AND ts <= ?").bind(from, to),
1356+
env.DB.prepare("DELETE FROM device_samples WHERE ts >= ? AND ts <= ?").bind(from, to),
1357+
env.DB.prepare("DELETE FROM meta WHERE k LIKE 'hist_v3_day_%' OR k LIKE 'batt_health_%'"),
1358+
]);
1359+
return c.json({ ok: true, dryRun: false, from, to, deleted: before });
1360+
});
1361+
13081362
app.post("/api/_alert_test", async (c) => {
13091363
if (!alertsConfigured(c.env)) return c.json({ ok: false, error: "no alert channel configured (ALERT_WEBHOOK_URL / TELEGRAM_BOT_TOKEN+TELEGRAM_CHAT_ID)" }, 400);
13101364
const r = await notify(c.env, "🔔 ทดสอบการแจ้งเตือนจาก Solar Monitor — ถ้าเห็นข้อความนี้แปลว่าช่องทางใช้ได้");

0 commit comments

Comments
 (0)