| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281 |
- #!/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())
|