Weather Station / ECOWITT / DNT
0

Configure Feed

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

live date fromapi

+406 -3
+41
README.md
··· 95 95 96 96 All API routes run in the Node.js runtime and read from the local filesystem. 97 97 98 + ## Realtime data (Ecowitt API v3) 99 + 100 + The homepage is split into two tabs: 101 + 102 + - **Echtzeit**: Fetches live data from Ecowitt API v3 via a server-side proxy (`/api/rt`). 103 + - **Gespeicherte Daten**: Historical dashboard powered by DuckDB/Parquet over your `DNT/` CSVs. 104 + 105 + Realtime proxy route: 106 + 107 + - `GET /api/rt?all=1` — returns the full "all" payload from Ecowitt 108 + - `GET /api/rt` — returns a small subset (indoor/outdoor temps) 109 + 110 + The proxy uses credentials from `eco.ts` (server-only) so your keys aren’t exposed to the browser. 111 + 112 + Docs: https://doc.ecowitt.net/web/#/apiv3en?page_id=17 (Getting Device Real-Time Data) 113 + 98 114 ## Development 99 115 100 116 ```bash ··· 102 118 npm run dev 103 119 # usually opens http://localhost:3000 104 120 ``` 121 + 122 + ### Configuration (.env and eco.ts) 123 + 124 + 1) Environment variables 125 + 126 + - Copy `env.example` to `.env` and adjust as needed. 127 + - Supported variable(s): 128 + 129 + ``` 130 + NEXT_PUBLIC_RT_REFRESH_MS=300000 # Realtime refresh interval in ms (default 300000 = 5 min) 131 + ``` 132 + 133 + 2) Ecowitt credentials (server-side) 134 + 135 + - Copy `eco.example.ts` to `eco.ts` and fill in your values: 136 + - `applicationKey` 137 + - `apiKey` 138 + - `mac` (station MAC, e.g., `F0:08:D1:07:AF:83`) 139 + - `server` (usually `api.ecowitt.net`) 140 + - `eco.ts` is imported by the server-side proxy at `src/app/api/rt/route.ts`. 141 + 142 + Security notes: 143 + 144 + - `.env*` files and `eco.ts` are ignored by Git (see `.gitignore`). 145 + - Do not commit your real keys. 105 146 106 147 ## Scripts 107 148
+34
eco.example.ts
··· 1 + // Demo EcoCon configuration. 2 + // Copy this file to `eco.ts` and fill in your Ecowitt API credentials. 3 + // IMPORTANT: Do NOT commit your real keys. The repo's .gitignore excludes `eco.ts`. 4 + // Docs: https://doc.ecowitt.net/web/#/apiv3en?page_id=17 5 + 6 + class EcoCon { 7 + private static instance: EcoCon; 8 + 9 + private readonly config = { 10 + applicationKey: "YOUR_APPLICATION_KEY_HERE", 11 + apiKey: "YOUR_API_KEY_HERE", 12 + mac: "AA:BB:CC:DD:EE:FF", 13 + server: "api.ecowitt.net" 14 + }; 15 + 16 + private constructor() {} 17 + 18 + public static getInstance(): EcoCon { 19 + if (!EcoCon.instance) { 20 + EcoCon.instance = new EcoCon(); 21 + } 22 + return EcoCon.instance; 23 + } 24 + 25 + public setServer(server: string): void { 26 + this.config.server = server; 27 + } 28 + 29 + public getConfig(): Readonly<typeof this.config> { 30 + return this.config; 31 + } 32 + } 33 + 34 + export default EcoCon;
+4
env.example
··· 1 + # Demo environment variables for realtime refresh 2 + # Copy to .env and adjust as needed 3 + # Default: 5 minutes (300000 ms) 4 + NEXT_PUBLIC_RT_REFRESH_MS=300000
+42
src/app/api/rt/route.ts
··· 1 + import { NextResponse } from "next/server"; 2 + import EcoCon from "eco"; 3 + 4 + export const dynamic = "force-dynamic"; // always fetch fresh 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 async function GET(req: Request) { 24 + try { 25 + const url = new URL(req.url); 26 + 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()}`; 31 + 32 + const res = await fetch(target, { cache: "no-store" }); 33 + if (!res.ok) { 34 + const text = await res.text().catch(() => ""); 35 + return NextResponse.json({ ok: false, error: `Upstream ${res.status}`, body: text }, { status: res.status }); 36 + } 37 + const data = await res.json(); 38 + return NextResponse.json(data, { headers: { "Cache-Control": "no-store" } }); 39 + } catch (err: any) { 40 + return NextResponse.json({ ok: false, error: String(err?.message || err) }, { status: 500 }); 41 + } 42 + }
+25 -1
src/app/page.tsx
··· 1 + "use client"; 2 + 3 + import React, { useState } from "react"; 1 4 import Dashboard from "@/components/Dashboard"; 5 + import Realtime from "@/components/Realtime"; 2 6 3 7 export default function Home() { 8 + const [tab, setTab] = useState<"rt" | "stored">("rt"); 4 9 return ( 5 10 <div className="min-h-screen w-full bg-gray-50 dark:bg-neutral-950 text-gray-900 dark:text-gray-100 p-4 sm:p-6"> 6 - <Dashboard /> 11 + <div className="max-w-6xl mx-auto"> 12 + <div className="mb-4 flex items-center gap-2 border-b border-gray-200 dark:border-neutral-800"> 13 + <button 14 + className={`px-3 py-2 text-sm font-medium rounded-t ${tab === "rt" ? "bg-white dark:bg-neutral-900 border border-b-0 border-gray-200 dark:border-neutral-800" : "text-gray-600 hover:text-gray-900"}`} 15 + onClick={() => setTab("rt")} 16 + > 17 + Echtzeit 18 + </button> 19 + <button 20 + className={`px-3 py-2 text-sm font-medium rounded-t ${tab === "stored" ? "bg-white dark:bg-neutral-900 border border-b-0 border-gray-200 dark:border-neutral-800" : "text-gray-600 hover:text-gray-900"}`} 21 + onClick={() => setTab("stored")} 22 + > 23 + Gespeicherte Daten 24 + </button> 25 + </div> 26 + 27 + <div className="rounded-b border border-gray-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4"> 28 + {tab === "rt" ? <Realtime /> : <Dashboard />} 29 + </div> 30 + </div> 7 31 </div> 8 32 ); 9 33 }
+257
src/components/Realtime.tsx
··· 1 + "use client"; 2 + 3 + import React, { useEffect, useMemo, useState } from "react"; 4 + 5 + type RTData = any; 6 + 7 + function LabelValue({ label, value }: { label: string; value: React.ReactNode }) { 8 + return ( 9 + <div className="flex items-center justify-between py-1 text-sm"> 10 + <span className="text-gray-600 dark:text-gray-300">{label}</span> 11 + <span className="font-medium text-gray-900 dark:text-gray-100">{value ?? "—"}</span> 12 + </div> 13 + ); 14 + } 15 + 16 + function tryRead(obj: any, path: string): any { 17 + return path.split(".").reduce((acc, key) => (acc && key in acc ? acc[key] : undefined), obj); 18 + } 19 + 20 + function valueAndUnit(v: any): { value: string | number | null; unit?: string } { 21 + if (v == null) return { value: null }; 22 + if (typeof v === "object" && ("value" in v)) { 23 + return { value: (v as any).value, unit: (v as any).unit }; 24 + } 25 + return { value: v }; 26 + } 27 + 28 + function fmtVU(vu: { value: string | number | null; unit?: string }, fallbackUnit?: string) { 29 + if (vu.value == null || vu.value === "") return "—"; 30 + const unit = vu.unit ?? fallbackUnit ?? ""; 31 + return `${vu.value}${unit ? ` ${unit}` : ""}`; 32 + } 33 + 34 + function fmtBattery(v: any) { 35 + const vu = valueAndUnit(v); 36 + if (vu.value == null || vu.value === "") return "—"; 37 + const n = Number(vu.value); 38 + if (Number.isNaN(n)) return String(vu.value); 39 + return n === 0 ? "OK" : "Niedrig"; 40 + } 41 + 42 + function deLabel(key: string): string { 43 + const k = key.toLowerCase(); 44 + const map: Record<string, string> = { 45 + temperature: "Temperatur", 46 + humidity: "Feuchte", 47 + feels_like: "Gefühlt", 48 + app_temp: "App-Temp", 49 + dew_point: "Taupunkt", 50 + wind_speed: "Wind", 51 + wind_gust: "Böe", 52 + wind_direction: "Richtung", 53 + "10_minute_average_wind_direction": "Richtung (10 min)", 54 + rain_rate: "Regenrate", 55 + hourly: "Stündlich", 56 + daily: "Täglich", 57 + weekly: "Wöchentlich", 58 + monthly: "Monatlich", 59 + yearly: "Jährlich", 60 + relative: "relativ", 61 + absolute: "absolut", 62 + solar: "Solar", 63 + uvi: "UV-Index" 64 + }; 65 + return map[k] || key.replace(/_/g, " "); 66 + } 67 + 68 + export default function Realtime() { 69 + const [data, setData] = useState<RTData | null>(null); 70 + const [error, setError] = useState<string | null>(null); 71 + const [loading, setLoading] = useState<boolean>(false); 72 + const [lastUpdated, setLastUpdated] = useState<Date | null>(null); 73 + const [channels, setChannels] = useState<Record<string, { name?: string }>>({}); 74 + 75 + const fetchNow = async () => { 76 + try { 77 + setLoading(true); 78 + setError(null); 79 + const res = await fetch("/api/rt?all=1", { cache: "no-store" }); 80 + if (!res.ok) throw new Error(`HTTP ${res.status}`); 81 + const json = await res.json(); 82 + setData(json); 83 + setLastUpdated(new Date()); 84 + } catch (e: any) { 85 + setError(e?.message || String(e)); 86 + } finally { 87 + setLoading(false); 88 + } 89 + }; 90 + 91 + useEffect(() => { 92 + fetchNow(); 93 + const refreshMs = Number(process.env.NEXT_PUBLIC_RT_REFRESH_MS || 300000); // default 5 min 94 + const id = setInterval(fetchNow, isFinite(refreshMs) && refreshMs > 0 ? refreshMs : 300000); 95 + return () => clearInterval(id); 96 + }, []); 97 + 98 + // Load channel display names 99 + useEffect(() => { 100 + (async () => { 101 + try { 102 + const res = await fetch("/api/config/channels", { cache: "no-store" }); 103 + if (!res.ok) return; 104 + const json = await res.json(); 105 + setChannels(json || {}); 106 + } catch {} 107 + })(); 108 + }, []); 109 + 110 + const timeText = useMemo(() => { 111 + if (!lastUpdated) return "—"; 112 + const d = lastUpdated; 113 + const dd = String(d.getDate()).padStart(2, "0"); 114 + const mm = String(d.getMonth() + 1).padStart(2, "0"); 115 + const yyyy = d.getFullYear(); 116 + const hh = String(d.getHours()).padStart(2, "0"); 117 + const mi = String(d.getMinutes()).padStart(2, "0"); 118 + const ss = String(d.getSeconds()).padStart(2, "0"); 119 + return `${dd}.${mm}.${yyyy} ${hh}:${mi}:${ss}`; 120 + }, [lastUpdated]); 121 + 122 + const d = data as any; 123 + const payload = d?.data ?? d; // try common wrapper 124 + 125 + const indoorT = valueAndUnit(tryRead(payload, "indoor.temperature")); 126 + const indoorH = valueAndUnit(tryRead(payload, "indoor.humidity")); 127 + const outdoorT = valueAndUnit(tryRead(payload, "outdoor.temperature")); 128 + const outdoorH = valueAndUnit(tryRead(payload, "outdoor.humidity")); 129 + const feelsLike = valueAndUnit(tryRead(payload, "outdoor.feels_like")); 130 + const appTemp = valueAndUnit(tryRead(payload, "outdoor.app_temp")); 131 + const dewPoint = valueAndUnit(tryRead(payload, "outdoor.dew_point")); 132 + // Pressure (relative/absolute) 133 + const pressureRel = valueAndUnit(tryRead(payload, "pressure.relative") ?? tryRead(payload, "barometer.relative") ?? tryRead(payload, "barometer.rel")); 134 + const pressureAbs = valueAndUnit(tryRead(payload, "pressure.absolute") ?? tryRead(payload, "barometer.absolute") ?? tryRead(payload, "barometer.abs")); 135 + // Wind 136 + const wind = valueAndUnit(tryRead(payload, "wind.wind_speed") ?? tryRead(payload, "wind_speed")); 137 + const gust = valueAndUnit(tryRead(payload, "wind.wind_gust") ?? tryRead(payload, "wind_gust")); 138 + const windDir = valueAndUnit(tryRead(payload, "wind.wind_direction") ?? tryRead(payload, "wind_direction")); 139 + const windDir10 = valueAndUnit(tryRead(payload, "wind.10_minute_average_wind_direction")); 140 + // Rainfall 141 + const rainRate = valueAndUnit(tryRead(payload, "rainfall.rain_rate") ?? tryRead(payload, "rain.rate")); 142 + const rainDaily = valueAndUnit(tryRead(payload, "rainfall.daily")); 143 + const rainHourly = valueAndUnit(tryRead(payload, "rainfall.hourly")); 144 + const rainWeekly = valueAndUnit(tryRead(payload, "rainfall.weekly")); 145 + const rainMonthly = valueAndUnit(tryRead(payload, "rainfall.monthly")); 146 + const rainYearly = valueAndUnit(tryRead(payload, "rainfall.yearly")); 147 + // Solar & UV 148 + const solar = valueAndUnit(tryRead(payload, "solar_and_uvi.solar")); 149 + const uvi = valueAndUnit(tryRead(payload, "solar_and_uvi.uvi")); 150 + 151 + // Detect channel sensor groups (e.g., ch1..ch8 or temp_and_humidity_ch1..ch8) 152 + const channelKeys = useMemo(() => { 153 + if (!payload || typeof payload !== "object") return [] as string[]; 154 + return Object.keys(payload) 155 + .filter((k) => /^ch\d+$/i.test(k) || /_ch\d+$/i.test(k)) 156 + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); 157 + }, [payload]); 158 + 159 + function channelDisplayName(key: string) { 160 + const m = key.match(/(?:^ch|_ch)(\d+)$/i); 161 + const id = m ? `ch${m[1]}`.toLowerCase() : key.toLowerCase(); 162 + const name = channels?.[id]?.name; 163 + if (name) return `${name} (${id.toUpperCase()})`; 164 + return key.replace(/^temp_and_humidity_/i, "").toUpperCase(); 165 + } 166 + 167 + return ( 168 + <div className="space-y-3"> 169 + <div className="flex items-center justify-between"> 170 + <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 + </div> 175 + 176 + {error && ( 177 + <div className="rounded border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">{error}</div> 178 + )} 179 + 180 + <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> 181 + <div className="rounded border border-gray-200 p-3"> 182 + <div className="font-semibold mb-2 text-emerald-700">Innen</div> 183 + <LabelValue label="Temperatur" value={fmtVU(indoorT, "°C")} /> 184 + <LabelValue label="Feuchte" value={fmtVU(indoorH, "%")} /> 185 + <LabelValue label="Luftdruck (rel.)" value={fmtVU(pressureRel, "hPa")} /> 186 + <LabelValue label="Luftdruck (abs.)" value={fmtVU(pressureAbs, "hPa")} /> 187 + </div> 188 + <div className="rounded border border-gray-200 p-3"> 189 + <div className="font-semibold mb-2 text-sky-700">Außen</div> 190 + <LabelValue label="Temperatur" value={fmtVU(outdoorT, "°C")} /> 191 + <LabelValue label="Feuchte" value={fmtVU(outdoorH, "%")} /> 192 + <LabelValue label="Gefühlt" value={fmtVU(feelsLike, "°C")} /> 193 + <LabelValue label="App-Temp" value={fmtVU(appTemp, "°C")} /> 194 + <LabelValue label="Taupunkt" value={fmtVU(dewPoint, "°C")} /> 195 + <LabelValue label="Wind" value={fmtVU(wind)} /> 196 + <LabelValue label="Böe" value={fmtVU(gust)} /> 197 + <LabelValue label="Richtung" value={fmtVU(windDir, "º")} /> 198 + <LabelValue label="Richtung (10 min)" value={fmtVU(windDir10, "º")} /> 199 + <LabelValue label="Regenrate" value={fmtVU(rainRate)} /> 200 + </div> 201 + </div> 202 + 203 + <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> 204 + <div className="rounded border border-gray-200 p-3"> 205 + <div className="font-semibold mb-2 text-amber-700">Solar / UV</div> 206 + <LabelValue label="Solar" value={fmtVU(solar, "W/m²")} /> 207 + <LabelValue label="UV Index" value={fmtVU(uvi)} /> 208 + </div> 209 + <div className="rounded border border-gray-200 p-3"> 210 + <div className="font-semibold mb-2 text-blue-700">Niederschlag</div> 211 + <LabelValue label="Rate" value={fmtVU(rainRate)} /> 212 + <LabelValue label="Stündlich" value={fmtVU(rainHourly, "mm")} /> 213 + <LabelValue label="Täglich" value={fmtVU(rainDaily, "mm")} /> 214 + <LabelValue label="Wöchentlich" value={fmtVU(rainWeekly, "mm")} /> 215 + <LabelValue label="Monatlich" value={fmtVU(rainMonthly, "mm")} /> 216 + <LabelValue label="Jährlich" value={fmtVU(rainYearly, "mm")} /> 217 + </div> 218 + </div> 219 + 220 + {channelKeys.length > 0 && ( 221 + <div className="rounded border border-gray-200 p-3"> 222 + <div className="font-semibold mb-2 text-purple-700">Kanalsensoren</div> 223 + <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> 224 + {channelKeys.map((ck) => { 225 + const ch = payload[ck] || {}; 226 + const entries = Object.entries(ch) as [string, any][]; 227 + return ( 228 + <div key={ck} className="rounded border border-gray-100 p-3"> 229 + <div className="font-medium mb-1">{channelDisplayName(ck)}</div> 230 + {entries.length === 0 && <div className="text-xs text-gray-500">Keine Daten</div>} 231 + {entries.map(([name, val]) => { 232 + const vu = valueAndUnit(val); 233 + return <LabelValue key={name} label={deLabel(name)} value={fmtVU(vu)} />; 234 + })} 235 + </div> 236 + ); 237 + })} 238 + </div> 239 + </div> 240 + )} 241 + 242 + {payload?.battery && typeof payload.battery === "object" && ( 243 + <div className="rounded border border-gray-200 p-3"> 244 + <div className="font-semibold mb-2 text-stone-700">Batterie</div> 245 + {Object.entries(payload.battery as Record<string, any>).map(([name, val]) => ( 246 + <LabelValue key={name} label={name} value={fmtBattery(val)} /> 247 + ))} 248 + </div> 249 + )} 250 + 251 + <details className="rounded border border-gray-200 p-3"> 252 + <summary className="cursor-pointer text-sm text-gray-700">Rohdaten</summary> 253 + <pre className="mt-2 text-xs overflow-auto max-h-80 bg-gray-50 dark:bg-neutral-900 p-2 rounded">{JSON.stringify(data, null, 2)}</pre> 254 + </details> 255 + </div> 256 + ); 257 + }
+1 -1
src/config/channels.json
··· 2 2 "ch1": { "name": "VK" }, 3 3 "ch2": { "name": "KU" }, 4 4 "ch3": { "name": "Garten1" }, 5 - "ch4": { "name": "CH4" }, 5 + "ch4": { "name": "WZ" }, 6 6 "ch5": { "name": "Bad" }, 7 7 "ch6": { "name": "Keller" }, 8 8 "ch7": { "name": "Garten" },
+2 -1
tsconfig.json
··· 20 20 } 21 21 ], 22 22 "paths": { 23 - "@/*": ["./src/*"] 23 + "@/*": ["./src/*"], 24 + "eco": ["./eco.ts"] 24 25 } 25 26 }, 26 27 "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],