Weather Station / ECOWITT / DNT
0

Configure Feed

Select the types of activity you want to include in your feed.

live date backend

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