diff --git a/Dockerfile b/Dockerfile index 2db31ec..63b8827 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,9 @@ COPY --from=build /out/ingestd /app/ingestd USER appuser +# Web UI +EXPOSE 8080 + # default config path inside container ENTRYPOINT ["/app/ingestd"] CMD ["-config", "/app/config.yaml"] diff --git a/README.md b/README.md index 03ab2a3..e519dce 100644 --- a/README.md +++ b/README.md @@ -15,5 +15,8 @@ Starter go-weatherstationrology data pipeline: 3) Run: go run ./cmd/ingestd -config config.yaml +4) Web UI: + http://localhost:8080 + ## Publish a test WS90 payload mosquitto_pub -h localhost -t ecowitt/ws90 -m '{"model":"Fineoffset-WS90","id":70618,"battery_ok":1,"battery_mV":3180,"temperature_C":24.2,"humidity":60,"wind_dir_deg":129,"wind_avg_m_s":0,"wind_max_m_s":0,"uvi":0,"light_lux":0,"flags":130,"rain_mm":0,"rain_start":0,"supercap_V":0.5,"firmware":160,"data":"3fff000000------0000ff7ff70000","mic":"CRC","protocol":"Fine Offset Electronics WS90 weather station","rssi":-44,"duration":32996}' diff --git a/cmd/ingestd/main.go b/cmd/ingestd/main.go index 054f8b2..5e2308e 100644 --- a/cmd/ingestd/main.go +++ b/cmd/ingestd/main.go @@ -58,6 +58,14 @@ func main() { go runOpenMeteoPoller(ctx, d, forecastCache, site, cfg.Pollers.OpenMeteo.Model, cfg.Pollers.OpenMeteo.Interval) } + if cfg.Web.Enabled != nil && *cfg.Web.Enabled { + go func() { + if err := runWebServer(ctx, d, site, cfg.Pollers.OpenMeteo.Model, cfg.Web.Listen); err != nil { + log.Printf("web server error: %v", err) + } + }() + } + if cfg.Wunderground.Enabled { go runWundergroundUploader(ctx, latest, cfg.Wunderground.StationID, cfg.Wunderground.StationKey, cfg.Wunderground.Interval) } diff --git a/cmd/ingestd/web.go b/cmd/ingestd/web.go new file mode 100644 index 0000000..e2154a4 --- /dev/null +++ b/cmd/ingestd/web.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "embed" + "encoding/json" + "errors" + "io/fs" + "log" + "net/http" + "time" + + "go-weatherstation/internal/db" + "go-weatherstation/internal/providers" +) + +//go:embed web/* +var webFS embed.FS + +type webServer struct { + db *db.DB + site providers.Site + model string +} + +type dashboardResponse struct { + GeneratedAt time.Time `json:"generated_at"` + Site string `json:"site"` + Model string `json:"model"` + OpenMeteoURL string `json:"open_meteo_url,omitempty"` + Observations []db.ObservationPoint `json:"observations"` + Forecast db.ForecastSeries `json:"forecast"` + Latest *db.ObservationPoint `json:"latest"` +} + +func runWebServer(ctx context.Context, d *db.DB, site providers.Site, model, addr string) error { + sub, err := fs.Sub(webFS, "web") + if err != nil { + return err + } + + ws := &webServer{ + db: d, + site: site, + model: model, + } + + mux := http.NewServeMux() + mux.HandleFunc("/api/dashboard", ws.handleDashboard) + mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + mux.Handle("/", http.FileServer(http.FS(sub))) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + errCh <- srv.ListenAndServe() + }() + + log.Printf("web ui listening addr=%s", addr) + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + return nil + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +func (s *webServer) handleDashboard(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + rangeStr := r.URL.Query().Get("range") + if rangeStr == "" { + rangeStr = "24h" + } + bucket := r.URL.Query().Get("bucket") + if bucket == "" { + bucket = "5m" + } + if bucket != "1m" && bucket != "5m" { + http.Error(w, "invalid bucket", http.StatusBadRequest) + return + } + + rangeDur, err := time.ParseDuration(rangeStr) + if err != nil || rangeDur <= 0 { + http.Error(w, "invalid range", http.StatusBadRequest) + return + } + + observations, err := s.db.ObservationSeries(r.Context(), s.site.Name, bucket, int64(rangeDur.Seconds())) + if err != nil { + http.Error(w, "failed to query observations", http.StatusInternalServerError) + log.Printf("web dashboard observations error: %v", err) + return + } + + latest, err := s.db.LatestObservation(r.Context(), s.site.Name) + if err != nil { + http.Error(w, "failed to query latest observation", http.StatusInternalServerError) + log.Printf("web dashboard latest error: %v", err) + return + } + + forecast, err := s.db.ForecastSeriesLatest(r.Context(), s.site.Name, s.model) + if err != nil { + http.Error(w, "failed to query forecast", http.StatusInternalServerError) + log.Printf("web dashboard forecast error: %v", err) + return + } + + openMeteoURL, err := providers.OpenMeteoRequestURL(s.site, s.model) + if err != nil { + log.Printf("web dashboard open-meteo url error: %v", err) + } + + resp := dashboardResponse{ + GeneratedAt: time.Now().UTC(), + Site: s.site.Name, + Model: s.model, + OpenMeteoURL: openMeteoURL, + Observations: observations, + Forecast: forecast, + Latest: latest, + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("web dashboard encode error: %v", err) + } +} diff --git a/cmd/ingestd/web/app.js b/cmd/ingestd/web/app.js new file mode 100644 index 0000000..d9a96db --- /dev/null +++ b/cmd/ingestd/web/app.js @@ -0,0 +1,352 @@ +const state = { + range: "24h", + bucket: "5m", + charts: {}, + timer: null, +}; + +const colors = { + obs: "#7bdff2", + forecast: "#f4b942", + gust: "#ff7d6b", + humidity: "#7ee081", + uvi: "#f4d35e", + light: "#b8f2e6", + precip: "#4ea8de", +}; + +function formatNumber(value, digits) { + if (value === null || value === undefined) { + return "--"; + } + return Number(value).toFixed(digits); +} + +function formatTime(iso) { + if (!iso) return "--"; + const dt = new Date(iso); + if (Number.isNaN(dt.getTime())) return "--"; + return dt.toLocaleString(); +} + +function series(points, key) { + return points.map((p) => ({ + x: p.ts, + y: p[key] === undefined ? null : p[key], + })); +} + +function minMax(values) { + let min = null; + let max = null; + for (const v of values) { + if (v === null || v === undefined) continue; + if (min === null || v < min) min = v; + if (max === null || v > max) max = v; + } + return { min, max }; +} + +function sum(values) { + let total = 0; + let seen = false; + for (const v of values) { + if (v === null || v === undefined) continue; + total += v; + seen = true; + } + return seen ? total : null; +} + +function updateText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; +} + +function upsertChart(id, config) { + const ctx = document.getElementById(id); + if (!ctx) return; + if (state.charts[id]) { + state.charts[id].data = config.data; + state.charts[id].options = config.options; + state.charts[id].update(); + return; + } + state.charts[id] = new Chart(ctx, config); +} + +function baseOptions() { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: { + legend: { labels: { color: "#d6f0f0" } }, + tooltip: { mode: "index", intersect: false }, + }, + scales: { + x: { + type: "time", + time: { unit: "hour" }, + ticks: { color: "#a4c4c4", maxTicksLimit: 6 }, + grid: { color: "rgba(123, 223, 242, 0.08)" }, + }, + y: { + ticks: { color: "#a4c4c4" }, + grid: { color: "rgba(123, 223, 242, 0.08)" }, + }, + }, + elements: { + line: { tension: 0.2, borderWidth: 2 }, + point: { radius: 0, hitRadius: 8 }, + }, + spanGaps: true, + }; +} + +function renderDashboard(data) { + const latest = data.latest; + updateText("site-meta", `${data.site} | model ${data.model}`); + updateText("last-updated", `updated ${formatTime(data.generated_at)}`); + const forecastMeta = data.forecast && data.forecast.points && data.forecast.points.length + ? `forecast retrieved ${formatTime(data.forecast.retrieved_at)}` + : "forecast not available"; + updateText("forecast-meta", forecastMeta); + + if (latest) { + updateText("live-temp", `${formatNumber(latest.temp_c, 2)} C`); + updateText("live-rh", `${formatNumber(latest.rh, 0)} %`); + updateText("live-wind", `${formatNumber(latest.wind_m_s, 2)}`); + updateText("live-gust", `${formatNumber(latest.wind_gust_m_s, 2)}`); + updateText("live-wdir", `${formatNumber(latest.wind_dir_deg, 0)}`); + updateText("live-uvi", `${formatNumber(latest.uvi, 2)}`); + updateText("live-lux", `${formatNumber(latest.light_lux, 0)}`); + } else { + updateText("live-temp", "--"); + updateText("live-rh", "--"); + updateText("live-wind", "--"); + updateText("live-gust", "--"); + updateText("live-wdir", "--"); + updateText("live-uvi", "--"); + updateText("live-lux", "--"); + } + + const forecastUrl = document.getElementById("forecast-url"); + if (forecastUrl) { + if (data.open_meteo_url) { + forecastUrl.href = data.open_meteo_url; + forecastUrl.textContent = data.open_meteo_url; + } else { + forecastUrl.textContent = "--"; + } + } + + const obs = data.observations || []; + const forecast = (data.forecast && data.forecast.points) || []; + + const obsTemps = obs.map((p) => p.temp_c); + const obsWinds = obs.map((p) => p.wind_m_s); + const obsGusts = obs.map((p) => p.wind_gust_m_s); + const obsRH = obs.map((p) => p.rh); + const obsUvi = obs.map((p) => p.uvi); + const obsLux = obs.map((p) => p.light_lux); + + const fcTemps = forecast.map((p) => p.temp_c); + const fcWinds = forecast.map((p) => p.wind_m_s); + const fcGusts = forecast.map((p) => p.wind_gust_m_s); + const fcRH = forecast.map((p) => p.rh); + const fcPrecip = forecast.map((p) => p.precip_mm); + + const obsTempSummary = minMax(obsTemps); + const obsWindSummary = minMax(obsWinds); + const obsUviSummary = minMax(obsUvi); + const obsLuxSummary = minMax(obsLux); + + const forecastTempSummary = minMax(fcTemps); + const forecastPrecipTotal = sum(fcPrecip); + + const obsParts = []; + if (obsTempSummary.min !== null) { + obsParts.push(`temp_c ${obsTempSummary.min.toFixed(1)} to ${obsTempSummary.max.toFixed(1)}`); + } + if (obsWindSummary.max !== null) { + obsParts.push(`wind_max ${obsWindSummary.max.toFixed(1)} m/s`); + } + if (obsUviSummary.max !== null) { + obsParts.push(`uvi_max ${obsUviSummary.max.toFixed(1)}`); + } + if (obsLuxSummary.max !== null) { + obsParts.push(`lux_max ${obsLuxSummary.max.toFixed(0)}`); + } + updateText("obs-summary", obsParts.length ? obsParts.join(" | ") : "--"); + + const forecastParts = []; + if (forecastTempSummary.min !== null) { + forecastParts.push(`temp_c ${forecastTempSummary.min.toFixed(1)} to ${forecastTempSummary.max.toFixed(1)}`); + } + if (forecastPrecipTotal !== null) { + forecastParts.push(`precip_total ${forecastPrecipTotal.toFixed(1)} mm`); + } + updateText("forecast-summary", forecastParts.length ? forecastParts.join(" | ") : "--"); + + updateText("commentary", buildCommentary(latest, forecast)); + + const tempChart = { + type: "line", + data: { + datasets: [ + { label: "obs temp C", data: series(obs, "temp_c"), borderColor: colors.obs }, + { label: "forecast temp C", data: series(forecast, "temp_c"), borderColor: colors.forecast }, + ], + }, + options: baseOptions(), + }; + upsertChart("chart-temp", tempChart); + + const windChart = { + type: "line", + data: { + datasets: [ + { label: "obs wind m/s", data: series(obs, "wind_m_s"), borderColor: colors.obs }, + { label: "obs gust m/s", data: series(obs, "wind_gust_m_s"), borderColor: colors.gust }, + { label: "forecast wind m/s", data: series(forecast, "wind_m_s"), borderColor: colors.forecast }, + { label: "forecast gust m/s", data: series(forecast, "wind_gust_m_s"), borderColor: "#f7d79f" }, + ], + }, + options: baseOptions(), + }; + upsertChart("chart-wind", windChart); + + const rhChart = { + type: "line", + data: { + datasets: [ + { label: "obs humidity %", data: series(obs, "rh"), borderColor: colors.humidity }, + { label: "forecast rh %", data: series(forecast, "rh"), borderColor: colors.forecast }, + ], + }, + options: baseOptions(), + }; + upsertChart("chart-rh", rhChart); + + const lightOptions = baseOptions(); + lightOptions.scales.y1 = { + position: "right", + ticks: { color: "#a4c4c4" }, + grid: { drawOnChartArea: false }, + }; + + const lightChart = { + type: "line", + data: { + datasets: [ + { label: "uvi", data: series(obs, "uvi"), borderColor: colors.uvi, yAxisID: "y" }, + { label: "light lux", data: series(obs, "light_lux"), borderColor: colors.light, yAxisID: "y1" }, + ], + }, + options: lightOptions, + }; + upsertChart("chart-light", lightChart); + + const precipChart = { + type: "bar", + data: { + datasets: [ + { label: "forecast precip mm", data: series(forecast, "precip_mm"), backgroundColor: colors.precip }, + ], + }, + options: baseOptions(), + }; + upsertChart("chart-precip", precipChart); +} + +function buildCommentary(latest, forecast) { + if (!latest || !forecast || !forecast.length) { + return "Waiting for forecast data..."; + } + + const obsTs = new Date(latest.ts); + if (Number.isNaN(obsTs.getTime())) { + return "No valid observation timestamp yet."; + } + + let nearest = null; + let bestDiff = null; + for (const p of forecast) { + if (!p.ts) continue; + const fcTs = new Date(p.ts); + if (Number.isNaN(fcTs.getTime())) continue; + const diff = Math.abs(obsTs - fcTs); + if (bestDiff === null || diff < bestDiff) { + bestDiff = diff; + nearest = p; + } + } + + if (!nearest || bestDiff > 2 * 60 * 60 * 1000) { + return "No nearby forecast point to compare yet."; + } + + const parts = []; + + if (latest.temp_c !== null && nearest.temp_c !== null) { + const delta = latest.temp_c - nearest.temp_c; + parts.push(`temp ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} C`); + } + if (latest.wind_m_s !== null && nearest.wind_m_s !== null) { + const delta = latest.wind_m_s - nearest.wind_m_s; + parts.push(`wind ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} m/s`); + } + if (latest.wind_gust_m_s !== null && nearest.wind_gust_m_s !== null) { + const delta = latest.wind_gust_m_s - nearest.wind_gust_m_s; + parts.push(`gust ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} m/s`); + } + if (latest.rh !== null && nearest.rh !== null) { + const delta = latest.rh - nearest.rh; + parts.push(`rh ${delta >= 0 ? "+" : ""}${delta.toFixed(0)} %`); + } + + if (!parts.length) { + return "Not enough data to compute deviation yet."; + } + + return `Now vs forecast: ${parts.join(", ")}`; +} + +async function loadAndRender() { + const params = new URLSearchParams({ + range: state.range, + bucket: state.bucket, + }); + try { + const resp = await fetch(`/api/dashboard?${params.toString()}`, { cache: "no-store" }); + if (!resp.ok) { + updateText("commentary", `Dashboard error ${resp.status}`); + return; + } + const data = await resp.json(); + renderDashboard(data); + } catch (err) { + updateText("commentary", "Dashboard fetch failed."); + } +} + +function setupControls() { + const buttons = document.querySelectorAll(".btn[data-range]"); + buttons.forEach((btn) => { + btn.addEventListener("click", () => { + buttons.forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + state.range = btn.dataset.range; + state.bucket = state.range === "6h" ? "1m" : "5m"; + loadAndRender(); + }); + }); +} + +document.addEventListener("DOMContentLoaded", () => { + setupControls(); + loadAndRender(); + if (state.timer) clearInterval(state.timer); + state.timer = setInterval(loadAndRender, 60 * 1000); +}); diff --git a/cmd/ingestd/web/index.html b/cmd/ingestd/web/index.html new file mode 100644 index 0000000..4743dfe --- /dev/null +++ b/cmd/ingestd/web/index.html @@ -0,0 +1,119 @@ + + + + + + Weatherstation Console + + + + + + +
+
+
+
Weatherstation Console
+
Loading...
+
+
+ + + +
+
+ +
+
+
+
Live Snapshot
+
--
+
+
+
+
Temp C
+
--
+
+
+
Humidity
+
--
+
+
+
Wind m/s
+
--
+
+
+
Gust m/s
+
--
+
+
+
Wind Dir deg
+
--
+
+
+
UVI
+
--
+
+
+
Light Lux
+
--
+
+
+ +
+
+
Deviation Commentary
+
Waiting for forecast data...
+
+
+
Observation Summary
+
--
+
+
+
Forecast Summary
+
--
+
+
+
Forecast Request
+
+ -- +
+
+
+
+ +
+
+
Metrics
+
--
+
+
+
+
Temperature (obs vs forecast)
+ +
+
+
Wind (obs vs forecast)
+ +
+
+
Humidity (obs vs forecast)
+ +
+
+
UV Index and Light
+ +
+
+
Precipitation (forecast)
+ +
+
+
+
+ + + + + + diff --git a/cmd/ingestd/web/styles.css b/cmd/ingestd/web/styles.css new file mode 100644 index 0000000..9b50dcf --- /dev/null +++ b/cmd/ingestd/web/styles.css @@ -0,0 +1,249 @@ +:root { + --bg: #0e1a21; + --bg-2: #162733; + --card: rgba(20, 39, 49, 0.85); + --line: rgba(123, 223, 242, 0.25); + --text: #e4f2f2; + --muted: #a4c4c4; + --accent: #7bdff2; + --accent-2: #f4b942; + --accent-3: #ff7d6b; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Space Grotesk", sans-serif; + color: var(--text); + background: var(--bg); + min-height: 100vh; +} + +.bg { + position: fixed; + inset: 0; + background: + radial-gradient(700px circle at 10% 20%, rgba(123, 223, 242, 0.18), transparent 45%), + radial-gradient(600px circle at 85% 15%, rgba(244, 185, 66, 0.2), transparent 40%), + radial-gradient(900px circle at 30% 80%, rgba(255, 125, 107, 0.12), transparent 50%), + linear-gradient(180deg, var(--bg), var(--bg-2)); + z-index: -1; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 28px 6vw 10px; + gap: 16px; +} + +.title { + font-size: clamp(22px, 3vw, 30px); + font-weight: 600; +} + +.subtitle { + color: var(--muted); + font-size: 14px; +} + +.controls { + display: flex; + gap: 10px; +} + +.btn { + border: 1px solid var(--line); + background: transparent; + color: var(--text); + padding: 8px 14px; + border-radius: 999px; + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.btn.active { + background: rgba(123, 223, 242, 0.15); + border-color: var(--accent); + color: var(--accent); +} + +.layout { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.6fr); + gap: 24px; + padding: 10px 6vw 48px; +} + +.panel { + background: var(--card); + border: 1px solid rgba(123, 223, 242, 0.15); + border-radius: 20px; + padding: 20px; + box-shadow: 0 18px 40px rgba(5, 12, 16, 0.35); + backdrop-filter: blur(12px); + animation: rise 0.6s ease both; +} + +.panel:nth-child(2) { + animation-delay: 0.1s; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 16px; + gap: 12px; +} + +.panel-title { + font-size: 18px; + font-weight: 600; +} + +.panel-meta { + color: var(--muted); + font-size: 12px; + font-family: "IBM Plex Mono", monospace; +} + +.live-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.metric { + padding: 12px; + border: 1px solid rgba(123, 223, 242, 0.12); + border-radius: 14px; + background: rgba(16, 31, 39, 0.6); +} + +.metric .label { + color: var(--muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.metric .value { + font-family: "IBM Plex Mono", monospace; + font-size: 18px; + margin-top: 6px; +} + +.callouts { + display: grid; + gap: 12px; + margin-top: 18px; +} + +.callout { + border: 1px solid rgba(123, 223, 242, 0.12); + border-radius: 14px; + padding: 12px; + background: rgba(14, 26, 33, 0.6); +} + +.callout-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); +} + +.callout-body { + margin-top: 6px; + font-size: 13px; + color: var(--muted); +} + +.mono { + font-family: "IBM Plex Mono", monospace; + color: var(--accent); + word-break: break-all; +} + +.charts { + min-height: 360px; +} + +.charts-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.chart-card { + background: rgba(10, 20, 26, 0.5); + border-radius: 16px; + padding: 12px; + border: 1px solid rgba(123, 223, 242, 0.1); + min-height: 240px; +} + +.chart-card.wide { + grid-column: span 2; +} + +.chart-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 8px; +} + +canvas { + width: 100%; + height: 210px; +} + +@media (max-width: 980px) { + .layout { + grid-template-columns: 1fr; + } + + .charts-grid { + grid-template-columns: 1fr; + } + + .chart-card.wide { + grid-column: span 1; + } +} + +@media (max-width: 640px) { + .topbar { + flex-direction: column; + align-items: flex-start; + } + + .live-grid { + grid-template-columns: 1fr; + } +} diff --git a/config.yaml b/config.yaml index 36d3f05..20e9fb8 100644 --- a/config.yaml +++ b/config.yaml @@ -19,6 +19,10 @@ pollers: interval: "30m" model: "ecmwf" +web: + enabled: true + listen: ":8080" + wunderground: enabled: true station_id: "ISSYDWXYZ123" diff --git a/docker-compose.yml b/docker-compose.yml index 70f770f..7ad8d20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,10 @@ services: depends_on: - timescaledb restart: unless-stopped + ports: + - "8080:8080" volumes: - ./config.yaml:/app/config.yaml:ro volumes: - tsdata: \ No newline at end of file + tsdata: diff --git a/internal/config/config.go b/internal/config/config.go index 290827d..8054c10 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,11 @@ type Config struct { } `yaml:"open_meteo"` } `yaml:"pollers"` + Web struct { + Enabled *bool `yaml:"enabled"` + Listen string `yaml:"listen"` + } `yaml:"web"` + Wunderground struct { Enabled bool `yaml:"enabled"` StationID string `yaml:"station_id"` @@ -77,6 +82,13 @@ func Load(path string) (*Config, error) { if c.Wunderground.Interval == 0 { c.Wunderground.Interval = 60 * time.Second } + if c.Web.Listen == "" { + c.Web.Listen = ":8080" + } + if c.Web.Enabled == nil { + enabled := true + c.Web.Enabled = &enabled + } // If enabled, require creds if c.Wunderground.Enabled && (c.Wunderground.StationID == "" || c.Wunderground.StationKey == "") { return nil, errors.New("wunderground enabled but station_id/station_key not set") diff --git a/internal/db/series.go b/internal/db/series.go new file mode 100644 index 0000000..a720c96 --- /dev/null +++ b/internal/db/series.go @@ -0,0 +1,220 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type ObservationPoint struct { + TS time.Time `json:"ts"` + TempC *float64 `json:"temp_c,omitempty"` + RH *float64 `json:"rh,omitempty"` + WindMS *float64 `json:"wind_m_s,omitempty"` + WindGustMS *float64 `json:"wind_gust_m_s,omitempty"` + WindDirDeg *float64 `json:"wind_dir_deg,omitempty"` + UVI *float64 `json:"uvi,omitempty"` + LightLux *float64 `json:"light_lux,omitempty"` +} + +type ForecastPoint struct { + TS time.Time `json:"ts"` + TempC *float64 `json:"temp_c,omitempty"` + RH *float64 `json:"rh,omitempty"` + PressureMSLH *float64 `json:"pressure_msl_hpa,omitempty"` + WindMS *float64 `json:"wind_m_s,omitempty"` + WindGustMS *float64 `json:"wind_gust_m_s,omitempty"` + WindDirDeg *float64 `json:"wind_dir_deg,omitempty"` + PrecipMM *float64 `json:"precip_mm,omitempty"` + CloudCover *float64 `json:"cloud_cover,omitempty"` +} + +type ForecastSeries struct { + RetrievedAt time.Time `json:"retrieved_at"` + Points []ForecastPoint `json:"points"` +} + +func (d *DB) ObservationSeries(ctx context.Context, site, bucket string, rangeSeconds int64) ([]ObservationPoint, error) { + if rangeSeconds <= 0 { + return nil, errors.New("range must be > 0") + } + + table := "cagg_ws90_5m" + switch bucket { + case "1m": + table = "cagg_ws90_1m" + case "5m": + table = "cagg_ws90_5m" + default: + return nil, fmt.Errorf("unsupported bucket: %s", bucket) + } + + query := fmt.Sprintf(` + SELECT + bucket, + temp_c_avg, + rh_avg, + wind_avg_ms_avg, + wind_gust_ms_max, + wind_dir_deg_avg, + uvi_max, + light_lux_max + FROM %s + WHERE site = $1 + AND bucket >= now() - make_interval(secs => $2) + ORDER BY bucket ASC + `, table) + + rows, err := d.Pool.Query(ctx, query, site, rangeSeconds) + if err != nil { + return nil, err + } + defer rows.Close() + + points := make([]ObservationPoint, 0, 512) + for rows.Next() { + var ( + ts time.Time + temp, rh, wind, gust sql.NullFloat64 + dir, uvi, light sql.NullFloat64 + ) + if err := rows.Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light); err != nil { + return nil, err + } + points = append(points, ObservationPoint{ + TS: ts, + TempC: nullFloatPtr(temp), + RH: nullFloatPtr(rh), + WindMS: nullFloatPtr(wind), + WindGustMS: nullFloatPtr(gust), + WindDirDeg: nullFloatPtr(dir), + UVI: nullFloatPtr(uvi), + LightLux: nullFloatPtr(light), + }) + } + if rows.Err() != nil { + return nil, rows.Err() + } + + return points, nil +} + +func (d *DB) LatestObservation(ctx context.Context, site string) (*ObservationPoint, error) { + query := ` + SELECT + ts, + temperature_c, + humidity, + wind_avg_m_s, + wind_max_m_s, + wind_dir_deg, + uvi, + light_lux + FROM observations_ws90 + WHERE site = $1 + ORDER BY ts DESC + LIMIT 1 + ` + + var ( + ts time.Time + temp, rh, wind, gust sql.NullFloat64 + dir, uvi, light sql.NullFloat64 + ) + err := d.Pool.QueryRow(ctx, query, site).Scan(&ts, &temp, &rh, &wind, &gust, &dir, &uvi, &light) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + + return &ObservationPoint{ + TS: ts, + TempC: nullFloatPtr(temp), + RH: nullFloatPtr(rh), + WindMS: nullFloatPtr(wind), + WindGustMS: nullFloatPtr(gust), + WindDirDeg: nullFloatPtr(dir), + UVI: nullFloatPtr(uvi), + LightLux: nullFloatPtr(light), + }, nil +} + +func (d *DB) ForecastSeriesLatest(ctx context.Context, site, model string) (ForecastSeries, error) { + var retrieved sql.NullTime + err := d.Pool.QueryRow(ctx, ` + SELECT max(retrieved_at) + FROM forecast_openmeteo_hourly + WHERE site = $1 AND model = $2 + `, site, model).Scan(&retrieved) + if err != nil { + return ForecastSeries{}, err + } + if !retrieved.Valid { + return ForecastSeries{}, nil + } + + rows, err := d.Pool.Query(ctx, ` + SELECT + ts, + temp_c, + rh, + pressure_msl_hpa, + wind_m_s, + wind_gust_m_s, + wind_dir_deg, + precip_mm, + cloud_cover + FROM forecast_openmeteo_hourly + WHERE site = $1 AND model = $2 AND retrieved_at = $3 + ORDER BY ts ASC + `, site, model, retrieved.Time) + if err != nil { + return ForecastSeries{}, err + } + defer rows.Close() + + points := make([]ForecastPoint, 0, 256) + for rows.Next() { + var ( + ts time.Time + temp, rh, msl, wind, gust sql.NullFloat64 + dir, precip, cloud sql.NullFloat64 + ) + if err := rows.Scan(&ts, &temp, &rh, &msl, &wind, &gust, &dir, &precip, &cloud); err != nil { + return ForecastSeries{}, err + } + points = append(points, ForecastPoint{ + TS: ts, + TempC: nullFloatPtr(temp), + RH: nullFloatPtr(rh), + PressureMSLH: nullFloatPtr(msl), + WindMS: nullFloatPtr(wind), + WindGustMS: nullFloatPtr(gust), + WindDirDeg: nullFloatPtr(dir), + PrecipMM: nullFloatPtr(precip), + CloudCover: nullFloatPtr(cloud), + }) + } + if rows.Err() != nil { + return ForecastSeries{}, rows.Err() + } + + return ForecastSeries{ + RetrievedAt: retrieved.Time, + Points: points, + }, nil +} + +func nullFloatPtr(v sql.NullFloat64) *float64 { + if !v.Valid { + return nil + } + val := v.Float64 + return &val +} diff --git a/internal/providers/openmeteo.go b/internal/providers/openmeteo.go index 23521c2..b290b58 100644 --- a/internal/providers/openmeteo.go +++ b/internal/providers/openmeteo.go @@ -17,34 +17,48 @@ type OpenMeteo struct { func (o *OpenMeteo) Name() string { return "open_meteo" } +var openMeteoHourlyFields = []string{ + "temperature_2m", + "pressure_msl", + "wind_speed_10m", + "wind_gusts_10m", + "wind_direction_10m", + "precipitation", + "cloud_cover", + "relative_humidity_1000hPa", +} + +func OpenMeteoRequestURL(site Site, model string) (string, error) { + u, err := url.Parse("https://api.open-meteo.com/v1/ecmwf") + if err != nil { + return "", err + } + q := u.Query() + q.Set("latitude", fmt.Sprintf("%.6f", site.Latitude)) + q.Set("longitude", fmt.Sprintf("%.6f", site.Longitude)) + q.Set("hourly", join(openMeteoHourlyFields)) + q.Set("wind_speed_unit", "ms") + q.Set("temperature_unit", "celsius") + q.Set("precipitation_unit", "mm") + u.RawQuery = q.Encode() + _ = model // endpoint is fixed to ECMWF; model is metadata only. + return u.String(), nil +} + func (o *OpenMeteo) Fetch(ctxDone <-chan struct{}, site Site, model string) (*ForecastResult, error) { if o.Client == nil { o.Client = &http.Client{Timeout: 15 * time.Second} } - // Hourly fields supported by the ECMWF endpoint. - hourly := []string{ - "temperature_2m", - "pressure_msl", - "wind_speed_10m", - "wind_gusts_10m", - "wind_direction_10m", - "precipitation", - "cloud_cover", - "relative_humidity_1000hPa", + reqURL, err := OpenMeteoRequestURL(site, model) + if err != nil { + return nil, err } - u, _ := url.Parse("https://api.open-meteo.com/v1/ecmwf") - q := u.Query() - q.Set("latitude", fmt.Sprintf("%.6f", site.Latitude)) - q.Set("longitude", fmt.Sprintf("%.6f", site.Longitude)) - q.Set("hourly", join(hourly)) - q.Set("wind_speed_unit", "ms") - q.Set("temperature_unit", "celsius") - q.Set("precipitation_unit", "mm") - u.RawQuery = q.Encode() - - safeURL := *u + safeURL, err := url.Parse(reqURL) + if err != nil { + return nil, err + } safeQuery := safeURL.Query() if safeQuery.Has("apikey") { safeQuery.Set("apikey", "redacted") @@ -56,7 +70,7 @@ func (o *OpenMeteo) Fetch(ctxDone <-chan struct{}, site Site, model string) (*Fo defer cancel() go func() { <-ctxDone; cancel() }() - req, _ := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + req, _ := http.NewRequestWithContext(ctx, "GET", reqURL, nil) resp, err := o.Client.Do(req) if err != nil { return nil, err