···
1
1
# Demo environment variables for realtime refresh
2
2
# Copy to .env and adjust as needed
3
3
-
# Default: 5 minutes (300000 ms)
3
3
+
## Server-side background poller interval (ms). Min 10000. Default 300000 (5 min).
4
4
+
RT_REFRESH_MS=300000
5
5
+
## Client-side refresh for the Realtime tab (ms). Default 300000 (5 min).
4
6
NEXT_PUBLIC_RT_REFRESH_MS=300000
7
7
+
## Only if you also want the API route (/api/rt) to write to DNT (normally OFF to avoid duplicates): set to 1 to enable
8
8
+
# RT_ARCHIVE_FROM_API=1
···
1
1
+
export { register } from "./src/instrumentation";
···
13
13
webpack: (config: any, { isServer }: { isServer: boolean }) => {
14
14
if (isServer) {
15
15
const externals = config.externals || [];
16
16
-
// Externalize base packages
16
16
+
// Externalize Node.js built-ins for server-only modules
17
17
externals.push({
18
18
+
"fs": "commonjs fs",
19
19
+
"fs/promises": "commonjs fs/promises",
20
20
+
"path": "commonjs path",
18
21
"@duckdb/node-api": "commonjs @duckdb/node-api",
19
22
"@duckdb/node-bindings": "commonjs @duckdb/node-bindings",
20
23
});
···
1
1
+
import "server-only";
1
2
import { NextResponse } from "next/server";
2
3
import { promises as fs } from "fs";
3
4
import path from "path";
···
1
1
+
import { NextResponse } from "next/server";
2
2
+
import { getLastRealtime } from "@/lib/realtimeArchiver";
3
3
+
4
4
+
export const dynamic = "force-dynamic";
5
5
+
export const runtime = "nodejs";
6
6
+
7
7
+
export async function GET() {
8
8
+
try {
9
9
+
const last = await getLastRealtime();
10
10
+
if (!last) {
11
11
+
return NextResponse.json({ ok: false, error: "no data yet", updatedAt: null }, { headers: { "Cache-Control": "no-store" } });
12
12
+
}
13
13
+
return NextResponse.json(last, { headers: { "Cache-Control": "no-store" } });
14
14
+
} catch (e: any) {
15
15
+
return NextResponse.json({ ok: false, error: e?.message || String(e) }, { status: 500, headers: { "Cache-Control": "no-store" } });
16
16
+
}
17
17
+
}
···
1
1
import { NextResponse } from "next/server";
2
2
import EcoCon from "eco";
3
3
+
import { buildTargetUrl, writeLiveToDNT } from "@/lib/realtimeArchiver";
3
4
4
5
export const dynamic = "force-dynamic"; // always fetch fresh
6
6
+
export const runtime = "nodejs"; // we need fs access
5
7
6
6
-
function buildParams(all: boolean) {
7
7
-
const eco = EcoCon.getInstance().getConfig();
8
8
-
const params = new URLSearchParams({
9
9
-
mac: eco.mac,
10
10
-
api_key: eco.apiKey,
11
11
-
application_key: eco.applicationKey,
12
12
-
method: "device/real_time",
13
13
-
call_back: all ? "all" : "indoor.temperature,outdoor.temperature",
14
14
-
temp_unitid: "1",
15
15
-
pressure_unitid: "3",
16
16
-
wind_speed_unitid: "7",
17
17
-
rainfall_unitid: "12",
18
18
-
solar_irradiance_unitid: "16"
19
19
-
});
20
20
-
return params;
21
21
-
}
8
8
+
// (archiving logic moved to shared module)
22
9
23
10
export async function GET(req: Request) {
24
11
try {
25
12
const url = new URL(req.url);
26
13
const all = url.searchParams.get("all") === "1";
27
27
-
const eco = EcoCon.getInstance().getConfig();
28
28
-
const baseUrl = `https://${eco.server}/api/v3/device/real_time`;
29
29
-
const qs = buildParams(all);
30
30
-
const target = `${baseUrl}?${qs.toString()}`;
14
14
+
const target = buildTargetUrl(all);
31
15
32
16
const res = await fetch(target, { cache: "no-store" });
33
17
if (!res.ok) {
···
35
19
return NextResponse.json({ ok: false, error: `Upstream ${res.status}`, body: text }, { status: res.status });
36
20
}
37
21
const data = await res.json();
22
22
+
// Optional: archive via API route (disabled by default to avoid duplicates with server poller)
23
23
+
if (process.env.RT_ARCHIVE_FROM_API === "1") {
24
24
+
try {
25
25
+
const payload = (data && (data.data || (data as any).payload || data)) as any;
26
26
+
if (payload && typeof payload === "object") {
27
27
+
await writeLiveToDNT(payload);
28
28
+
}
29
29
+
} catch (e) {
30
30
+
// Swallow write errors to not break realtime API
31
31
+
console.error("[rt] write to DNT failed:", e);
32
32
+
}
33
33
+
}
38
34
return NextResponse.json(data, { headers: { "Cache-Control": "no-store" } });
39
35
} catch (err: any) {
40
36
return NextResponse.json({ ok: false, error: String(err?.message || err) }, { status: 500 });
···
76
76
try {
77
77
setLoading(true);
78
78
setError(null);
79
79
-
const res = await fetch("/api/rt?all=1", { cache: "no-store" });
79
79
+
const res = await fetch("/api/rt/last", { cache: "no-store" });
80
80
if (!res.ok) throw new Error(`HTTP ${res.status}`);
81
81
-
const json = await res.json();
82
82
-
setData(json);
83
83
-
setLastUpdated(new Date());
81
81
+
const rec = await res.json();
82
82
+
if (!rec || rec.ok === false) {
83
83
+
const msg = rec?.error || "keine Daten";
84
84
+
setError(msg);
85
85
+
return;
86
86
+
}
87
87
+
setData(rec.data ?? null);
88
88
+
setLastUpdated(rec.updatedAt ? new Date(rec.updatedAt) : new Date());
84
89
} catch (e: any) {
85
90
setError(e?.message || String(e));
86
91
} finally {
···
120
125
}, [lastUpdated]);
121
126
122
127
const d = data as any;
123
123
-
const payload = d?.data ?? d; // try common wrapper
128
128
+
const payload = d; // cached payload already unwrapped
124
129
125
130
const indoorT = valueAndUnit(tryRead(payload, "indoor.temperature"));
126
131
const indoorH = valueAndUnit(tryRead(payload, "indoor.humidity"));
···
166
171
167
172
return (
168
173
<div className="space-y-3">
169
169
-
<div className="flex items-center justify-between">
174
174
+
<div className="flex items-center justify-start">
170
175
<div className="text-sm text-gray-600 dark:text-gray-400">Letzte Aktualisierung: {timeText}</div>
171
171
-
<button onClick={fetchNow} className="px-3 py-1.5 text-sm rounded bg-emerald-600 hover:bg-emerald-700 text-white disabled:opacity-50" disabled={loading}>
172
172
-
{loading ? "Aktualisiere…" : "Aktualisieren"}
173
173
-
</button>
174
176
</div>
175
177
176
178
{error && (
···
1
1
+
import "server-only";
2
2
+
3
3
+
// Avoid multiple intervals in dev/HMR
4
4
+
declare global {
5
5
+
// eslint-disable-next-line no-var
6
6
+
var __rtPoller: NodeJS.Timer | undefined;
7
7
+
}
8
8
+
9
9
+
export async function register() {
10
10
+
// Only run on Node.js runtime (not Edge)
11
11
+
if (process.env.NEXT_RUNTIME === "edge") return;
12
12
+
13
13
+
const msRaw = process.env.RT_REFRESH_MS ?? process.env.NEXT_PUBLIC_RT_REFRESH_MS ?? "300000"; // default 5 min
14
14
+
const intervalMs = Math.max(10_000, Number(msRaw) || 300_000); // min 10s safety
15
15
+
16
16
+
if (!global.__rtPoller) {
17
17
+
console.log(`[rt] Server poller active: every ${intervalMs} ms`);
18
18
+
// Immediate run to populate cache on startup
19
19
+
(async () => {
20
20
+
try {
21
21
+
const { fetchAndArchive } = await import("@/lib/realtimeArchiver");
22
22
+
await fetchAndArchive(true);
23
23
+
} catch (e) {
24
24
+
const msg = (e as any)?.message ? String((e as any).message) : String(e);
25
25
+
console.log(`[rt] update not ok: ${msg}`);
26
26
+
console.error("[rt] background fetch/archive failed:", e);
27
27
+
try {
28
28
+
const { setLastRealtime } = await import("@/lib/realtimeArchiver");
29
29
+
await setLastRealtime({ ok: false, updatedAt: new Date().toISOString(), error: msg });
30
30
+
} catch {}
31
31
+
}
32
32
+
})();
33
33
+
34
34
+
global.__rtPoller = setInterval(async () => {
35
35
+
try {
36
36
+
const { fetchAndArchive } = await import("@/lib/realtimeArchiver");
37
37
+
await fetchAndArchive(true);
38
38
+
} catch (e) {
39
39
+
const msg = (e as any)?.message ? String((e as any).message) : String(e);
40
40
+
console.log(`[rt] update not ok: ${msg}`);
41
41
+
console.error("[rt] background fetch/archive failed:", e);
42
42
+
try {
43
43
+
const { setLastRealtime } = await import("@/lib/realtimeArchiver");
44
44
+
await setLastRealtime({ ok: false, updatedAt: new Date().toISOString(), error: msg });
45
45
+
} catch {}
46
46
+
}
47
47
+
}, intervalMs);
48
48
+
}
49
49
+
}
···
1
1
+
import "server-only";
2
2
+
import EcoCon from "eco";
3
3
+
import { promises as fs } from "fs";
4
4
+
import path from "path";
5
5
+
6
6
+
function buildParams(all: boolean) {
7
7
+
const eco = EcoCon.getInstance().getConfig();
8
8
+
const params = new URLSearchParams({
9
9
+
mac: eco.mac,
10
10
+
api_key: eco.apiKey,
11
11
+
application_key: eco.applicationKey,
12
12
+
method: "device/real_time",
13
13
+
call_back: all ? "all" : "indoor.temperature,outdoor.temperature",
14
14
+
temp_unitid: "1",
15
15
+
pressure_unitid: "3",
16
16
+
wind_speed_unitid: "7",
17
17
+
rainfall_unitid: "12",
18
18
+
solar_irradiance_unitid: "16"
19
19
+
});
20
20
+
return params;
21
21
+
}
22
22
+
23
23
+
export function buildTargetUrl(all: boolean) {
24
24
+
const eco = EcoCon.getInstance().getConfig();
25
25
+
const baseUrl = `https://${eco.server}/api/v3/device/real_time`;
26
26
+
const qs = buildParams(all);
27
27
+
return `${baseUrl}?${qs.toString()}`;
28
28
+
}
29
29
+
30
30
+
function yyyymm(d: Date) {
31
31
+
const y = d.getFullYear();
32
32
+
const m = d.getMonth() + 1;
33
33
+
const mm = m < 10 ? `0${m}` : String(m);
34
34
+
return `${y}${mm}`;
35
35
+
}
36
36
+
37
37
+
function timeString(d: Date) {
38
38
+
// Format: 2025/08/13 12:03 (with leading zeros)
39
39
+
const y = d.getFullYear();
40
40
+
const M = d.getMonth() + 1;
41
41
+
const D = d.getDate();
42
42
+
const H = d.getHours();
43
43
+
const Min = d.getMinutes();
44
44
+
45
45
+
// Add leading zeros
46
46
+
const mm = M < 10 ? `0${M}` : String(M);
47
47
+
const dd = D < 10 ? `0${D}` : String(D);
48
48
+
const hh = H < 10 ? `0${H}` : String(H);
49
49
+
const min = Min < 10 ? `0${Min}` : String(Min);
50
50
+
51
51
+
return `${y}/${mm}/${dd} ${hh}:${min}`;
52
52
+
}
53
53
+
54
54
+
function tryRead(obj: any, dotted: string): any {
55
55
+
return dotted.split(".").reduce((o, k) => (o && typeof o === "object" ? (k in o ? o[k] : undefined) : undefined), obj);
56
56
+
}
57
57
+
58
58
+
function numVal(v: any): number | null {
59
59
+
if (v == null) return null;
60
60
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
61
61
+
if (typeof v === "string") return isNaN(Number(v)) ? null : Number(v);
62
62
+
if (typeof v === "object" && v) {
63
63
+
const x = (v as any).value;
64
64
+
if (x == null) return null;
65
65
+
if (typeof x === "number") return Number.isFinite(x) ? x : null;
66
66
+
if (typeof x === "string") return isNaN(Number(x)) ? null : Number(x);
67
67
+
}
68
68
+
return null;
69
69
+
}
70
70
+
71
71
+
async function ensureDir(p: string) {
72
72
+
await fs.mkdir(p, { recursive: true });
73
73
+
}
74
74
+
75
75
+
async function appendCsv(abs: string, header: string[], row: (string | number | null)[]) {
76
76
+
let exists = true;
77
77
+
try { await fs.access(abs); } catch { exists = false; }
78
78
+
const lines: string[] = [];
79
79
+
if (!exists) {
80
80
+
lines.push(header.join(","));
81
81
+
}
82
82
+
const body = row.map((v) => (v == null ? "" : String(v))).join(",");
83
83
+
lines.push(body);
84
84
+
await fs.appendFile(abs, lines.join("\n") + "\n", "utf8");
85
85
+
}
86
86
+
87
87
+
export async function writeLiveToDNT(payload: any) {
88
88
+
const now = new Date();
89
89
+
const ym = yyyymm(now);
90
90
+
const dnt = path.join(process.cwd(), "DNT");
91
91
+
await ensureDir(dnt);
92
92
+
93
93
+
// Allsensors_A (channels)
94
94
+
const allsFile = path.join(dnt, `${ym}Allsensors_A.CSV`);
95
95
+
const allsHeader: string[] = ["Time"];
96
96
+
const allsRow: (string | number | null)[] = [timeString(now)];
97
97
+
for (let i = 1; i <= 8; i++) {
98
98
+
const ch = tryRead(payload, `ch${i}`) ?? tryRead(payload, `temp_and_humidity_ch${i}`);
99
99
+
allsHeader.push(`CH${i} Temperature`, `CH${i} Luftfeuchtigkeit`, `CH${i} Taupunkt`);
100
100
+
const t = numVal(ch?.temperature);
101
101
+
const h = numVal(ch?.humidity);
102
102
+
const d = numVal(ch?.dew_point);
103
103
+
allsRow.push(t, h, d);
104
104
+
}
105
105
+
await appendCsv(allsFile, allsHeader, allsRow);
106
106
+
107
107
+
// Main A (station)
108
108
+
const mainFile = path.join(dnt, `${ym}A.CSV`);
109
109
+
const mainHeader = [
110
110
+
"Time",
111
111
+
"Outdoor Temperature",
112
112
+
"Outdoor Humidity",
113
113
+
"Indoor Temperature",
114
114
+
"Indoor Humidity",
115
115
+
"Pressure Relative",
116
116
+
"Pressure Absolute",
117
117
+
"Wind Speed",
118
118
+
"Wind Gust",
119
119
+
"Wind Direction",
120
120
+
"Wind Direction 10min",
121
121
+
"Rain Rate",
122
122
+
"Rain Hourly",
123
123
+
"Rain Daily",
124
124
+
"Rain Weekly",
125
125
+
"Rain Monthly",
126
126
+
"Rain Yearly",
127
127
+
"Solar",
128
128
+
"UVI"
129
129
+
];
130
130
+
const mainRow: (string | number | null)[] = [timeString(now)];
131
131
+
mainRow.push(
132
132
+
numVal(tryRead(payload, "outdoor.temperature")),
133
133
+
numVal(tryRead(payload, "outdoor.humidity")),
134
134
+
numVal(tryRead(payload, "indoor.temperature")),
135
135
+
numVal(tryRead(payload, "indoor.humidity")),
136
136
+
numVal(tryRead(payload, "pressure.relative") ?? tryRead(payload, "barometer.relative") ?? tryRead(payload, "barometer.rel")),
137
137
+
numVal(tryRead(payload, "pressure.absolute") ?? tryRead(payload, "barometer.absolute") ?? tryRead(payload, "barometer.abs")),
138
138
+
numVal(tryRead(payload, "wind.wind_speed")),
139
139
+
numVal(tryRead(payload, "wind.wind_gust")),
140
140
+
numVal(tryRead(payload, "wind.wind_direction")),
141
141
+
numVal(tryRead(payload, "wind.10_minute_average_wind_direction")),
142
142
+
numVal(tryRead(payload, "rainfall.rain_rate") ?? tryRead(payload, "rain.rate")),
143
143
+
numVal(tryRead(payload, "rainfall.hourly")),
144
144
+
numVal(tryRead(payload, "rainfall.daily")),
145
145
+
numVal(tryRead(payload, "rainfall.weekly")),
146
146
+
numVal(tryRead(payload, "rainfall.monthly")),
147
147
+
numVal(tryRead(payload, "rainfall.yearly")),
148
148
+
numVal(tryRead(payload, "solar_and_uvi.solar")),
149
149
+
numVal(tryRead(payload, "solar_and_uvi.uvi"))
150
150
+
);
151
151
+
await appendCsv(mainFile, mainHeader, mainRow);
152
152
+
}
153
153
+
154
154
+
function cachePath() {
155
155
+
return path.join(process.cwd(), "DNT", "rt-last.json");
156
156
+
}
157
157
+
158
158
+
export async function setLastRealtime(rec: { ok: boolean; updatedAt: string; data?: any; error?: string }) {
159
159
+
const dnt = path.join(process.cwd(), "DNT");
160
160
+
await ensureDir(dnt);
161
161
+
try {
162
162
+
await fs.writeFile(cachePath(), JSON.stringify(rec), "utf8");
163
163
+
} catch (e) {
164
164
+
// best-effort; ignore
165
165
+
}
166
166
+
}
167
167
+
168
168
+
export async function getLastRealtime(): Promise<{ ok: boolean; updatedAt: string; data?: any; error?: string } | null> {
169
169
+
try {
170
170
+
const txt = await fs.readFile(cachePath(), "utf8");
171
171
+
return JSON.parse(txt);
172
172
+
} catch {
173
173
+
return null;
174
174
+
}
175
175
+
}
176
176
+
177
177
+
export async function fetchAndArchive(all: boolean = true) {
178
178
+
const target = buildTargetUrl(all);
179
179
+
const res = await fetch(target, { cache: "no-store" });
180
180
+
if (!res.ok) {
181
181
+
const txt = await res.text().catch(() => "");
182
182
+
throw new Error(`Upstream ${res.status}: ${txt}`);
183
183
+
}
184
184
+
const data = await res.json();
185
185
+
const payload = (data && (data.data || (data as any).payload || data)) as any;
186
186
+
if (payload && typeof payload === "object") {
187
187
+
await writeLiveToDNT(payload);
188
188
+
// Log success with ISO timestamp
189
189
+
try {
190
190
+
console.log(`[rt] update ok: ${new Date().toISOString()}`);
191
191
+
} catch {}
192
192
+
await setLastRealtime({ ok: true, updatedAt: new Date().toISOString(), data: payload });
193
193
+
}
194
194
+
return data;
195
195
+
}