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