Explorar o código

dev: välistemperatuur (Open-Meteo) -> uus67.valisilm + veebigraafikul

- weather-collector: Open-Meteo tundipõhine (temp/niiskus/tuul/sademed), backfill + tunnipoll, aukude täitmine (taidetud)
- CH: uus67.valisilm (ReplacingMergeTree, ORDER BY hour)
- nutikodu-web: /api/valisilm + /api/latest.outside; graafikul roheline väli-joon + lüliti + kaart
- eraldi CH_WEATHER_TABLE (väldib .env CH_TABLE konflikti)
- LOEMIND + README uuendatud
ardo hai 1 mes
pai
achega
6c1d18a48a

+ 43 - 0
dev/LOEMIND.md

@@ -253,10 +253,53 @@ docker exec -it clickhouse clickhouse-client --user default --password dev_click
   Postgres-migratsioon on eeskujuks: `postgresql()` tabelifunktsioon lihtsustab oluliselt (Oracle'i jaoks
   tuleb vastav funktsioon/ODBC-vood eraldi uurida).
 
+## Välistemperatuur (`weather-collector`)
+
+Open-Meteo API-st tunniandmed (temperatuur, niiskus, tuul, sademed) → ClickHouse
+`uus67.valisilm`. Eraldi konteiner `weather-collector`.
+
+| Omadus | Väärtus |
+|--------|---------|
+| Kood | `./weather-collector/` (`collector.py`, `Dockerfile`, `requirements.txt`) |
+| Konteiner | `weather-collector` (püsiv, `restart: unless-stopped`, `mem_limit: 128m`) |
+| Allikas | Open-Meteo (`api.open-meteo.com`, `archive-api.open-meteo.com`) |
+| Asukoht | `LATITUDE` / `LONGITUDE` (vaikimisi 58.87009, 22.9479) |
+| Backfill | `BACKFILL_FROM` (vaikimisi 2026-09-17); archive + forecast API |
+| Intervall | `POLL_INTERVAL` (vaikimisi 1800 s; jookseb iga tunni +5 min) |
+| Tabel | `uus67.valisilm` |
+
+**Loogika:** käivitusel backfill; edasi tunnipoll, mis uuendab hiljutisi tunde.
+Puuduvad tunnid täidetakse eelmise väärtusega (`taidetud = true`) — auke ei jää.
+Tabel on `ReplacingMergeTree` (`ORDER BY hour`), päringud kasutavad `FINAL`.
+
+```sql
+CREATE TABLE uus67.valisilm (
+    hour        DateTime('Europe/Tallinn'),
+    temperature Nullable(Float32),
+    humidity    Nullable(Float32),
+    wind        Nullable(Float32),
+    rain        Nullable(Float32),
+    taidetud    Bool DEFAULT false
+) ENGINE = ReplacingMergeTree
+PARTITION BY toYYYYMM(hour)
+ORDER BY hour;
+```
+
+> **NB!** `.env`-is on `CH_TABLE=temperatuur` (zigbee koguja jaoks). Välikoguja
+> kasutab eraldi muutujat **`CH_WEATHER_TABLE`** (vaikimisi `valisilm`) — muidu
+> kirjutaks `.env` väärtus valesse tabelisse.
+
+```bash
+docker logs -f weather-collector
+docker exec clickhouse clickhouse-client --user default --password dev_clickhouse_2026 \
+  --query "SELECT * FROM uus67.valisilm ORDER BY hour DESC LIMIT 24"
+```
+
 ## Nutikodu veebiliides (`nutikodu-web`)
 
 Interaktiivne ajatelg `uus67` skeemi zigbee andurite andmetele. FastAPI backend +
 üks staatiline leht (ECharts), eestikeelne, mobiilisõbralik, tume/hele teema.
+Graafikul kuvatakse ka **välistemperatuur** rohelise joonena (`/api/valisilm`).
 
 | Omadus | Väärtus |
 |--------|---------|

+ 20 - 0
dev/docker-compose.yml

@@ -50,6 +50,26 @@ services:
     networks:
       - main_ai_network
 
+  # Välistemperatuuri koguja (Open-Meteo): backfill + tunnipoll -> uus67.valisilm.
+  weather-collector:
+    build: ./weather-collector
+    container_name: weather-collector
+    restart: unless-stopped
+    mem_limit: 128m
+    env_file:
+      - .env
+    environment:
+      - TZ=Europe/Tallinn
+      - CH_URL=${CH_URL:-http://clickhouse:8123}
+      - CH_DB=${CH_DB:-uus67}
+      - CH_USER=${CH_USER:-zigbee}
+      - CH_WEATHER_TABLE=${CH_WEATHER_TABLE:-valisilm}
+      - BACKFILL_FROM=${BACKFILL_FROM:-2026-09-17}
+    depends_on:
+      - clickhouse
+    networks:
+      - main_ai_network
+
   # Nutikodu veebiliides: uus67 zigbee andmete interaktiivne ajatelg (FastAPI + ECharts).
   # Ligipääs ainult Tailscale'ist: http://100.87.1.24:8095
   nutikodu-web:

+ 3 - 0
dev/nutikodu-web/README.md

@@ -19,6 +19,8 @@ tume/hele teema.
 - **Täidetud tunnid** (kui andur ei olnud kättesaadav) on koguja poolt eelmise
   väärtusega asendatud (`taidetud = true`) ja kuvatakse graafikul **tühjade
   ringidena** — aegreas ei teki auke.
+- **Välistemperatuur** (eraldi koguja, Open-Meteo) kuvatakse samal graafikul
+  rohelise joonena; niiskus/tuul/sademed salvestatakse andmebaasi.
 - Viimaste väärtuste kaardid + lahtiste häirete ülevaade (60 s värskendus).
 - PWA-manifest (installitav telefonis), `noindex`.
 
@@ -28,6 +30,7 @@ tume/hele teema.
 |----------|-----------|
 | `GET /api/meta` | seadmed, tz, andmete ulatus |
 | `GET /api/temperatuur?from=&to=&device=&agg=hour\|day` | aegrida |
+| `GET /api/valisilm?from=&to=&agg=hour\|day` | välistemperatuuri aegrida (temp, niiskus, tuul, sademed) |
 | `GET /api/ukseandur?from=&to=&device=` | ukseolekute sündmused (UI tuleb hiljem) |
 | `GET /api/vead?from=&to=&device=` | häirete sündmused |
 | `GET /api/latest` | viimased väärtused + lahtised häired |

+ 75 - 0
dev/nutikodu-web/app.py

@@ -172,6 +172,53 @@ async def temperatuur(
     return {"from": from_s, "to": to_s, "agg": agg, "series": series}
 
 
+@app.get("/api/valisilm", dependencies=[Depends(check_token)])
+async def valisilm(
+    from_: str | None = Query(None, alias="from"),
+    to: str | None = Query(None),
+    agg: str = Query("hour", pattern="^(hour|day)$"),
+) -> dict:
+    from_s, to_s = resolve_range(from_, to)
+    params = {"from": from_s, "to": to_s}
+    where = (
+        f"hour BETWEEN toDateTime({{from:String}}, 'Europe/Tallinn') "
+        f"AND toDateTime({{to:String}}, 'Europe/Tallinn')"
+    )
+    if agg == "day":
+        select = (
+            "toUInt64(toUnixTimestamp(toStartOfDay(hour))) * 1000 AS t, "
+            "round(avg(temperature), 2) AS temperature, round(avg(humidity), 2) AS humidity, "
+            "round(avg(wind), 2) AS wind, round(sum(rain), 2) AS rain, max(taidetud) AS taidetud"
+        )
+        group = "GROUP BY t"
+    else:
+        select = (
+            "toUInt64(toUnixTimestamp(hour)) * 1000 AS t, "
+            "temperature, humidity, wind, rain, taidetud"
+        )
+        group = ""
+    rows = ch_query(
+        f"SELECT {select} FROM {CH_DB}.valisilm FINAL WHERE {where} {group} ORDER BY t",
+        params,
+    )
+    series: dict[str, list] = {
+        "t": [],
+        "temperature": [],
+        "humidity": [],
+        "wind": [],
+        "rain": [],
+        "taidetud": [],
+    }
+    for row in rows:
+        series["t"].append(int(row["t"]))
+        series["temperature"].append(row["temperature"])
+        series["humidity"].append(row["humidity"])
+        series["wind"].append(row["wind"])
+        series["rain"].append(row["rain"])
+        series["taidetud"].append(bool(row.get("taidetud")))
+    return {"from": from_s, "to": to_s, "agg": agg, "series": series}
+
+
 @app.get("/api/ukseandur", dependencies=[Depends(check_token)])
 async def ukseandur(
     from_: str | None = Query(None, alias="from"),
@@ -254,6 +301,33 @@ async def latest() -> dict:
         f"toString(max(ts)) AS ts_str, argMax(olek, ts) AS olek_now "
         f"FROM {CH_DB}.vead GROUP BY device, tyyp HAVING olek_now = 'aktiveeritud'"
     )
+    outside_rows = ch_query(
+        f"SELECT toString(max(hour)) AS ts_str, "
+        f"argMax(temperature, hour) AS temperature, "
+        f"argMax(humidity, hour) AS humidity, "
+        f"argMax(wind, hour) AS wind, "
+        f"argMax(rain, hour) AS rain, "
+        f"toUInt64(toUnixTimestamp(max(hour))) * 1000 AS t "
+        f"FROM {CH_DB}.valisilm FINAL"
+    )
+    outside: dict = {
+        "temperature": None,
+        "humidity": None,
+        "wind": None,
+        "rain": None,
+        "hour": None,
+        "t": None,
+    }
+    if outside_rows:
+        r = outside_rows[0]
+        outside = {
+            "temperature": r.get("temperature"),
+            "humidity": r.get("humidity"),
+            "wind": r.get("wind"),
+            "rain": r.get("rain"),
+            "hour": r.get("ts_str"),
+            "t": int(r["t"]) if r.get("t") is not None else None,
+        }
     return {
         "readings": [
             {
@@ -269,6 +343,7 @@ async def latest() -> dict:
             {"device": r["device"], "tyyp": r["tyyp"], "detail": r["detail_now"], "ts": r["ts_str"]}
             for r in faults
         ],
+        "outside": outside,
     }
 
 

+ 52 - 4
dev/nutikodu-web/static/app.js

@@ -7,10 +7,12 @@ const state = {
   device: "",
   showTemp: true,
   showHum: true,
+  showOut: true,
   meta: null,
 };
 
 let chart = null;
+let currentData = { series: {} };
 
 const $ = (sel) => document.querySelector(sel);
 
@@ -54,6 +56,7 @@ function buildOption(data) {
   const border = cssVar("--border");
   const tempColor = cssVar("--temp");
   const humColor = cssVar("--hum");
+  const outColor = cssVar("--out");
   const series = [];
   const palette = ["#1f6feb", "#e8590c", "#2fb344", "#9c36b5", "#f08c00", "#0ca678"];
   const devices = Object.keys(data.series).sort();
@@ -107,6 +110,35 @@ function buildOption(data) {
     }
   }
 
+  const out = data.outside;
+  if (state.showOut && out && Array.isArray(out.t) && out.t.length) {
+    series.push({
+      name: "Väli · °C",
+      type: "line",
+      yAxisIndex: 0,
+      showSymbol: false,
+      smooth: 0.25,
+      lineStyle: { width: 2, color: outColor },
+      itemStyle: { color: outColor },
+      emphasis: { focus: "series" },
+      data: out.t.map((t, i) => [t, out.temperature[i]]),
+    });
+    if (Array.isArray(out.taidetud) && out.taidetud.some(Boolean)) {
+      series.push({
+        name: "Väli · täidetud",
+        type: "scatter",
+        yAxisIndex: 0,
+        symbol: "emptyCircle",
+        symbolSize: 9,
+        itemStyle: { color: outColor, borderWidth: 2 },
+        z: 3,
+        data: out.t
+          .map((t, i) => (out.taidetud[i] ? [t, out.temperature[i]] : null))
+          .filter(Boolean),
+      });
+    }
+  }
+
   return {
     backgroundColor: "transparent",
     animationDuration: 300,
@@ -199,9 +231,20 @@ async function load() {
   if (state.device) params.set("device", state.device);
   setStatus(null);
   try {
-    const res = await fetch(`/api/temperatuur?${params}`);
-    if (!res.ok) throw new Error(await res.text());
-    const data = await res.json();
+    const [tempRes, outRes] = await Promise.all([
+      fetch(`/api/temperatuur?${params}`),
+      fetch(`/api/valisilm?${params}`),
+    ]);
+    if (!tempRes.ok) throw new Error(await tempRes.text());
+    const data = await tempRes.json();
+    if (outRes.ok) {
+      try {
+        data.outside = (await outRes.json()).series;
+      } catch {
+        data.outside = null;
+      }
+    }
+    currentData = data;
     currentSeries = data.series;
     render(data);
     setStatus(true);
@@ -250,6 +293,10 @@ function renderCards(data) {
     cards.push(`<div class="card hum"><div class="k">💧 ${r.device} · niiskus</div>
       <div class="v">${r.humidity ?? "–"}<small> %</small></div></div>`);
   }
+  if (data.outside && data.outside.temperature != null) {
+    cards.push(`<div class="card out"><div class="k">🌤️ Väli · temperatuur</div>
+      <div class="v">${data.outside.temperature}<small> °C</small></div></div>`);
+  }
   if (data.open_faults.length) {
     const items = data.open_faults
       .map((f) => `<div>⚠️ <b>${f.device}</b> · ${f.tyyp}${f.detail ? " · " + f.detail : ""}</div>`)
@@ -266,7 +313,7 @@ function setTheme(theme) {
   $("#theme-toggle").textContent = theme === "dark" ? "☀️" : "🌙";
   const meta = $("#meta-theme");
   if (meta) meta.setAttribute("content", theme === "dark" ? "#0e1116" : "#1f6feb");
-  if (chart) chart.setOption(buildOption({ series: currentSeries }), true);
+  if (chart) chart.setOption(buildOption(currentData), true);
 }
 
 let currentSeries = {};
@@ -298,6 +345,7 @@ function init() {
 
   $("#show-temp").addEventListener("change", (e) => { state.showTemp = e.target.checked; load(); });
   $("#show-hum").addEventListener("change", (e) => { state.showHum = e.target.checked; load(); });
+  $("#show-out").addEventListener("change", (e) => { state.showOut = e.target.checked; load(); });
   $("#apply").addEventListener("click", () => {
     state.from = $("#from").value;
     state.to = $("#to").value;

+ 1 - 0
dev/nutikodu-web/static/index.html

@@ -47,6 +47,7 @@
         <div class="toggles">
           <label class="chk"><input type="checkbox" id="show-temp" checked /> <span>Temperatuur</span></label>
           <label class="chk"><input type="checkbox" id="show-hum" checked /> <span>Niiskus</span></label>
+          <label class="chk"><input type="checkbox" id="show-out" checked /> <span>Väli</span></label>
           <div class="seg" id="agg">
             <button type="button" data-agg="hour" class="active">Tund</button>
             <button type="button" data-agg="day">Päev</button>

+ 3 - 0
dev/nutikodu-web/static/style.css

@@ -8,6 +8,7 @@
   --accent-soft: #e7f0ff;
   --temp: #e8590c;
   --hum: #1f9ed4;
+  --out: #2fb344;
   --ok: #2fb344;
   --warn: #e0a400;
   --err: #d64545;
@@ -23,6 +24,7 @@
   --accent-soft: #1b2a44;
   --temp: #ff8a4c;
   --hum: #56c3ee;
+  --out: #45d06a;
   --ok: #45d06a;
   --warn: #f0c000;
   --err: #ff6b6b;
@@ -76,6 +78,7 @@ main > * { min-width: 0; }
 .card .v small { font-size: 14px; font-weight: 500; color: var(--muted); }
 .card.temp .v { color: var(--temp); }
 .card.hum .v { color: var(--hum); }
+.card.out .v { color: var(--out); }
 .card.wide { grid-column: 1 / -1; }
 
 .panel {

+ 10 - 0
dev/weather-collector/Dockerfile

@@ -0,0 +1,10 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY collector.py .
+
+CMD ["python", "-u", "collector.py"]

+ 281 - 0
dev/weather-collector/collector.py

@@ -0,0 +1,281 @@
+#!/usr/bin/env python3
+"""Välistemperatuuri koguja (Open-Meteo).
+
+Toob Open-Meteo API-st tunniandmed (temperatuur, niiskus, tuul, sademed)
+asukoha jaoks (vaikimisi Suuremõisa, Hiiumaa) ja kirjutab need ClickHouse'i
+tabelisse ``uus67.valisilm``.
+
+- Käivitusel backfill alates ``BACKFILL_FROM`` (vaikimisi 2026-09-17).
+- Edasi iga ``POLL_INTERVAL`` sekundi järel uuendab viimaseid tunde
+  (Open-Meteo uuendab hiljutisi väärtusi) ja täidab vahelejäänud tunnid.
+- Auke ei jäta: kui mõne tunni andmed puuduvad, kasutab eelmise teadaoleva
+  tunni väärtust ja märgib ``taidetud = true``.
+
+Tabel on ReplacingMergeTree (ORDER BY hour) — sama tunni uuesti kirjutamine
+asendab varasema rea. Päringud peaksid kasutama ``FINAL``.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import signal
+import time
+from datetime import date, datetime, timedelta
+from typing import Any
+from zoneinfo import ZoneInfo
+
+import requests
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)s %(message)s",
+)
+LOG = logging.getLogger("weather-collector")
+
+TZ = ZoneInfo(os.environ.get("TZ_NAME", "Europe/Tallinn"))
+
+LATITUDE = float(os.environ.get("LATITUDE", "58.87009"))
+LONGITUDE = float(os.environ.get("LONGITUDE", "22.9479"))
+BACKFILL_FROM = os.environ.get("BACKFILL_FROM", "2026-09-17")
+POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "1800"))
+
+FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
+ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive"
+HOURLY_VARS = "temperature_2m,relative_humidity_2m,wind_speed_10m,precipitation"
+
+CH_URL = os.environ.get("CH_URL", "http://clickhouse:8123").rstrip("/")
+CH_USER = os.environ.get("CH_USER", "zigbee")
+CH_PASS = os.environ.get("CH_PASS", "")
+CH_DB = os.environ.get("CH_DB", "uus67")
+CH_TABLE = os.environ.get("CH_WEATHER_TABLE", "valisilm")
+
+MAX_PAST_DAYS = 92
+
+
+def current_hour(now: datetime | None = None) -> datetime:
+    now = now or datetime.now(TZ)
+    return now.replace(minute=0, second=0, microsecond=0)
+
+
+def parse_dt(value: str) -> datetime:
+    return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=TZ)
+
+
+def _num(value: Any) -> float | None:
+    if isinstance(value, bool) or not isinstance(value, (int, float)):
+        return None
+    return float(value)
+
+
+def _parse_hourly(payload: dict) -> dict[datetime, dict[str, float | None]]:
+    hourly = payload.get("hourly") or {}
+    times = hourly.get("time") or []
+    temps = hourly.get("temperature_2m") or []
+    hums = hourly.get("relative_humidity_2m") or []
+    winds = hourly.get("wind_speed_10m") or []
+    rains = hourly.get("precipitation") or []
+    out: dict[datetime, dict[str, float | None]] = {}
+    for i, ts in enumerate(times):
+        dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M").replace(tzinfo=TZ)
+        out[dt] = {
+            "temperature": _num(temps[i]) if i < len(temps) else None,
+            "humidity": _num(hums[i]) if i < len(hums) else None,
+            "wind": _num(winds[i]) if i < len(winds) else None,
+            "rain": _num(rains[i]) if i < len(rains) else None,
+        }
+    return out
+
+
+def fetch_forecast(past_days: int) -> dict[datetime, dict[str, float | None]]:
+    params = {
+        "latitude": LATITUDE,
+        "longitude": LONGITUDE,
+        "hourly": HOURLY_VARS,
+        "timezone": "Europe/Tallinn",
+        "past_days": max(1, min(past_days, MAX_PAST_DAYS)),
+        "forecast_days": 1,
+        "wind_speed_unit": "kmh",
+    }
+    resp = requests.get(FORECAST_URL, params=params, timeout=30)
+    resp.raise_for_status()
+    return _parse_hourly(resp.json())
+
+
+def fetch_archive(start: date, end: date) -> dict[datetime, dict[str, float | None]]:
+    params = {
+        "latitude": LATITUDE,
+        "longitude": LONGITUDE,
+        "start_date": start.isoformat(),
+        "end_date": end.isoformat(),
+        "hourly": HOURLY_VARS,
+        "timezone": "Europe/Tallinn",
+        "wind_speed_unit": "kmh",
+    }
+    resp = requests.get(ARCHIVE_URL, params=params, timeout=30)
+    resp.raise_for_status()
+    return _parse_hourly(resp.json())
+
+
+def gather(start: datetime, now: datetime) -> dict[datetime, dict[str, float | None]]:
+    """Kogu API-andmed vahemikus [start, now] (lõpp kaasatud)."""
+    today = now.replace(hour=0, minute=0, second=0, microsecond=0)
+    api: dict[datetime, dict[str, float | None]] = {}
+    if start < today:
+        archive_end = today - timedelta(days=1)
+        try:
+            api.update(fetch_archive(start.date(), archive_end.date()))
+        except Exception:
+            LOG.exception("Open-Meteo archive päring ebaõnnestus")
+    past_days = max(1, (today - start).days + 1)
+    try:
+        api.update(fetch_forecast(past_days))
+    except Exception:
+        LOG.exception("Open-Meteo forecast päring ebaõnnestus")
+    return {h: v for h, v in api.items() if start <= h <= now}
+
+
+def build_rows(
+    api: dict[datetime, dict[str, float | None]],
+    start: datetime,
+    end: datetime,
+) -> list[dict[str, Any]]:
+    """Ehita tunniread, täites puuduvad tunnid eelmise väärtusega."""
+    rows: list[dict[str, Any]] = []
+    last: dict[str, float | None] | None = None
+    hour = start
+    while hour <= end:
+        values = api.get(hour)
+        has_data = values is not None and values.get("temperature") is not None
+        if has_data:
+            last = values
+            rows.append(_row(hour, values, False))
+        elif last is not None:
+            rows.append(_row(hour, last, True))
+        hour += timedelta(hours=1)
+    return rows
+
+
+def _row(hour: datetime, values: dict[str, float | None], taidetud: bool) -> dict[str, Any]:
+    return {
+        "hour": hour.strftime("%Y-%m-%d %H:%M:%S"),
+        "temperature": values.get("temperature"),
+        "humidity": values.get("humidity"),
+        "wind": values.get("wind"),
+        "rain": values.get("rain"),
+        "taidetud": taidetud,
+    }
+
+
+def ch_query(query: str) -> str:
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={"database": CH_DB, "query": query},
+        auth=(CH_USER, CH_PASS),
+        timeout=20,
+    )
+    resp.raise_for_status()
+    return resp.text
+
+
+def load_last_hour() -> datetime | None:
+    try:
+        text = ch_query(
+            f"SELECT toString(max(hour)) AS mh FROM {CH_DB}.{CH_TABLE} FORMAT JSONEachRow"
+        ).strip()
+        if text:
+            obj = json.loads(text.splitlines()[0])
+            if obj.get("mh"):
+                return datetime.strptime(obj["mh"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=TZ)
+    except Exception:
+        LOG.exception("Viimase tunni laadimine ebaõnnestus")
+    return None
+
+
+def insert_rows(rows: list[dict[str, Any]]) -> None:
+    if not rows:
+        return
+    body = "\n".join(json.dumps(r, ensure_ascii=False) for r in rows)
+    resp = requests.post(
+        f"{CH_URL}/",
+        params={
+            "database": CH_DB,
+            "query": f"INSERT INTO {CH_DB}.{CH_TABLE} FORMAT JSONEachRow",
+        },
+        data=body.encode("utf-8"),
+        auth=(CH_USER, CH_PASS),
+        timeout=30,
+    )
+    resp.raise_for_status()
+    LOG.info("ClickHouse'i (%s) kirjutatud %d rida", CH_TABLE, len(rows))
+
+
+def run_once(backfill_start: datetime, full: bool) -> None:
+    now = current_hour()
+    if full:
+        start = backfill_start
+    else:
+        last = load_last_hour()
+        start = backfill_start if last is None else max(backfill_start, last - timedelta(days=2))
+    if start > now:
+        start = now
+    api = gather(start, now)
+    rows = build_rows(api, start, now)
+    insert_rows(rows)
+    LOG.info(
+        "Vahemik %s .. %s, API ridu %d, kirjutatud %d",
+        start.strftime("%Y-%m-%d %H:%M"),
+        now.strftime("%Y-%m-%d %H:%M"),
+        len(api),
+        len(rows),
+    )
+
+
+def main() -> int:
+    running = {"stop": False}
+
+    def shutdown(signum, frame):
+        LOG.info("Lõpetamissignaal (%s), sulgen", signum)
+        running["stop"] = True
+
+    signal.signal(signal.SIGTERM, shutdown)
+    signal.signal(signal.SIGINT, shutdown)
+
+    backfill_start = parse_dt(BACKFILL_FROM).replace(hour=0, minute=0, second=0, microsecond=0)
+    LOG.info(
+        "Välistemperatuuri koguja: lat=%.5f lon=%.5f, backfill alates %s, intervall %ds",
+        LATITUDE,
+        LONGITUDE,
+        backfill_start.strftime("%Y-%m-%d"),
+        POLL_INTERVAL,
+    )
+
+    # Käivitusel täisbackfill (kuni aegunud aukudeni)
+    try:
+        run_once(backfill_start, full=True)
+    except Exception:
+        LOG.exception("Backfill ebaõnnestus, jätkan pollimisega")
+
+    while not running["stop"]:
+        now = datetime.now(TZ)
+        nxt = now.replace(minute=5, second=0, microsecond=0)
+        if nxt <= now:
+            nxt += timedelta(hours=1)
+        wait_s = min(POLL_INTERVAL, max(30.0, (nxt - now).total_seconds()))
+        waited = 0.0
+        while waited < wait_s and not running["stop"]:
+            time.sleep(min(5.0, wait_s - waited))
+            waited += 5.0
+        if running["stop"]:
+            break
+        try:
+            run_once(backfill_start, full=False)
+        except Exception:
+            LOG.exception("Tunnipoll ebaõnnestus, proovin hiljem uuesti")
+
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 1 - 0
dev/weather-collector/requirements.txt

@@ -0,0 +1 @@
+requests>=2.31