Files
magnus919_agent-skills/tempest-cli/scripts/tempest-cli
T
Magnus Hedemark f2756491b7 feat: add tempest-cli skill — hyper-local weather from Tempest station
CLI wrapper for the WeatherFlow Tempest API: current conditions,
forecast, historical observations, and real-time UDP broadcasts.
Demonstrates all cli-builder patterns in a working, testable project:

- Non-interactive with --json, --dry-run, --quiet, --verbose
- Lazy auth (--help and --dry-run work without a token)
- Multi-device filtering (auto-skips HB hub, prefers ST > SKY > AIR)
- Dual-output via emit() helper
- Structured logging with log/warn/die
- Stderr hygiene and import-time warning suppression
- Idempotent operations
- Global flags in any position (pre-parsed from argv)

Includes the Python CLI script (scripts/tempest-cli) and full API
field layout reference (references/tempest-api-field-layouts.md).

Signed-off-by: Jasper <magnus@groktop.us>
2026-05-21 22:33:20 -04:00

697 lines
27 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""tempest-cli — Hyper-local weather from your Tempest station.
Two data sources:
REST API — stations, observations, forecast via WeatherFlow cloud
UDP/local — real-time broadcast from your hub on port 50222
Requires TEMPEST_TOKEN env var (personal access token from weatherflow.com).
"""
import argparse
import json
import os
import socket
import struct
import sys
import time
import warnings
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional, Tuple
# === Suppress dependency warnings before imports ===
warnings.simplefilter("ignore")
import requests
# === Config ===
DEFAULT_SERVER = "https://swd.weatherflow.com/swd/rest"
DEFAULT_UDP_PORT = 50222
UDP_BROADCAST_ADDR = "0.0.0.0"
ENV_TOKEN = os.getenv("TEMPEST_TOKEN", "")
ENV_SERVER = os.getenv("TEMPEST_SERVER", DEFAULT_SERVER)
# === Logging ===
QUIET = False
def log(msg: str) -> None:
"""Log to stdout, suppressed in --json or --quiet mode."""
if not QUIET and not GLOBAL_FLAGS.get("json", False):
print(msg)
def warn(msg: str) -> None:
print(f"Warning: {msg}", file=sys.stderr)
def die(msg: str, exit_code: int = 1) -> None:
print(f"Error: {msg}", file=sys.stderr)
sys.exit(exit_code)
def emit(human: str, data: Any) -> None:
"""Dual output — machine JSON or human text."""
if GLOBAL_FLAGS.get("json", False):
print(json.dumps(data, default=str))
else:
print(human)
# === Global flags (pre-parsed from argv) ===
GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "force": False, "quiet": False, "verbose": False}
def _preparse_global_flags(argv: List[str]) -> Tuple[Dict[str, Any], List[str]]:
"""Strip global flags from argv regardless of position."""
GLOBAL_BOOLS = {"--json", "--dry-run", "--force", "--quiet", "--verbose"}
flags: Dict[str, Any] = {}
filtered: List[str] = [argv[0]]
i = 1
while i < len(argv):
arg = argv[i]
if arg in GLOBAL_BOOLS:
flags[arg.lstrip("-").replace("-", "_")] = True
i += 1
elif arg == "--help" or arg == "-h":
return flags, argv # let argparse handle help
elif arg == "--":
filtered.extend(argv[i:])
break
else:
filtered.append(arg)
i += 1
return flags, filtered
# === Tempest API Client ===
class TempestClient:
"""REST API client for WeatherFlow Tempest."""
def __init__(self, token: str = "", server: str = "", dry_run: bool = False):
self.token = token or ENV_TOKEN
self.server = (server or ENV_SERVER).rstrip("/")
self.dry_run = dry_run
def _get(self, path: str, params: Optional[Dict] = None) -> Any:
"""Generic GET with token auth."""
url = f"{self.server}{path}"
if params is None:
params = {}
params["token"] = self.token
if self.dry_run:
return {"dry_run": True, "url": url, "params": params}
try:
resp = requests.get(url, params=params, timeout=30)
except requests.ConnectionError as e:
die(f"Cannot connect to {self.server}: {e}\n Is the server reachable?")
if resp.status_code == 401:
die("Auth failed (401). Check your TEMPEST_TOKEN or generate a new one at weatherflow.com.")
if resp.status_code == 403:
die("Forbidden (403). Your token may not have access to this station/device.")
if resp.status_code == 404:
die(f"Not found (404) at {path}. Check station/device IDs.")
if resp.status_code >= 400:
try:
detail = resp.json()
except Exception:
detail = resp.text[:200]
die(f"API error ({resp.status_code}): {detail}")
try:
return resp.json()
except ValueError:
return {"raw": resp.text[:500]}
# === Endpoints ===
def get_stations(self) -> List[Dict]:
"""List all stations and their devices."""
data = self._get("/stations")
return data if isinstance(data, list) else data.get("stations", [])
def get_observations(self, device_id: int, days_back: int = 0, time_start: Optional[int] = None) -> Dict:
"""Get observations for a device. Use days_back=1 for last day, or time_start epoch."""
params: Dict[str, Any] = {}
if days_back > 0:
params["day_offset"] = days_back
elif time_start:
params["time_start"] = time_start
else:
params["latest"] = "true"
return self._get(f"/observations/device/{device_id}", params)
def get_forecast(self, station_id: int) -> Dict:
"""Get better_forecast — current conditions + daily + hourly."""
return self._get("/better_forecast", {"station_id": station_id})
# === Observation decoders ===
OBS_ST_FIELDS = [
("epoch", "seconds_utc"),
("wind_lull", "m/s"),
("wind_avg", "m/s"),
("wind_gust", "m/s"),
("wind_direction", "degrees"),
("wind_sample_interval", "seconds"),
("station_pressure", "MB"),
("air_temperature", "C"),
("relative_humidity", "%"),
("illuminance", "lux"),
("uv", "index"),
("solar_radiation", "W/m^2"),
("rain_accumulation", "mm"),
("precipitation_type", "0=none 1=rain 2=hail"),
("avg_strike_distance", "km"),
("strike_count", "count"),
("battery", "volts"),
("report_interval", "minutes"),
("local_day_rain_accumulation", "mm"),
("nc_rain_accumulation", "mm"),
("local_day_nc_rain_accumulation", "mm"),
("precip_analysis_type", "type"),
]
OBS_AIR_FIELDS = [
("epoch", "seconds_utc"),
("station_pressure", "MB"),
("air_temperature", "C"),
("relative_humidity", "%"),
("lightning_strike_count", "count"),
("lightning_avg_distance", "km"),
("battery", "volts"),
("report_interval", "minutes"),
]
OBS_SKY_FIELDS = [
("epoch", "seconds_utc"),
("illuminance", "lux"),
("uv", "index"),
("rain_accumulation", "mm"),
("wind_lull", "m/s"),
("wind_avg", "m/s"),
("wind_gust", "m/s"),
("wind_direction", "degrees"),
("battery", "volts"),
("report_interval", "minutes"),
("solar_radiation", "W/m^2"),
("local_day_rain_accumulation", "mm"),
("precipitation_type", "0=none 1=rain 2=hail"),
("wind_sample_interval", "seconds"),
]
def decode_obs(obs_array: List, type_str: str) -> Dict:
"""Decode a raw observation array into a dict with field names."""
if type_str == "obs_st":
fields = OBS_ST_FIELDS
elif type_str == "obs_air":
fields = OBS_AIR_FIELDS
elif type_str == "obs_sky":
fields = OBS_SKY_FIELDS
else:
return {f"field_{i}": v for i, v in enumerate(obs_array)}
result = {}
for i, (name, unit) in enumerate(fields):
val = obs_array[i] if i < len(obs_array) else None
if name == "epoch" and val is not None:
result["timestamp"] = datetime.fromtimestamp(val, tz=timezone.utc).isoformat()
result[name] = val
result[f"{name}_unit"] = unit if name != "epoch" else None
return result
def wind_dir_to_cardinal(deg: Optional[float]) -> str:
"""Convert wind degrees to cardinal direction."""
if deg is None:
return "N/A"
dirs = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW", "N"]
idx = round(deg / 22.5)
return dirs[idx % 16]
def format_current(obs: Dict) -> str:
"""Format current conditions for human display."""
lines = []
if obs.get("air_temperature") is not None:
temp_c = obs["air_temperature"]
temp_f = temp_c * 9 / 5 + 32
lines.append(f"🌡️ Temperature: {temp_c:.1f}°C / {temp_f:.1f}°F")
if obs.get("relative_humidity") is not None:
lines.append(f"💧 Humidity: {obs['relative_humidity']:.0f}%")
if obs.get("station_pressure") is not None:
press_mb = obs["station_pressure"]
press_inhg = press_mb * 0.02953
lines.append(f"🔵 Pressure: {press_mb:.1f} MB / {press_inhg:.2f} inHg")
if obs.get("wind_avg") is not None:
wind_mps = obs["wind_avg"]
wind_mph = wind_mps * 2.237
gust_mps = obs.get("wind_gust")
gust_mph = gust_mps * 2.237 if gust_mps else None
dir_deg = obs.get("wind_direction")
card = wind_dir_to_cardinal(dir_deg)
gust_str = f" (gust {gust_mph:.1f} mph)" if gust_mph else ""
lines.append(f"💨 Wind: {wind_mps:.1f} m/s ({wind_mph:.1f} mph){gust_str} from {card} ({dir_deg:.0f}°)")
if obs.get("illuminance") is not None and obs["illuminance"] > 0:
lux = obs["illuminance"]
lines.append(f"☀️ Illuminance: {lux:.0f} lux")
if obs.get("solar_radiation") is not None and obs["solar_radiation"] > 0:
lines.append(f"⚡ Solar Radiation: {obs['solar_radiation']:.0f} W/m²")
if obs.get("uv") is not None:
lines.append(f"🌞 UV Index: {obs['uv']:.1f}")
if obs.get("rain_accumulation") is not None and obs["rain_accumulation"] > 0:
rain_mm = obs["rain_accumulation"]
rain_in = rain_mm / 25.4
lines.append(f"🌧️ Rain (last interval): {rain_mm:.2f} mm / {rain_in:.3f} in")
if obs.get("local_day_rain_accumulation") is not None and obs["local_day_rain_accumulation"] > 0:
day_mm = obs["local_day_rain_accumulation"]
day_in = day_mm / 25.4
lines.append(f"🌧️ Rain (today): {day_mm:.2f} mm / {day_in:.3f} in")
if obs.get("strike_count") is not None and obs["strike_count"] > 0:
lines.append(f"⚡ Lightning strikes: {obs['strike_count']} (avg dist {obs.get('avg_strike_distance', '?')} km)")
if obs.get("battery") is not None:
lines.append(f"🔋 Battery: {obs['battery']:.2f}V")
if obs.get("timestamp"):
lines.append(f"🕐 Recorded: {obs['timestamp']}")
return "\n".join(lines) if lines else "(no observations)"
# === CLI Commands ===
def cmd_stations(client: TempestClient, args: List[str]) -> None:
"""List stations and attached devices."""
stations = client.get_stations()
if not stations:
emit("No stations found for this token.", {"stations": []})
return
output_human = []
for s in stations:
name = s.get("name", s.get("station_name", "Unnamed"))
sid = s.get("station_id", "?")
output_human.append(f"Station: {name} (id={sid})")
for dev in s.get("devices", []):
dev_id = dev.get("device_id", "?")
dev_type = dev.get("device_type", "?")
sn = dev.get("serial_number", "?")
output_human.append(f" ├─ Device: {dev_type} (id={dev_id}, sn={sn})")
if dev.get("name"):
output_human.append(f" │ Name: {dev['name']}")
emit("\n".join(output_human), {"stations": stations})
def cmd_current(client: TempestClient, args: List[str]) -> None:
"""Get latest observations from your station."""
parser = argparse.ArgumentParser(prog="tempest-cli current")
parser.add_argument("--station-id", type=int, help="Station ID (optional if only one station)")
parser.add_argument("--device-id", type=int, help="Device ID (default: first Tempest device)")
parsed, _ = parser.parse_known_args(args)
if client.dry_run:
emit("[dry-run] Would query latest observations from your station.",
{"dry_run": True, "command": "current",
"station_id": parsed.station_id, "device_id": parsed.device_id})
return
stations = client.get_stations()
if not stations:
die("No stations found. Verify your TEMPEST_TOKEN.")
# Pick station
if parsed.station_id:
station = next((s for s in stations if s.get("station_id") == parsed.station_id), None)
if not station:
die(f"Station {parsed.station_id} not found.")
else:
station = stations[0]
devices = station.get("devices", [])
if not devices:
die(f"Station '{station.get('name', '?')}' has no devices.")
# Pick device — skip the hub (HB), prefer ST (Tempest) or SKY/AIR
if parsed.device_id:
device = next((d for d in devices if d.get("device_id") == parsed.device_id), None)
else:
# Filter out the hub (device_type=HB), use first sensor
sensors = [d for d in devices if d.get("device_type") not in ("HB", "hub")]
if not sensors:
die("No sensor devices found on this station (only a hub).")
# Prefer Tempest (ST), then Sky, then Air
for preferred in ("ST", "SKY", "AIR"):
match = next((d for d in sensors if d.get("device_type") == preferred), None)
if match:
device = match
break
else:
device = sensors[0]
dev_id = device.get("device_id")
log(f"Station: {station.get('name', '?')} Device: {device.get('device_type', '?')} (id={dev_id})")
obs_data = client.get_observations(dev_id)
obs_list = obs_data.get("obs", [])
obs_type = obs_data.get("type", "obs_st")
if not obs_list:
emit("No observations available yet.", {"observations": [], "type": obs_type})
return
latest = obs_list[-1] # newest
decoded = decode_obs(latest, obs_type)
if GLOBAL_FLAGS.get("json", False):
emit("", {"station": station.get("name", "?"), "device_id": dev_id, "type": obs_type, "observation": decoded})
else:
print(format_current(decoded))
def cmd_obs(client: TempestClient, args: List[str]) -> None:
"""Get historical observations."""
parser = argparse.ArgumentParser(prog="tempest-cli obs")
parser.add_argument("--device-id", type=int, required=True, help="Device ID (required)")
parser.add_argument("--days", type=int, default=1, help="Days back to fetch (default: 1)")
parsed, _ = parser.parse_known_args(args)
if client.dry_run:
emit("[dry-run] Would fetch historical observations.",
{"dry_run": True, "command": "obs",
"device_id": parsed.device_id, "days": parsed.days})
return
obs_data = client.get_observations(parsed.device_id, days_back=parsed.days)
obs_list = obs_data.get("obs", [])
obs_type = obs_data.get("type", "obs_st")
decoded = [decode_obs(o, obs_type) for o in obs_list]
if GLOBAL_FLAGS.get("json", False):
emit("", {"device_id": parsed.device_id, "type": obs_type, "count": len(decoded), "observations": decoded})
else:
print(f"{len(decoded)} observations from the last {parsed.days} day(s) (type: {obs_type}):")
print("")
# Show latest 3
for o in decoded[-3:]:
print("---")
print(format_current(o))
print("")
def cmd_forecast(client: TempestClient, args: List[str]) -> None:
"""Get forecast — current conditions + daily + hourly."""
parser = argparse.ArgumentParser(prog="tempest-cli forecast")
parser.add_argument("--station-id", type=int, help="Station ID (optional if only one station)")
parser.add_argument("--days", type=int, default=5, help="Days of daily forecast (default: 5)")
parsed, _ = parser.parse_known_args(args)
if client.dry_run:
emit("[dry-run] Would fetch hyper-local forecast for your station.",
{"dry_run": True, "command": "forecast",
"station_id": parsed.station_id, "days": parsed.days})
return
stations = client.get_stations()
if not stations:
die("No stations found.")
if parsed.station_id:
station = next((s for s in stations if s.get("station_id") == parsed.station_id), None)
else:
station = stations[0]
if not station:
die("Station not found.")
sid = station.get("station_id")
name = station.get("name", "?")
log(f"Fetching forecast for station '{name}' (id={sid})...\n")
data = client.get_forecast(sid)
fc_data = data.get("forecast", data) # prefer nested "forecast" key, fall back to top-level
if GLOBAL_FLAGS.get("json", False):
emit("", {"station_id": sid, "station_name": name, "forecast": data})
return
# Current conditions
current = data.get("current_conditions", {})
if current:
print("── Current Conditions ──")
icon = current.get("icon", "")
cond = current.get("conditions", "")
icon_str = f" ({icon})" if icon else ""
print(f"Conditions: {cond}{icon_str}")
for key in ("air_temperature", "temperature"):
if current.get(key) is not None:
val = current[key]
feels = current.get("feels_like")
if feels is not None:
feels_f = feels * 9 / 5 + 32
else:
feels_f = None
print(f"Temperature: {val * 9 / 5 + 32:.0f}°F (feels like {feels_f:.0f}°F)" if feels_f is not None else f"Temperature: {val * 9 / 5 + 32:.0f}°F")
break
if current.get("relative_humidity") is not None:
print(f"Humidity: {current['relative_humidity']}%")
if current.get("station_pressure") is not None:
print(f"Pressure: {current['station_pressure']} MB")
if current.get("wind_avg") is not None:
wd = current.get("wind_direction_cardinal", "")
print(f"Wind: {current['wind_avg']} mph {wd}")
if current.get("conditions"):
print(f" [{current.get('conditions', '')}]")
print()
# Daily forecast
daily = fc_data.get("daily", [])
if daily:
print(f"── {parsed.days}-Day Forecast ──")
for day in daily[: parsed.days]:
day_start = day.get("day_start_local")
if isinstance(day_start, (int, float)):
day_str = datetime.fromtimestamp(day_start).strftime("%a %b %d")
elif day_start:
day_str = str(day_start).split("T")[0]
else:
day_str = "?"
hi = day.get("air_temp_high")
lo = day.get("air_temp_low")
hi_f = hi * 9 / 5 + 32 if hi is not None else None
lo_f = lo * 9 / 5 + 32 if lo is not None else None
cond = day.get("conditions", "")
precip = day.get("precip_probability")
precip_str = f" {precip}%" if precip is not None else ""
precip_type = day.get("precip_type", "")
hi_str = f"{hi_f:.0f}" if hi_f is not None else "?"
lo_str = f"{lo_f:.0f}" if lo_f is not None else "?"
print(f" {day_str}: {lo_str}{hi_str}°F {cond}{precip_str} {precip_type}".strip())
print()
# Hourly
hourly = fc_data.get("hourly", [])
if hourly:
print("── Next 12 Hours ──")
for h in hourly[:12]:
local_hour = h.get("local_hour")
dt_str = f"{local_hour:02d}:00" if local_hour is not None else "?"
temp_c = h.get("air_temperature")
temp_str = f"{temp_c * 9 / 5 + 32:.0f}" if temp_c is not None else "?"
cond = h.get("conditions", "")
precip = h.get("precip_probability")
precip_str = f" {precip}%" if precip is not None else ""
print(f" {dt_str}: {temp_str}°F {cond}{precip_str}")
if len(hourly) > 12:
print(f" ... and {len(hourly) - 12} more hours")
# === UDP Commands ===
def udp_listen(args: List[str]) -> None:
"""Listen for local UDP broadcasts from the Tempest hub."""
parser = argparse.ArgumentParser(prog="tempest-cli udp listen")
parser.add_argument("--port", type=int, default=DEFAULT_UDP_PORT, help=f"UDP port (default: {DEFAULT_UDP_PORT})")
parser.add_argument("--timeout", type=int, default=0, help="Listen for N seconds (0 = indefinite)")
parser.add_argument("--show-all", action="store_true", help="Show raw message type even if unknown")
parsed, _ = parser.parse_known_args(args)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((UDP_BROADCAST_ADDR, parsed.port))
sock.settimeout(parsed.timeout if parsed.timeout > 0 else None)
log(f"Listening for Tempest UDP broadcasts on port {parsed.port}...")
if parsed.timeout > 0:
log(f"Will stop after {parsed.timeout}s\n")
try:
start = time.time()
while True:
try:
data, addr = sock.recvfrom(65535)
except socket.timeout:
log("Listen timeout reached.")
break
try:
msg = json.loads(data.decode("utf-8", errors="replace"))
except json.JSONDecodeError:
if parsed.show_all:
log(f"[raw] from {addr[0]}: {data[:200]}")
continue
msg_type = msg.get("type", "unknown")
sn = msg.get("serial_number", msg.get("hub_sn", "?"))
if msg_type == "obs_st":
for obs_arr in msg.get("obs", []):
decoded = decode_obs(obs_arr, "obs_st")
emit(f"\n── Tempest Observation from {sn} ──\n{format_current(decoded)}",
{"type": "obs_st", "serial_number": sn, "observation": decoded})
elif msg_type == "obs_air":
for obs_arr in msg.get("obs", []):
decoded = decode_obs(obs_arr, "obs_air")
emit(f"\n── Air Observation from {sn} ──\n{format_current(decoded)}",
{"type": "obs_air", "serial_number": sn, "observation": decoded})
elif msg_type == "obs_sky":
for obs_arr in msg.get("obs", []):
decoded = decode_obs(obs_arr, "obs_sky")
emit(f"\n── Sky Observation from {sn} ──\n{format_current(decoded)}",
{"type": "obs_sky", "serial_number": sn, "observation": decoded})
elif msg_type == "rapid_wind":
for ob in msg.get("ob", []):
ts = datetime.fromtimestamp(ob[0], tz=timezone.utc).isoformat() if len(ob) > 0 else "?"
speed = ob[1] if len(ob) > 1 else "?"
direction = ob[2] if len(ob) > 2 else "?"
card = wind_dir_to_cardinal(direction)
m_s_mph = f"{speed * 2.237:.1f} mph" if isinstance(speed, (int, float)) else "?"
emit(f"💨 Rapid Wind: {speed} m/s ({m_s_mph}) from {card} ({direction}°) [{ts}]",
{"type": "rapid_wind", "serial_number": sn,
"wind_speed_mps": speed, "wind_direction": direction,
"timestamp": ts})
elif msg_type == "evt_strike":
evt = msg.get("evt", [])
ts = datetime.fromtimestamp(evt[0], tz=timezone.utc).isoformat() if len(evt) > 0 else "?"
dist = evt[1] if len(evt) > 1 else "?"
energy = evt[2] if len(evt) > 2 else "?"
emit(f"⚡ Lightning Strike — distance: {dist} km, energy: {energy} [{ts}]",
{"type": "evt_strike", "serial_number": sn,
"distance_km": dist, "energy": energy, "timestamp": ts})
elif msg_type == "evt_precip":
evt = msg.get("evt", [])
ts = datetime.fromtimestamp(evt[0], tz=timezone.utc).isoformat() if len(evt) > 0 else "?"
emit(f"🌧️ Rain started [{ts}]",
{"type": "evt_precip", "serial_number": sn, "timestamp": ts})
elif msg_type == "hub_status":
if parsed.show_all:
emit(f"[hub_status] {sn} — freq: {msg.get('freq', '?')}",
{"type": "hub_status", "serial_number": sn, "status": msg})
elif parsed.show_all:
emit(f"[{msg_type}] from {sn}", {"type": msg_type, "serial_number": sn, "raw": msg})
if parsed.timeout > 0 and time.time() - start > parsed.timeout:
break
except KeyboardInterrupt:
log("\nStopped.")
finally:
sock.close()
# === Main ===
def main() -> None:
global GLOBAL_FLAGS, QUIET
GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv)
if GLOBAL_FLAGS.get("quiet", False):
QUIET = True
if GLOBAL_FLAGS.get("json", False):
warnings.simplefilter("ignore")
parser = argparse.ArgumentParser(
prog="tempest-cli",
description="Hyper-local weather from your Tempest station.",
epilog="Global flags can appear anywhere: tempest-cli --json current --device-id X"
)
sub = parser.add_subparsers(dest="command", help="Available commands")
# stations
sub.add_parser("stations", help="List stations and devices linked to your token")
# current
p_current = sub.add_parser("current", help="Latest observations from your station")
p_current.add_argument("--station-id", type=int, help="Station ID (optional)")
p_current.add_argument("--device-id", type=int, help="Device ID (optional)")
# obs
p_obs = sub.add_parser("obs", help="Historical observations")
p_obs.add_argument("--device-id", type=int, required=True, help="Device ID")
p_obs.add_argument("--days", type=int, default=1, help="Days back (default: 1)")
# forecast
p_fcst = sub.add_parser("forecast", help="Hyper-local forecast (current + daily + hourly)")
p_fcst.add_argument("--station-id", type=int, help="Station ID (optional)")
p_fcst.add_argument("--days", type=int, default=5, help="Days of daily forecast (default: 5)")
# udp
p_udp = sub.add_parser("udp", help="Local UDP broadcast commands")
udp_sub = p_udp.add_subparsers(dest="udp_command")
p_listen = udp_sub.add_parser("listen", help="Listen for local UDP broadcasts from hub")
p_listen.add_argument("--port", type=int, default=DEFAULT_UDP_PORT)
p_listen.add_argument("--timeout", type=int, default=0, help="Listen N seconds (0=indefinite)")
p_listen.add_argument("--show-all", action="store_true", help="Show all message types incl. hub_status")
args = parser.parse_args(filtered_argv[1:])
if not args.command:
parser.print_help()
sys.exit(1)
if args.command == "stations":
if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
die("TEMPEST_TOKEN not set. Get one at https://weatherflow.com")
cmd_stations(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)), [])
elif args.command == "current":
if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
die("TEMPEST_TOKEN not set.")
cmd_current(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)),
filtered_argv[filtered_argv.index("current") + 1:])
elif args.command == "obs":
if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
die("TEMPEST_TOKEN not set.")
cmd_obs(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)),
filtered_argv[filtered_argv.index("obs") + 1:])
elif args.command == "forecast":
if not ENV_TOKEN and not GLOBAL_FLAGS.get("dry_run", False):
die("TEMPEST_TOKEN not set.")
cmd_forecast(TempestClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)),
filtered_argv[filtered_argv.index("forecast") + 1:])
elif args.command == "udp":
if args.udp_command == "listen":
udp_listen(filtered_argv[filtered_argv.index("listen") + 1:])
else:
p_udp.print_help()
sys.exit(1)
if __name__ == "__main__":
main()