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 @@ + + +
+ + +