#!/usr/bin/env python3 """ fetch_3a.py - fetch stations, sensors and weather data from the 3A API (https://api.3acloud.it, spec in 3a_api_dump/spec__serve_docs_base.json). python fetch_3a.py overview # stations count, sensors, columns, date ranges python fetch_3a.py stations # list of stations (+ count) python fetch_3a.py sensors # hourly/daily sensors per station python fetch_3a.py catalogue # ask again (3 requests), rewrite 3a_stations_sensors.json python fetch_3a.py columns # all hourly/daily column names python fetch_3a.py daily --from 2024-01-01 --to 2024-12-31 python fetch_3a.py hourly --from 2024-01-01 --to 2024-01-31 --stations 4,46 \ --columns CodiceStazione,Stazione,Timestamp,TemperaturaAriaMedia,Pioggia options: --stations 1,2,3 (default: all stations) --columns a,b,c (default: all available columns) --chunk-days N (split long periods into N-day requests) --refresh (ignore cached metadata and ask the server again) How auth works (from the spec): POST /login with headers name, password, totp -> token every other call sends header token: Credentials are not stored in this file: THREEA_USER username (default: mohsen) THREEA_PASSWORD password (else asked with a hidden prompt) THREEA_TOTP_SECRET optional base32 TOTP secret -> codes generated automatically (else you are asked to paste the 6-digit code when a login is needed) The token is cached in 3a_data/.token, so you only need a new TOTP when it expires. The server allows only 5 requests per 15 minutes; the script waits automatically. Results are written to 3a_data/ as .json and .csv. """ import argparse import getpass import json import os import sys import time from datetime import datetime, timedelta from pathlib import Path import pandas as pd import requests BASE_URL = "https://api.3acloud.it" OUT = Path(__file__).resolve().parent / "3a_data" OUT.mkdir(exist_ok=True) TOKEN_FILE = OUT / ".token" TIMEOUT = 120 sys.stdout.reconfigure(encoding="utf-8", errors="replace") session = requests.Session() session.headers["output-fmt"] = "json" _password = None # ─────────────────────────── rate limit ─────────────────────────── _rate = {"remaining": None, "reset": None} def _note_rate_limit(resp): h = resp.headers if "X-RateLimit-Remaining" in h: _rate["remaining"] = int(h["X-RateLimit-Remaining"]) if "X-RateLimit-Reset" in h: _rate["reset"] = int(h["X-RateLimit-Reset"]) def _wait_until(epoch, why): while True: left = int(epoch - time.time()) + 2 if left <= 0: break print(f"\r {why}: waiting {left // 60:02d}:{left % 60:02d} ", end="", flush=True) time.sleep(min(left, 5)) print("\r" + " " * 60 + "\r", end="") def _respect_rate_limit(): if _rate["remaining"] == 0 and _rate["reset"] and _rate["reset"] > time.time(): _wait_until(_rate["reset"], "rate limit (5 requests / 15 min)") def _send(method, path, **kw): """One HTTP request, waiting for the rate-limit window if needed.""" while True: _respect_rate_limit() r = session.request(method, BASE_URL + path, timeout=TIMEOUT, **kw) _note_rate_limit(r) if r.status_code == 429: reset = _rate["reset"] or time.time() + 15 * 60 _wait_until(reset, "server said 429 Too Many Requests") continue return r # ─────────────────────────── auth ─────────────────────────── CRED_FILE = Path(__file__).resolve().parent / "3a_credentials.json" def _cred(key, env): """Value from the environment, else from 3a_credentials.json (if filled in).""" if os.environ.get(env): return os.environ[env] if CRED_FILE.exists(): value = json.loads(CRED_FILE.read_text(encoding="utf-8")).get(key, "") if value and not value.startswith("PUT_"): return value return None def _ask_totp(): secret = _cred("totp_secret", "THREEA_TOTP_SECRET") if secret: import pyotp # pip install pyotp return pyotp.TOTP(secret.replace(" ", "")).now() while True: code = input("Paste a fresh 6-digit TOTP code (it changes every 30 s): ").strip().replace(" ", "") if code.isdigit() and len(code) == 6: return code print(" That doesn't look like a 6-digit code, try again.") def _extract_token(resp): try: data = resp.json() except ValueError: return resp.text.strip().strip('"') or None if isinstance(data, str): return data if isinstance(data, dict): for k in ("token", "access_token", "Token"): if k in data: return data[k] return None def login(): global _password user = _cred("user", "THREEA_USER") or "mohsen" if _password is None: _password = _cred("password", "THREEA_PASSWORD") or getpass.getpass( f"Password for {user} (typing is hidden, press Enter when done): ") for attempt in range(3): totp = _ask_totp() r = _send("POST", "/login", headers={"name": user, "password": _password, "totp": totp}) token = _extract_token(r) if r.ok else None if token: session.headers["token"] = token TOKEN_FILE.write_text(token) print(" ✓ logged in, token cached") return print(f" ✗ login failed ({r.status_code}): {r.text[:200]}") if r.status_code not in (401, 403): break print(" The TOTP may have expired or been mistyped.") sys.exit("Could not log in.") def _load_cached_token(): if TOKEN_FILE.exists(): session.headers["token"] = TOKEN_FILE.read_text().strip() return True return False def call(method, path, body=None): """Authenticated API call; logs in (asking for a TOTP) only when needed.""" if "token" not in session.headers and not _load_cached_token(): login() r = _send(method, path, json=body) if r.status_code in (401, 403): print(f" token rejected ({r.status_code}: {r.text[:80]}) -> new login needed") TOKEN_FILE.unlink(missing_ok=True) session.headers.pop("token", None) login() r = _send(method, path, json=body) if not r.ok: sys.exit(f"{method} {path} failed ({r.status_code}): {r.text[:500]}") try: return r.json() except ValueError: return r.text # ─────────────────────────── saving ─────────────────────────── def save(name, data): (OUT / f"{name}.json").write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") df = to_frame(data) if df is not None: df.to_csv(OUT / f"{name}.csv", index=False, encoding="utf-8-sig") return df def to_frame(data): if isinstance(data, list) and data and all(isinstance(x, dict) for x in data): return pd.json_normalize(data) if isinstance(data, dict): # e.g. {"data": [...]} or {station_id: [...]} lists = {k: v for k, v in data.items() if isinstance(v, list)} if len(lists) == 1: return to_frame(next(iter(lists.values()))) if lists and all(not isinstance(x, dict) for v in lists.values() for x in v): return pd.DataFrame([(k, x) for k, v in lists.items() for x in v], columns=["key", "value"]) return None def cached(name, method, path, body=None, refresh=False): """Metadata changes rarely and requests are scarce, so reuse what's on disk.""" f = OUT / f"{name}.json" if f.exists() and not refresh: return json.loads(f.read_text(encoding="utf-8")) print(f"→ {method} {path}") data = call(method, path, body) save(name, data) return data # ─────────────────────────── commands ─────────────────────────── def get_stations(refresh=False): data = cached("stations", "GET", "/getAvailableStations", refresh=refresh) df = to_frame(data) return data, df def station_ids(args): if args.stations: return [int(s) for s in args.stations.split(",")] _, df = get_stations(args.refresh) col = next((c for c in ("StationId", "id") if df is not None and c in df.columns), None) if col is None: sys.exit("Could not read station ids from stations.json; pass --stations explicitly.") return sorted(int(i) for i in df[col]) def cmd_stations(args): data, df = get_stations(args.refresh) if df is None: print(json.dumps(data, indent=2, ensure_ascii=False)[:3000]) return print(f"\nNumber of stations: {len(df)}") with pd.option_context("display.max_rows", 500, "display.width", 200): print(df.to_string(index=False)) print(f"\nSaved → {OUT / 'stations.csv'}") def cmd_sensors(args): ids = station_ids(args) for kind in ("Hourly", "Daily"): data = cached(f"sensors_{kind.lower()}", "POST", f"/getAvailableStation{kind}Sensors", {"stationsIds": ids}, args.refresh) print(f"\n{kind} sensors:") print(json.dumps(data, indent=1, ensure_ascii=False)[:4000]) print(f" saved → {OUT / f'sensors_{kind.lower()}.json'}") def cmd_summary(args): """Station x sensor tables built from stations.json + sensors_*.json.""" _, stations = get_stations(args.refresh) names = dict(zip(stations["StationId"].astype(int), stations["StationName"])) rows = [] for kind in ("Hourly", "Daily"): data = cached(f"sensors_{kind.lower()}", "POST", f"/getAvailableStation{kind}Sensors", {"stationsIds": station_ids(args)}, args.refresh) items = data if isinstance(data, list) else [data] for item in items: for sid, sensors in item.items(): for s in sensors: rows.append({"StationId": int(sid), "StationName": names.get(int(sid), ""), "Resolution": kind.lower(), "Sensor": s.get("name"), "Description": s.get("description"), "Unit": s.get("unit")}) long = pd.DataFrame(rows).sort_values(["StationId", "Resolution", "Sensor"]) long.to_csv(OUT / "station_sensors_list.csv", index=False, encoding="utf-8-sig") hourly = long[long.Resolution == "hourly"] matrix = pd.crosstab([hourly.StationId, hourly.StationName], hourly.Sensor).clip(upper=1) matrix = matrix.reindex(pd.MultiIndex.from_arrays( [stations["StationId"].astype(int), stations["StationName"]]), fill_value=0) matrix.insert(0, "NumSensors", matrix.sum(axis=1)) matrix.to_csv(OUT / "station_sensors_matrix_hourly.csv", encoding="utf-8-sig") with pd.option_context("display.max_rows", 500, "display.width", 250): print(f"\n{len(stations)} stations. Hourly sensors per station (1 = present):\n") print(matrix.to_string()) print("\nSensor catalogue (how many stations have each, hourly / daily):\n") cat = long.groupby(["Sensor", "Description", "Unit", "Resolution"]).StationId.nunique() print(cat.unstack("Resolution").fillna(0).astype(int).to_string()) print(f"\nSaved → {OUT / 'station_sensors_list.csv'}\n {OUT / 'station_sensors_matrix_hourly.csv'}") # ─────────────────────────── catalogue (3a_stations_sensors.json) ─────────────────────────── CATALOGUE_FILE = Path(__file__).resolve().parent / "3a_stations_sensors.json" # sensor group (name used by the engine's stations.py) -> label, unit, its hourly api_parameters SENSOR_GROUPS = [ ("TemperaturaAria", "Air temperature sensor", "°C", ["TemperaturaAriaMax", "TemperaturaAriaMedia", "TemperaturaAriaMin"]), ("UmiditaRelativa", "Relative humidity sensor", "%", ["UmiditaRelativaMax", "UmiditaRelativaMedia", "UmiditaRelativaMin"]), ("TBulb", "Wet-bulb temperature (mean, supplied by 3A)", "°C", ["TBulbMed"]), ("Pioggia", "Rain gauge (RainRateMax in mm/h)", "mm", ["Pioggia", "RainRateMax"]), ("BagnaturaFogliare", "Leaf wetness sensor", "min", ["BagnaturaFogliare"]), ("VelocitaVento2m", "Wind speed sensor (anemometer, 2 m)", "m/s", ["VelocitaVento2mMax", "VelocitaVento2mMedia"]), ("Pressione", "Barometric pressure sensor", "mbar", ["PressioneMax", "PressioneMed", "PressioneMin"]), ("RadiazioneSolare", "Solar radiation sensor (pyranometer, irradiance)", "W/m²", ["RadiazioneSolareMax", "RadiazioneSolareMin"]), ("Suolo1", "Soil moisture sensor 1", "%", ["Suolo1"]), ("Suolo2", "Soil moisture sensor 2", "%", ["Suolo2"]), ("TemperaturaTerreno", "Soil temperature sensor 1", "°C", ["TemperaturaTerrenoMedia"]), ("TemperaturaTerreno2", "Soil temperature sensor 2", "°C", ["TemperaturaTerrenoMedia2"]), ] def _sensors_by_station(data): """{station id: {api_parameter: sensor entry}} from a getAvailableStation*Sensors answer.""" out = {} for item in (data if isinstance(data, list) else [data]): for sid, sensors in item.items(): out.setdefault(int(sid), {}).update({s["name"]: s for s in sensors}) return out def build_catalogue(stations, hourly, daily, checked): """Catalogue dict from the three metadata answers (no API call). Sensors = HOURLY sensors grouped as in SENSOR_GROUPS; a parameter not in any group gets its own group (named after it, label/unit from the API). DailyOnlyParameters = columns offered only in daily data. """ hourly, daily = _sensors_by_station(hourly), _sensors_by_station(daily) known = {p for g in SENSOR_GROUPS for p in g[3]} result = [] for st in sorted(stations, key=lambda s: int(s["StationId"])): sid = int(st["StationId"]) have = hourly.get(sid, {}) sensors = [] for key, label, unit, params in SENSOR_GROUPS: present = [p for p in params if p in have] if present: sensors.append({"sensor": key, "name": label, "unit": unit, "api_parameters": present}) for p in sorted(set(have) - known): print(f" note: station {sid} has new hourly parameter {p!r}; listed as its own sensor") sensors.append({"sensor": p, "name": have[p].get("description", p), "unit": have[p].get("unit", ""), "api_parameters": [p]}) entry = {k: st.get(k) for k in ("StationId", "StationName", "Municipality", "Latitude_WGS84", "Longitude_WGS84", "Elevation_m", "TimeZone")} entry.update(NumSensors=len(sensors), Sensors=sensors, DailyOnlyParameters=sorted(set(daily.get(sid, {})) - set(have))) result.append(entry) return { "source": "https://api.3acloud.it (getAvailableStations, getAvailableStationHourlySensors, " "getAvailableStationDailySensors)", "checked": checked, "note": "Sensors = HOURLY sensors (what the blocks fetch). DailyOnlyParameters = columns the " "station offers only in daily data (/getWeatherDaily). Units from /getAvailableHourlyColumns.", "NumStations": len(result), "Stations": result, } def cmd_catalogue(args): """Ask the server again (3 requests) and rewrite 3a_stations_sensors.json.""" stations, _ = get_stations(refresh=True) ids = sorted(int(s["StationId"]) for s in stations) sensors = {kind: cached(f"sensors_{kind.lower()}", "POST", f"/getAvailableStation{kind}Sensors", {"stationsIds": ids}, refresh=True) for kind in ("Hourly", "Daily")} old = json.loads(CATALOGUE_FILE.read_text(encoding="utf-8")) if CATALOGUE_FILE.exists() else {} new = build_catalogue(stations, sensors["Hourly"], sensors["Daily"], datetime.now().strftime("%Y-%m-%d")) params = lambda cat: {s["StationId"]: {p for x in s["Sensors"] for p in x["api_parameters"]} for s in cat.get("Stations", [])} before, after = params(old), params(new) print("\nChanges in hourly parameters since the last catalogue:") for sid in sorted(set(before) | set(after)): added, removed = after.get(sid, set()) - before.get(sid, set()), before.get(sid, set()) - after.get(sid, set()) if added or removed: print(f" {sid}: added {sorted(added)} removed {sorted(removed)}") if before == after: print(" none") CATALOGUE_FILE.write_text(json.dumps(new, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") print(f"\nSaved → {CATALOGUE_FILE} (update README.md if anything changed)") def get_columns(kind, refresh=False): data = cached(f"columns_{kind.lower()}", "GET", f"/getAvailable{kind}Columns", refresh=refresh) if isinstance(data, dict): data = next((v for v in data.values() if isinstance(v, list)), data) if isinstance(data, list) and data and isinstance(data[0], dict): key = next(iter(data[0])) data = [d[key] for d in data] return data def cmd_columns(args): for kind in ("Hourly", "Daily"): cols = get_columns(kind, args.refresh) print(f"\n{kind} columns ({len(cols)}):\n " + ", ".join(map(str, cols))) def cmd_overview(args): cmd_stations(args) cmd_sensors(args) cmd_columns(args) ids = station_ids(args) print("\nAvailable periods:") for kind in ("Daily", "Hourly"): for edge in ("First", "Last"): data = cached(f"{edge.lower()}_{kind.lower()}_timestamp", "POST", f"/get{edge}Available{kind}Timestamp", {"stationsIds": ids}, args.refresh) print(f" {edge:5s} {kind:6s}: {json.dumps(data, ensure_ascii=False)[:300]}") def _parse_date(s): for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d"): try: return datetime.strptime(s, fmt) except ValueError: pass sys.exit(f"Bad date {s!r}; use YYYY-MM-DD or 'YYYY-MM-DD HH:MM'") def _periods(start, end, chunk_days): if not chunk_days: yield start, end return cur = start while cur <= end: nxt = min(cur + timedelta(days=chunk_days) - timedelta(minutes=1), end) yield cur, nxt cur = nxt + timedelta(minutes=1) def cmd_weather(args, kind): ids = station_ids(args) columns = args.columns.split(",") if args.columns else get_columns(kind, args.refresh) start, end = _parse_date(args.date_from), _parse_date(args.date_to) if len(args.date_to) == 10: end = end.replace(hour=23, minute=59) frames, raw = [], [] for a, b in _periods(start, end, args.chunk_days): body = {"stationsIds": ids, "dateFrom": a.strftime("%Y-%m-%d %H:%M"), "dateTo": b.strftime("%Y-%m-%d %H:%M"), "columns": columns} print(f"→ {kind} data {body['dateFrom']} … {body['dateTo']} for {len(ids)} station(s)") data = call("POST", f"/getWeather{kind}", body) raw.append(data) df = to_frame(data) if df is not None: frames.append(df) print(f" {len(df)} rows") else: print(" (response not tabular, kept in JSON)", str(data)[:200]) name = f"weather_{kind.lower()}_{start:%Y%m%d}_{end:%Y%m%d}" (OUT / f"{name}.json").write_text(json.dumps(raw if len(raw) > 1 else raw[0], indent=1, ensure_ascii=False), encoding="utf-8") if frames: df = pd.concat(frames, ignore_index=True) df.to_csv(OUT / f"{name}.csv", index=False, encoding="utf-8-sig") print(f"\n{len(df)} rows, {df.shape[1]} columns → {OUT / (name + '.csv')}") print(df.head(10).to_string(index=False)) def main(): p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("command", choices=["overview", "stations", "sensors", "summary", "catalogue", "columns", "daily", "hourly", "login"]) p.add_argument("--from", dest="date_from") p.add_argument("--to", dest="date_to") p.add_argument("--stations", help="comma-separated station ids (default: all)") p.add_argument("--columns", help="comma-separated column names (default: all)") p.add_argument("--chunk-days", type=int, help="split the period into N-day requests") p.add_argument("--refresh", action="store_true", help="re-download cached metadata") args = p.parse_args() if args.command in ("daily", "hourly") and not (args.date_from and args.date_to): p.error("--from and --to are required for daily/hourly") {"overview": cmd_overview, "stations": cmd_stations, "sensors": cmd_sensors, "summary": cmd_summary, "catalogue": cmd_catalogue, "columns": cmd_columns, "login": lambda a: login(), "daily": lambda a: cmd_weather(a, "Daily"), "hourly": lambda a: cmd_weather(a, "Hourly")}[args.command](args) if __name__ == "__main__": main()