diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 24e8eab..88f323b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1245,7 +1245,7 @@ "./tempest" ], "strict": false, - "description": "Query hyper-local weather from a WeatherFlow Tempest station: current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. Use when the user asks about the weather, temperature, rain, wind, humidity, forecast, or wants conditions from their own station rather than a generic weather service." + "description": "Query hyper-local weather from a WeatherFlow Tempest station over its REST API and the hub's local UDP broadcast: current conditions, forecast, historical observations, and real-time decoded datagrams (obs_st, rapid_wind, evt_precip, evt_strike, hub_status). Use when the user asks about weather, temperature, rain, wind, humidity, or forecast data from their own Tempest/WeatherFlow station, or wants to parse the hub's UDP port 50222 broadcast. Do not use this skill for generic or city forecasts without a Tempest station (public weather services serve those), for Shakespeare's play The Tempest or other literature questions, or for weather hardware from other vendors - the REST endpoints require a personal-use token and the UDP broadcast only exists on a Tempest hub's LAN." }, { "name": "terraform", diff --git a/README.md b/README.md index 622c382..d5ba857 100644 --- a/README.md +++ b/README.md @@ -565,7 +565,7 @@ Operate the Prometheus + OpenTelemetry + Loki observability stack as one unit: s ### [tempest](tempest/SKILL.md) -Hyper-local weather from a WeatherFlow Tempest station. Query current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. A complete reference implementation of the cli-builder patterns in a working, testable project — including the CLI binary and full API field layout reference. +Hyper-local weather from a WeatherFlow Tempest station over the REST API and the hub's local UDP broadcast: current conditions, forecast, historical observations, and real-time decoded datagrams (obs_st, rapid_wind, evt_precip, evt_strike, hub_status) with metric-native values and conversion guidance. Not for generic city forecasts without a station, or weather hardware from other vendors. ### [terraform](terraform/SKILL.md) diff --git a/llms.txt b/llms.txt index 342cc38..58e1615 100644 --- a/llms.txt +++ b/llms.txt @@ -139,7 +139,7 @@ - [technical-documentation](technical-documentation/SKILL.md): Create and review technical documentation, including READMEs, agent-facing instructions, API references, and CLI help. Use when documentation must help someone complete real work. Do not use for marketing copy, brand messaging, or long-form editorial content. - [technology-radar](technology-radar/SKILL.md): Build and maintain technology radars for adoption, trial, assessment, and hold decisions, and choose proportionate architecture-governance paths for technology portfolios. Use when governing technology choices, build-versus-buy decisions, architecture standards, exceptions, or engineering portfolio risk. Do not use for enterprise capability or target-state architecture, writing ADRs, implementing systems, security engineering, or operational incident/runbook work. - [telemetry](telemetry/SKILL.md): Operate the observability stack that deploys as one unit: Prometheus scrape configuration, recording and alerting rules, relabeling, retention, and high availability; OpenTelemetry Collector pipelines (receivers, processors, exporters, sampling, trace/span correlation); and Loki ingest, LogQL, retention, and label design — with a bundled read-only telemetry-check script for Prometheus rule sanity and scrape-target reachability. Use when running, tuning, or troubleshooting a Prometheus, OpenTelemetry Collector, or Loki deployment, or reviewing the collection/ingest/retention layer. Do not use for observability strategy, SLI/SLO design, or paging policy (that is platform-engineering) or Grafana dashboards, panels, and Grafana-side alerting (that is grafana). -- [tempest](tempest/SKILL.md): Query hyper-local weather from a WeatherFlow Tempest station: current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. Use when the user asks about the weather, temperature, rain, wind, humidity, forecast, or wants conditions from their own station rather than a generic weather service. +- [tempest](tempest/SKILL.md): Query hyper-local weather from a WeatherFlow Tempest station over its REST API and the hub's local UDP broadcast: current conditions, forecast, historical observations, and real-time decoded datagrams (obs_st, rapid_wind, evt_precip, evt_strike, hub_status). Use when the user asks about weather, temperature, rain, wind, humidity, or forecast data from their own Tempest/WeatherFlow station, or wants to parse the hub's UDP port 50222 broadcast. Do not use this skill for generic or city forecasts without a Tempest station (public weather services serve those), for Shakespeare's play The Tempest or other literature questions, or for weather hardware from other vendors - the REST endpoints require a personal-use token and the UDP broadcast only exists on a Tempest hub's LAN. - [terraform](terraform/SKILL.md): Operate Terraform and OpenTofu across the whole infrastructure lifecycle: module structure, state backends and locking, plan/apply workflow, drift detection, remote state, upgrade and refactor flows, and evidence-based diagnostics. Use when running or inspecting terraform plans, applies, state files, imports, or state surgery, or when the bundled tfops script should handle the task. Do not use for IaC methodology or cloud design decisions - those route up to platform-engineering. - [three](three/SKILL.md): Build browser-based Three.js and WebGL scenes, animations, and interactive 3D visualizations with a small vanilla JavaScript starting point. - [tmdb](tmdb/SKILL.md): Query TMDb metadata for films and television, then enrich results with details, credits, providers, and external IDs. Do not use this skill for torrent search, streaming playback, or personal watch-history tracking. diff --git a/references/skill-triggers.md b/references/skill-triggers.md index 80fe1f7..0d9426c 100644 --- a/references/skill-triggers.md +++ b/references/skill-triggers.md @@ -23,7 +23,7 @@ Each skill's `description` field is the canonical routing contract. This conveni | "Jira", "Atlassian Jira", "JQL", "ticket PROJ-123", "sprint work", "JIRA_API_TOKEN" | [jira](../jira/SKILL.md) | | "Open Library", "openlibrary", "book search", "ISBN lookup", "author records", "work details", "book editions", "book ratings", "cover image" | [openlibrary](../openlibrary/SKILL.md) | | "PeerTube", "peertube", "federated video", "SepiaSearch", "decentralized video platform", "PEERTUBE_SERVER", "my PeerTube instance" | [peertube](../peertube/SKILL.md) | -| "weather", "forecast", "temperature", "is it raining", "Tempest" | [tempest](../tempest/SKILL.md) | +| "weather", "forecast", "temperature", "is it raining", "Tempest", "WeatherFlow", "my station" | [tempest](../tempest/SKILL.md) | | "TMDb", "The Movie Database", "movie search", "trending movies", "upcoming TV releases", "TMDB_ACCESS_TOKEN" | [tmdb](../tmdb/SKILL.md) | | "traefik", "reverse proxy", "load balancer", "API gateway", "Let's Encrypt", "ACME", "Docker routing", "traefik.yml", "entry point", "middleware", "TLS termination", "forward auth", "rate limit" | [traefik](../traefik/SKILL.md) | | "reverse-engineer", "understand this codebase", "PRD from code", "architecture document", "architecture health", "coupling analysis", "modularity", "decomposition readiness", "data ownership map", "distributed workflow analysis", "reconciliation path" | [software-architecture-analysis](../software-architecture-analysis/SKILL.md) | diff --git a/tempest/README.md b/tempest/README.md index bb72a4e..3aedf9d 100644 --- a/tempest/README.md +++ b/tempest/README.md @@ -1,37 +1,51 @@ -# Tempest — Hyper-Local Weather from Your Station +# Tempest — Hyper-Local Weather from Your Own Station -Query live weather data from a WeatherFlow Tempest station. Current conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. +Query live weather from a WeatherFlow Tempest station: current conditions, 7-day forecast, historical observations, and real-time broadcasts from your hub's local network — with every positional sensor array and UDP message family decoded for you. ## Why Install This Skill -When your agent loads this skill, it can **check hyper-local weather from your own station** — more accurate than generic services. That means: +Generic weather services tell you what the model thinks the sky is doing kilometers away. This skill reads **your actual station**: the Tempest sitting in your yard, via WeatherFlow's documented REST API and the hub's local UDP broadcast. Once installed, your agent can: -- **Current conditions** — temperature, humidity, wind, rain, UV, solar radiation, barometric pressure -- **7-day forecast** — daily and hourly outlook with precipitation probability -- **Historical data** — past observations for analysis -- **Real-time UDP** — local broadcast reception without cloud dependency -- **Auto-discovery** — finds your station and sensors automatically +- **Current conditions** — temperature, humidity, wind (lull/avg/gust + direction), rain, UV, solar radiation, barometric pressure +- **7-day forecast** — daily and hourly outlook with precipitation probabilities, unit-aware +- **Historical observations** — past UTC days of minute-level data for analysis +- **Real-time UDP stream** — decoded `obs_st`, `rapid_wind`, `evt_precip`, `evt_strike`, and `hub_status` messages straight from the hub on port 50222, no cloud round-trip +- **Station discovery** — finds your stations and sensors automatically, never mistaking the hub for a sensor + +The tricky parts of the Tempest API are handled for you: observations arrive as *positional arrays* whose meaning depends on the index, UDP message families have three different payload shapes, and forecast responses are unit-selectable (a naive script double-converts Fahrenheit data into 172-degree nonsense). The CLI decodes all of it, keeps JSON output in metric-native wire units, and converts only for human display. ## What You Get -| Directory | Purpose | -|-----------|---------| -| `SKILL.md` | Complete command reference with examples | -| `scripts/tempest` | CLI tool for WeatherFlow Tempest API | -| `references/` | API field layout reference | +| Path | What it provides | +|------|------------------| +| `SKILL.md` | Command reference, pipeline recipes, and the gotchas that actually bite | +| `scripts/tempest` | CLI: `stations`, `current`, `obs`, `forecast`, `udp listen` with `--json`/`--dry-run` | +| `scripts/test_tempest.py` | Offline test suite (canned datagram bytes + mocked REST, no network) | +| `references/rest-api-and-auth.md` | Token auth, endpoint catalog, response shapes, error signatures | +| `references/udp-broadcast-protocol.md` | Port 50222 transport, every message family's exact layout | +| `references/observation-layouts-and-units.md` | Index-by-index field maps for obs_st/obs_air/obs_sky + conversion tables | +| `references/cli-worked-recipes.md` | Copy-paste multi-step recipes with jq stages | ## Quick Start ```bash +# Create a token in the Tempest web app: Settings -> Data Authorizations -> Create Token export TEMPEST_TOKEN="your-token-here" -tempest current -tempest forecast + +tempest stations # discover your station and device IDs +tempest current # conditions right now +tempest forecast # current + daily + hourly outlook +tempest udp listen --timeout 30 # real-time broadcast from the hub (no token needed) ``` +Every command accepts `--json` for machine-readable output and `--dry-run` to preview the plan offline. + ## Triggers -Load this for weather, temperature, rain, wind, humidity, forecast, or conditions from a specific Tempest station. +Load this skill when the user mentions Tempest, WeatherFlow, their weather station, hyper-local conditions, station observations, or parsing the hub's UDP port 50222 broadcast — temperature, rain, wind, humidity, lightning, or forecast questions tied to a personal station. ## Requirements -Python 3.8+ with `requests` library. Free token from weatherflow.com. +- Python 3.8+ with the `requests` library (the only dependency) +- A `TEMPEST_TOKEN` (free, personal use) for REST commands — created in the Tempest web app under Settings → Data Authorizations +- **For UDP listening: a Tempest hub on the same LAN** — the hub broadcasts on UDP port 50222 to the local network only; broadcasts do not cross routers, and no token or cloud account is involved. REST commands work from anywhere with internet access. diff --git a/tempest/SKILL.md b/tempest/SKILL.md index a361efd..82ac057 100644 --- a/tempest/SKILL.md +++ b/tempest/SKILL.md @@ -1,161 +1,276 @@ --- name: tempest -description: 'Query hyper-local weather from a WeatherFlow Tempest station: current - conditions, 7-day forecast, historical observations, and real-time UDP broadcasts. - Use when the user asks about the weather, temperature, rain, wind, humidity, forecast, - or wants conditions from their own station rather than a generic weather service.' +description: >- + Query hyper-local weather from a WeatherFlow Tempest station over its REST + API and the hub's local UDP broadcast: current conditions, forecast, + historical observations, and real-time decoded datagrams (obs_st, + rapid_wind, evt_precip, evt_strike, hub_status). Use when the user asks + about weather, temperature, rain, wind, humidity, or forecast data from + their own Tempest/WeatherFlow station, or wants to parse the hub's UDP port + 50222 broadcast. Do not use this skill for generic or city forecasts + without a Tempest station (public weather services serve those), for + Shakespeare's play The Tempest or other literature questions, or for + weather hardware from other vendors - the REST endpoints require a + personal-use token and the UDP broadcast only exists on a Tempest hub's + LAN. license: MIT -compatibility: Requires TEMPEST_TOKEN env var (free from weatherflow.com), Python - 3.8+, and the `requests` library. +compatibility: >- + Requires TEMPEST_TOKEN env var for REST (create it in the Tempest web app + under Settings -> Data Authorizations), Python 3.8+, and `requests`. UDP + listening needs a Tempest hub on the LAN and no token. `--help` and + `--dry-run` work without credentials. metadata: - tags: weather, tempest, forecast, weatherflow, station, hyper-local - sources: https://weatherflow.com, https://swd.weatherflow.com/swd/rest + tags: weather, tempest, weatherflow, forecast, station, udp, hyper-local + sources: https://apidocs.tempestwx.com/reference/quick-start, https://weatherflow.github.io/Tempest/api/udp/v171/ --- -# tempest — Hyper-Local Weather from Your Tempest Station +# tempest — Hyper-local weather from your Tempest station -Query live weather data from a WeatherFlow Tempest station. Supports REST API access to current conditions, forecasts, and history via the cloud, plus local UDP broadcast reception from your hub on the same LAN. +Drive a WeatherFlow Tempest station from the terminal. Two transports, both +first-class: the documented REST API (`swd.weatherflow.com/swd/rest`, +personal-use token) for conditions, forecast, and history — officially the +primary data source — and the hub's unauthenticated UDP broadcast on port +50222 for real-time, lowest-latency readings on your LAN. The bundled CLI +decodes the positional observation arrays and every UDP message family, keeps +`--json` output metric-native, and converts units only for human display. ## Setup -1. Get a personal access token at [weatherflow.com](https://weatherflow.com) (Account → API Tokens) -2. Set it in your environment: +1. Create a personal access token: sign in to the Tempest web app + (tempestwx.com), then **Settings → Data Authorizations → Create Token**. + (This is the documented non-graphical auth method; OAuth exists for web + apps but is not what a CLI uses.) +2. Export it: ```bash -export TEMPEST_TOKEN="your-token-here" +export TEMPEST_TOKEN="" ``` -The CLI reads `TEMPEST_TOKEN` from the environment. It also falls back to reading `~/.tempest.env` if the env var is not set (for agent subprocesses that don't inherit env vars). `--help` and `--dry-run` work without a token. +The token travels to the API as a **query parameter** (`?token=...`) per the +official docs — the CLI handles this. If the env var is not set, the CLI +falls back to reading `TEMPEST_TOKEN=` from `~/.tempest.env` (handy for agent +subprocesses that skip shell profiles). `--help` and `--dry-run` never need a +token. UDP listening never needs one either — the hub broadcast is +unauthenticated and LAN-only. ## Essential Commands -### current — Current conditions +### stations — discover your stations and devices ```bash -tempest current # human-readable -tempest current --station-id 12345 --device-id 67890 # specific hardware -tempest current --json # machine-readable +tempest stations # names, station ids, device types, serials +tempest stations --json | jq '.stations[] | {station_id, name, + devices: [.devices[] | {device_id, device_type, serial_number}]}' ``` -If you have one station, it auto-selects it and picks the best sensor (ST > SKY > AIR, skips the HB hub). Pass `--station-id` or `--device-id` to override. +Every station response nests a `devices` array: `device_type` is `ST` (the +Tempest all-in-one), `AR`/`AIR`, `SK`/`SKY`, or `HB` (the hub — it has **no** +observations; always filter it out before querying observations). Run this +first when you don't know your ids. -### forecast — Multi-day forecast (+ current conditions + hourly) +### current — latest conditions ```bash -tempest forecast # current + 5-day daily + 12-hour hourly -tempest forecast --days 3 # fewer days -tempest forecast --station-id 12345 # specific station -tempest forecast --json # machine-readable +tempest current # human-readable, converted +tempest current --json # metric-native, jq-ready +tempest current --station-id 12799 --device-id 60526 # pin exact hardware ``` -### stations — List your stations and devices +With one station it auto-selects and picks the best sensor (`ST`, then +`SKY`/`SK`, then `AIR`/`AR`, skipping `HB`). Output `.observation` carries the +decoded positional array as named fields with `_unit` companions. + +### forecast — current conditions + daily + hourly ```bash -tempest stations # shows station names, IDs, device types, serials -tempest stations --json # full device inventory +tempest forecast # current + 5-day daily + next 12 hours +tempest forecast --days 7 --json +tempest forecast --station-id 12799 --days 3 ``` -Use this first if you don't know your station ID or want to see what sensors are online. +The `better_forecast` response nests daily/hourly under a `forecast` wrapper +key, and it is unit-selectable (`units_temp=c|f` and friends, default metric) +— the CLI reads the response's `units` before converting anything. -### obs — Historical observations +### obs — historical observations ```bash -tempest obs --device-id 67890 --days 1 # last 24 hours -tempest obs --device-id 67890 --days 7 # last week -tempest obs --device-id 67890 --json # machine-readable +tempest obs --device-id 60526 --days 1 # last UTC day (day_offset) +tempest obs --device-id 60526 --days 7 +tempest obs --device-id 60526 --json ``` -### udp listen — Real-time broadcasts from the hub +`--days N` maps to the API's `day_offset` (whole UTC days). The underlying +endpoint also accepts `time_start`/`time_end` epoch ranges (one-minute +resolution guaranteed up to 5 days) — use raw calls for those; see +references/rest-api-and-auth.md. + +## UDP broadcasts from your hub (port 50222, listen-only) ```bash -tempest udp listen # listen indefinitely (Ctrl-C to stop) +tempest udp listen # live stream until Ctrl-C tempest udp listen --timeout 30 # auto-stop after 30s -tempest udp listen --show-all # include hub_status messages +tempest udp listen --timeout 60 --json # one JSON object per datagram +tempest udp listen --show-all # include hub_status/device_status ``` -Requires being on the same LAN as the hub (port 50222 UDP broadcast). Receives observations, rapid wind updates, lightning strike events, and precipitation start events in real time. +Requires being on the same LAN as the hub (routed connectivity is not enough +— broadcasts don't cross routers). No token involved. The listener decodes +every message family, dispatching on `type` before touching array positions: -## Data Reference +| Family | Payload shape | Decoded fields | +|---|---|---| +| `obs_st` / `obs_air` / `obs_sky` | list of report rows under `obs` | named observation fields | +| `rapid_wind` | ONE 3-element array under `ob` | wind_speed_mps, wind_direction | +| `evt_precip` | ONE array under `evt` | timestamp (rain started) | +| `evt_strike` | ONE array under `evt` | distance_km, energy | +| `hub_status`, `device_status` | named fields, no array | uptime, rssi, seq, voltage, sensor_status | -### obs_st field layout (Tempest all-in-one) +## Multi-step pipeline recipes -Observations from the Tempest sensor arrive as positional arrays. The CLI decodes them, but if you're reading raw JSON output, this map tells you what each index means: +### Discover, then observe -| Index | Field | Units | Notes | -|-------|-------|-------|-------| -| 0 | epoch | seconds UTC | | -| 1 | wind_lull | m/s | Minimum 3-second sample | -| 2 | wind_avg | m/s | Average over report interval | -| 3 | wind_gust | m/s | Maximum 3-second sample | -| 4 | wind_direction | degrees | 0=N | -| 5 | wind_sample_interval | seconds | | -| 6 | station_pressure | MB | | -| 7 | air_temperature | C | CLI converts to °F | -| 8 | relative_humidity | % | | -| 9 | illuminance | lux | | -| 10 | uv | index | | -| 11 | solar_radiation | W/m² | | -| 12 | rain_accumulation | mm | Over last interval | -| 13 | precipitation_type | enum | 0=none 1=rain 2=hail | -| 14 | avg_strike_distance | km | Lightning | -| 15 | strike_count | count | Lightning | -| 16 | battery | volts | ~2.6V normal, ~2.5V low | -| 17 | report_interval | minutes | | -| 18 | local_day_rain_accumulation | mm | | +```bash +# Stage 1 -> stage 2: stations --json emits integer ids that current consumes +tempest stations --json | jq -r '.stations[].devices[] + | select(.device_type == "ST") | .device_id' | head -1 +tempest current --device-id --json +``` -See [references/tempest-api-field-layouts.md](references/tempest-api-field-layouts.md) for the full obs_air and obs_sky field layouts. +### Rain watch: yesterday's total, then live rain events -### Unit conversions the CLI applies +```bash +tempest obs --device-id 60526 --days 1 --json \ + | jq '{samples: (.observations | length), + day_rain_mm: .observations[-1].local_day_rain_accumulation}' +tempest udp listen --timeout 600 --json | jq 'select(.type == "evt_precip")' +``` -| Input | Output | Conversion | -|-------|--------|------------| -| °C | °F | `c * 9/5 + 32` | -| m/s | mph | `mps * 2.237` | -| MB | inHg | `mb * 0.02953` | -| mm | in | `mm / 25.4` | -| degrees | cardinal | N, NNE, NE, ..., NNW | +`obs --json` ends with decoded observations carrying +`local_day_rain_accumulation` (mm, number); `evt_precip` datagrams decode to +`{type, serial_number, timestamp}` — both stages emit typed fields the next +stage can consume. + +### Unit-aware forecast slice + +```bash +tempest forecast --days 7 --json \ + | jq '{units_temp: .forecast.units.units_temp, + highs_f: [.forecast.forecast.daily[] | .air_temp_high * 9 / 5 + 32], + rain_hours: [.forecast.forecast.hourly[] + | select(.precip_probability > 30) | .local_hour]}' +``` + +The jq math here is safe **only because** it checks `units_temp` first — see +gotcha 2. + +## JSON output and jq processing + +`--json` output is **metric-native** — the raw wire units (m/s wind, mm rain, +°C temperature, MB pressure) with `_unit` companion fields naming each. +Convert at the consumption edge: + +```bash +tempest current --json | jq '{temp_c: .observation.air_temperature, + temp_f: (.observation.air_temperature * 9 / 5 + 32), + wind_mph: (.observation.wind_avg * 2.237), + rain_in: (.observation.rain_accumulation / 25.4)}' +``` + +Global flags work in any position: `tempest --json current --device-id 60526` +and `tempest current --device-id 60526 --json` are identical. `--quiet` +silences the progress logs (data on stdout, logs on stderr). +`--dry-run` prints a plan object and exits 0 without touching the network. ## Known Gotchas -### The API is metric-native +1. **Observations are positional arrays, not objects.** Raw `obs` rows have + no field names; meaning comes from the index (obs_st: 0 epoch, 2 wind avg + m/s, 4 wind direction, 6 pressure MB, 7 temperature °C, 12 rain mm, 16 + battery V, 17 report interval). Reading index 6 as temperature gives you a + plausible-looking wrong number — decode with the CLI or the layout tables + in references/observation-layouts-and-units.md. +2. **`/better_forecast` is unit-selectable, not Celsius-locked.** It defaults + to metric but honors `units_temp=f`, `units_wind=mph`, `units_pressure=inhg`, + `units_precip=in`. It reports what it used in `response.units`. Converting + an already-Fahrenheit response doubles it (25.4 °C → 77.7 °F → 172 "°F"). + Always read `units` before converting; the CLI does this for you. +3. **UDP message families differ structurally — dispatch on `type` first.** + obs families nest rows under `obs`; `rapid_wind` carries one array under + `ob`; `evt_precip`/`evt_strike` carry one array under `evt`; + `hub_status`/`device_status` carry named fields with no payload array. + Iterating `rapid_wind`'s `ob` element-wise is the classic TypeError; the + bundled `decode_message()` shows the correct dispatch. +4. **UDP obs_st rows stop at index 17; REST rows run to 21.** The four + Nearcast/analysis fields (18–21) exist only in REST responses. Decoders + must tolerate both lengths — the CLI emits `None` for missing tails. +5. **Pressure is MB (millibars), numerically hPa — not kPa.** It is also + *station* pressure (raw sensor). The Tempest app's "relative pressure" + adds an elevation adjustment; don't compare raw station pressure against + the app and conclude the sensor drifted. +6. **Forecast timestamps are epoch integers, never ISO strings.** + `day_start_local`, `sunrise`, `sunset`, hourly `time` are epoch seconds; + hourly objects carry `local_hour` (0–23) and `local_day` (day of month). + There is **no** `local_time` or `time_string` field — code expecting one + silently falls back to its default branch. +7. **The forecast nests under a `forecast` wrapper key.** `data["daily"]` is + always empty; read `data["forecast"]["daily"]` and + `data["forecast"]["hourly"]` (the CLI's `--json` preserves the full + response, wrapper and all). +8. **Hubs (`HB`) have no observations.** They only relay. Auto-selection + skips them; if you call the API directly, filter `device_type == "HB"` + out before hitting `/observations/device/{id}` (documented 404 otherwise). +9. **UDP is LAN-only and unauthenticated.** Broadcasts don't cross routers + and can't be token-gated — anyone on the network can read your station. + WeatherFlow officially positions REST/WebSocket as primary and UDP as the + off-grid/backup interface. +10. **`obs_sky` UDP day-rain is always null.** Local-day rain accumulation + (index 11) is `null` in UDP SKY broadcasts; REST supplies the real value. + Don't build day-rain totals from UDP SKY rows. -All raw observation data comes in metric (Celsius, m/s, MB, mm). The CLI converts for human display. If you're parsing raw `--json` output, expect metric values. The `better_forecast` endpoint returns unit-converted values based on station preferences — check the `units` key in the response — but **temperatures are always in Celsius** regardless. +## When to use -### Timestamps are epoch integers, not strings - -The API returns Unix epoch timestamps, not ISO 8601 strings. The `daily[].day_start_local` field is an `int`, not `"2026-05-10T00:00:00"`. Hourly objects have `local_hour` (int 0-23) and `local_day` (int) — there is **no** `local_time` or `time_string` field. The CLI handles this, but raw JSON consumers need to convert with `datetime.fromtimestamp(ts)`. - -### Nested forecast response - -The `better_forecast` endpoint nests daily and hourly arrays under a `forecast` wrapper key, not at the top level: - -```python -# Correct path: -fc = data.get("forecast", {}) -days = fc.get("daily", []) -hours = fc.get("hourly", []) -``` - -The top-level keys are: `current_conditions` (dict), `forecast` (dict with `daily` + `hourly`), `station` (metadata), `units`, `status`, `timezone`. - -### Device type filtering - -A station returns all devices including the hub (device_type `HB`). The hub cannot serve observations. The CLI auto-filters HB devices and prefers ST > SKY > AIR. If you're bypassing the CLI and calling the API directly, always filter out device_type `HB` before querying observation endpoints. - -### Global flags in any position - -`--json`, `--dry-run`, `--quiet`, and `--verbose` work anywhere in the command: - -```bash -tempest --json current --device-id 67890 # flag before subcommand -tempest current --device-id 67890 --json # flag after subcommand -``` - -## References - -- [references/tempest-api-field-layouts.md](references/tempest-api-field-layouts.md) — Full field index maps for obs_st, obs_air, and obs_sky observation arrays. Read when decoding raw JSON output or building on top of the Tempest API. -- [scripts/tempest](scripts/tempest) — The CLI binary itself. Designed following the cli-builder patterns: non-interactive, `--json`, `--dry-run`, `--quiet`, `--verbose`, idempotent, dual-output via `emit()`, and structured logging. +- The user owns or manages a WeatherFlow Tempest / Air / Sky station and asks + about its readings, forecast, or history. +- Parsing or integrating with the hub's local UDP broadcast (port 50222). +- Rain/wind/lightning monitoring scripts, dashboards, or home-automation + hooks fed from the station. ## When not to use -Do not use this skill for weather questions that do not involve a personal WeatherFlow station (a public forecast service serves those better), for aviation METAR/TAF data, or for hardware from other vendors — every endpoint here requires a Tempest account token and talks to WeatherFlow's consumer API. +- **Generic city forecasts or users without a station** — every endpoint + requires the user's own Tempest station and a personal-use token; use a + public weather service instead. +- **Shakespeare's play *The Tempest*, or any literary/meteorological-theory + question** — this is a station-data CLI, not an encyclopedia. +- **Other vendors' hardware** (Netatmo, Ecowitt, Davis, Ambient) — different + APIs entirely; no endpoint here will accept their devices. +- **Commercial/network-wide data products** — those need WeatherFlow's + TempestONE agreements, not a personal token (see the remote developer + policy). + +## Reference Files + +| File | Read when | +|---|---| +| [references/rest-api-and-auth.md](references/rest-api-and-auth.md) | Working with REST endpoints directly: token auth, StationSet shapes, observation parameters, forecast units, error signatures | +| [references/udp-broadcast-protocol.md](references/udp-broadcast-protocol.md) | Parsing raw UDP datagrams: port 50222 transport, every message family's layout, the type-dispatch rule | +| [references/observation-layouts-and-units.md](references/observation-layouts-and-units.md) | Decoding positional observation arrays by index (obs_st/obs_air/obs_sky, UDP vs REST lengths) and unit conversion tables | +| [references/cli-worked-recipes.md](references/cli-worked-recipes.md) | Copy-paste multi-step CLI recipes with jq stages, dry-run plans, and expected error paths | + +## Available Scripts + +- [scripts/tempest](scripts/tempest) — the CLI: `stations`, `current`, `obs`, + `forecast`, `udp listen`; global `--json`, `--dry-run`, `--quiet`, + `--verbose` accepted in any position; offline dry-run plans for every + command. +- [scripts/test_tempest.py](scripts/test_tempest.py) — offline suite: canned + UDP datagram bytes fed to the decoder (no sockets), mocked REST transport, + both pytest and unittest runners. + +## Prerequisites + +- Python 3.8+ with `requests` (the only dependency). +- `TEMPEST_TOKEN` for REST commands (free, personal use; created in the + Tempest web app). UDP listening needs no token, only line-of-sight to the + hub's LAN. diff --git a/tempest/evals/evals.json b/tempest/evals/evals.json new file mode 100644 index 0000000..6f2e950 --- /dev/null +++ b/tempest/evals/evals.json @@ -0,0 +1,92 @@ +{ + "schema_version": 1, + "skill_name": "tempest", + "evals": [ + { + "id": "current-conditions-from-station", + "prompt": "What's the temperature, wind, and rain at my Tempest station right now? Give it to me as JSON I can pipe to jq.", + "expected_output": "Export TEMPEST_TOKEN (create it in the Tempest web app: Settings -> Data Authorizations -> Create Token), then run tempest current --json. Auto-selection picks your first station and its ST device (skipping HB hubs). The .observation object carries metric-native named fields: air_temperature (C), wind_avg (m/s), rain_accumulation (mm), station_pressure (MB), relative_humidity (%). Use --station-id/--device-id only when you own several stations.", + "assertions": [ + "exports TEMPEST_TOKEN and runs tempest current --json", + "reads metric-native fields air_temperature, wind_avg, and rain_accumulation from .observation", + "does not invent a local_time or hourly.local_time field anywhere", + "does not present imperial units as the wire values without converting" + ] + }, + { + "id": "stations-to-current-pipeline", + "prompt": "I have two Tempest stations. Figure out their IDs and then pull the latest reading from the backyard one, chained so I can re-run it.", + "expected_output": "Discover first: tempest stations --json emits {\"stations\": [...]} with integer station_id and a devices[] array where each device has device_id, device_type (HB hub, ST Tempest, AR Air, SK Sky), and serial_number. Filter device_type == \"ST\" (never HB - hubs carry no observations) and feed those integers to tempest current --station-id --device-id --json. The two stages compose because stations --json device_id/station_id are the same integer types current's flags accept.", + "assertions": [ + "runs tempest stations --json first and extracts station_id and device_id as integers", + "filters out device_type HB hubs before choosing the observation device", + "passes the extracted ids to tempest current --station-id/--device-id", + "does not call an undocumented /user/devices endpoint" + ] + }, + { + "id": "forecast-units-double-conversion-gotcha", + "prompt": "Why does my script show 172 degrees for my Tempest forecast after I switched the station display to Fahrenheit? The high today is definitely not 172.", + "expected_output": "Double conversion. The /better_forecast endpoint is unit-selectable, not Celsius-locked: it honors units_temp=f (default c) and reports what it used in response.units. Your script converted an already-Fahrenheit response with C->F math (77.7 * 9/5 + 32 = 172). Fix: read .forecast.units.units_temp before converting anything, or request explicit units. With the CLI: tempest forecast --json already handles this - its human output converts only Celsius stations, and raw --json values stay in the units the response declared.", + "assertions": [ + "explains the 172 value as a double conversion of an already-Fahrenheit response", + "states the endpoint honors units_temp=f and reports units in the response", + "instructs reading .forecast.units.units_temp before converting", + "does not claim the forecast endpoint is always Celsius regardless of parameters" + ] + }, + { + "id": "udp-message-family-dispatch", + "prompt": "I'm parsing my Tempest hub's UDP broadcast on port 50222 in Python. I keep getting TypeError when a rapid wind message shows up, and my parser never sees rain-start events. What's wrong?", + "expected_output": "Message families are structurally different - dispatch on the top-level \"type\" before indexing. obs_st/obs_air/obs_sky nest report rows under \"obs\" (msg[\"obs\"][0][7] is temperature); rapid_wind carries ONE 3-element array under \"ob\" ([epoch, m/s, degrees]) - iterating it element-wise like an obs row list is exactly the TypeError you hit; evt_precip and evt_strike carry ONE array under \"evt\" ([epoch] and [epoch, km, energy]); hub_status and device_status have named fields (uptime, rssi, seq, reset_flags, sensor_status) and no payload array at all. Each UDP datagram is one complete JSON object; bind 0.0.0.0:50222 and listen only - the hub never expects a reply.", + "assertions": [ + "dispatches on the type field before any positional indexing", + "reads rapid_wind speed from the single ob array as ob[1], not by iterating it", + "distinguishes obs families (list under obs) from evt families (single array under evt) and status families (named fields)", + "binds the listener to UDP port 50222 and treats it as listen-only broadcast" + ] + }, + { + "id": "obs-st-positional-array-decode", + "prompt": "Decode this raw obs_st payload from my Tempest: [1588948614, 0.18, 0.22, 0.27, 144, 6, 1017.57, 22.37, 50.26, 328, 0.03, 3, 0.0, 0, 0, 0, 2.410, 1]. What's the temperature and wind?", + "expected_output": "obs_st is a positional array - meaning comes from the index. Index 0 epoch 1588948614 (2020-05-07 UTC); index 1-3 wind lull/avg/gust 0.18/0.22/0.27 m/s; index 4 wind direction 144 degrees (SE); index 6 station pressure 1017.57 MB (millibars, same as hPa); index 7 air temperature 22.37 C (72.3 F); index 8 humidity 50.26%; index 12 rain 0.0 mm this minute; index 16 battery 2.410 V (healthy, about 2.4 nominal); index 17 report interval 1 minute. The UDP broadcast record ends at index 17; REST adds Nearcast rain fields 18-21 for 22 positions - tolerate both lengths.", + "assertions": [ + "maps index 7 to air temperature 22.37 C and index 6 to pressure in MB/hPa", + "maps indices 1-3 to wind lull/average/gust in m/s and index 4 to direction", + "notes the UDP record stops at index 17 while the REST record has 22 positions", + "does not misread index 6 pressure as temperature or vice versa" + ] + }, + { + "id": "metric-native-units-and-conversions", + "prompt": "Are the values from my Tempest station in Fahrenheit and mph? I want mph wind and inches of rain in my dashboard.", + "expected_output": "No - the wire is metric-native everywhere: wind m/s, rain mm, temperature C, pressure MB (millibars, numerically hPa - not kPa), lightning distance km. Conversion is the caller's job: mph = m/s * 2.237, inches = mm / 25.4, F = C * 9/5 + 32, inHg = MB * 0.02953. The CLI converts only for human display; --json stays metric-native so jq can convert: tempest current --json | jq '{wind_mph: (.observation.wind_avg * 2.237), rain_in: (.observation.rain_accumulation / 25.4)}'.", + "assertions": [ + "states observations are metric-native (m/s, mm, C, MB) with conversion as the caller's job", + "provides the m/s-to-mph and mm-to-inches conversion formulas or a jq snippet", + "does not claim UDP or raw JSON values arrive in imperial units", + "uses MB or hPa for pressure, not kPa" + ] + }, + { + "id": "not-shakespeare-the-tempest", + "prompt": "Analyze the opening storm scene of Shakespeare's play The Tempest and explain how Prospero raises the tempest.", + "expected_output": "This must not trigger the tempest skill: it is a literature question about Shakespeare's play, not a request for WeatherFlow weather-station data. The tempest skill operates a personal weather station (REST token auth, UDP port 50222 broadcasts) and has nothing to say about the play. Route this to literary analysis instead.", + "assertions": [ + "must not trigger the tempest skill for the Shakespeare play", + "recognizes the question as literary analysis of The Tempest", + "does not invoke station APIs, tokens, or UDP ports for this prompt" + ] + }, + { + "id": "not-generic-weather-forecast", + "prompt": "What's the weather forecast for Paris tomorrow? I don't own any weather station.", + "expected_output": "This must not trigger the tempest skill: every endpoint it drives requires the user's own WeatherFlow Tempest station and a personal-use token, and UDP listening requires a hub on the LAN. A user with no station asking for a generic city forecast needs a public weather service or forecast skill, not this station tool. Only load tempest when the user owns or manages a Tempest/WeatherFlow station.", + "assertions": [ + "must not trigger the tempest skill for a generic city forecast", + "notes the skill requires the user's own Tempest station and token", + "routes the request to a public forecast service instead" + ] + } + ] +} diff --git a/tempest/references/cli-worked-recipes.md b/tempest/references/cli-worked-recipes.md new file mode 100644 index 0000000..347f2b0 --- /dev/null +++ b/tempest/references/cli-worked-recipes.md @@ -0,0 +1,160 @@ +# CLI Worked Recipes (tempest) + +Multi-step, executable recipes for the bundled `tempest` CLI. Global flags +`--json`, `--dry-run`, `--quiet`, `--verbose` work in any position on the +command line. `--json` output is metric-native (raw wire units); human output +is converted. `--dry-run` never touches the network and always exits 0 with a +plan object. + +## Recipe 1: Discover the station, then read current conditions + +```bash +# Step 1: find station and device ids (works even before you memorize ids) +tempest stations --json | jq '.stations[] | {station_id, name, + devices: [.devices[] | {device_id, device_type, serial_number}]}' + +# Step 2: current conditions, machine-readable +tempest current --json | jq '{station, device_id, type, + temp_c: .observation.air_temperature, + wind_mps: .observation.wind_avg, + rain_mm: .observation.rain_accumulation}' + +# Step 3 (pin a specific station/device when several exist) +tempest current --station-id 12799 --device-id 60526 --json +``` + +Stage compatibility: `stations --json` emits `{"stations": [...]}` with +integer `station_id`/`device_id` fields — feed those ints to +`--station-id`/`--device-id` on `current`. `current --json` emits +`{station, device_id, type, observation}` where `observation` carries the +decoded positional array as named fields (metric-native types: numbers for +measurements, `timestamp` as ISO-8601 string). + +Auto-selection rules when you don't pass ids: the first station is used; the +device is the first `ST` (Tempest), then `SKY`/`SK`, then `AIR`/`AR`, always +skipping `HB` hubs (hubs carry no observations). If only a hub exists the CLI +dies with a clear error instead of guessing. + +## Recipe 2: 7-day forecast slice for scripts + +```bash +tempest forecast --days 7 --json \ + | jq '{units_temp: .forecast.units.units_temp, + today: (.forecast.forecast.daily[0] + | {day_start_local, air_temp_high, air_temp_low, precip_probability}), + next12: [.forecast.forecast.hourly[:12][] + | {local_hour, air_temperature, precip_probability}]}' +``` + +Converting highs to °F with jq (read `units` from the same document before +converting anything): + +```bash +tempest forecast --json \ + | jq '{units_temp: .forecast.units.units_temp, + highs_f: [.forecast.forecast.daily[] | .air_temp_high * 9 / 5 + 32], + rain_hours: [.forecast.forecast.hourly[] | select(.precip_probability > 30) | .local_hour]}' +``` + +**Convert only after reading `units`:** the endpoint honors unit overrides +(`units_temp=f` etc.), so hard-coded Celsius math double-converts Fahrenheit +responses. When the CLI displays forecast values it converts °C→°F only for +stations whose `units_temp` is `c`. Human output prints current conditions, +then the daily table, then the next 12 hours. + +## Recipe 3: Rain-watch (yesterday's total + live rain events) + +```bash +# What fell yesterday (UTC day): obs from history, day_offset=1 +DEVICE_ID=$(tempest stations --json | jq -r ' + .stations[].devices[] | select(.device_type == "ST") | .device_id' | head -1) +tempest obs --device-id "$DEVICE_ID" --days 1 --json \ + | jq '{type, samples: (.observations | length), + day_rain_mm: .observations[-1].local_day_rain_accumulation}' + +# Live: rain-start events and rapid wind from the hub broadcast +tempest udp listen --timeout 600 --json | jq 'select(.type == "evt_precip")' +``` + +Stage compatibility: `obs --json` emits `{device_id, type, count, +observations}` with each decoded observation carrying +`local_day_rain_accumulation` (mm, number) — the `-1` index grabs the newest +sample of the day. `udp listen --json` emits one JSON object per datagram; +`evt_precip` objects carry `{type, serial_number, timestamp}`. + +## Recipe 4: Decode any raw UDP datagram positionally + +Feed canned datagram bytes to the same decoder the listener uses — no +sockets, no hub required (this is exactly how `scripts/test_tempest.py` +exercises the parser): + +```python +# /tmp/decode_one.py +import importlib.machinery, importlib.util, json +loader = importlib.machinery.SourceFileLoader("t", "tempest/scripts/tempest") +spec = importlib.util.spec_from_loader(loader.name, loader) +mod = importlib.util.module_from_spec(spec) +loader.exec_module(mod) + +datagram = (b'{"serial_number":"ST-00000512","type":"obs_st","hub_sn":"HB-00013030",' + b'"obs":[[1588948614,0.18,0.22,0.27,144,6,1017.57,22.37,50.26,328,0.03,3,' + b'0.0,0,0,0,2.410,1]],"firmware_revision":129}') +msg = json.loads(datagram.decode()) +for row in msg["obs"]: # obs families: list of rows + decoded = mod.decode_obs(row, msg["type"]) + print(decoded["air_temperature"], "°C", decoded["air_temperature_unit"]) + +rapid = json.loads(b'{"type":"rapid_wind","ob":[1493322445,2.3,128],"serial_number":"SK-1"}'.decode()) +speed, direction = rapid["ob"][1], rapid["ob"][2] # rapid_wind: ONE array under "ob" +``` + +The three structural keys to remember (see udp-broadcast-protocol.md): +observation families nest rows under `obs`; `rapid_wind` carries one array +under `ob`; events (`evt_precip`, `evt_strike`) carry one array under `evt`; +`hub_status`/`device_status` have named fields and no array at all. Dispatch +on `type` before indexing. + +## Recipe 5: Dry-run previews and flag behavior + +```bash +# Plan, don't execute: valid JSON, exit 0, zero network +tempest forecast --station-id 12799 --days 3 --dry-run --json +# -> {"dry_run": true, "command": "forecast", "station_id": 12799, "days": 3} + +# Every documented command has a dry-run plan: current, obs, forecast, stations +tempest obs --device-id 60526 --days 2 --dry-run --json + +# Quiet/verbose piping: logs on stderr, data on stdout +tempest current --json --quiet | jq .observation.air_temperature +``` + +Behavior contract: `--dry-run` works without `TEMPEST_TOKEN` set (no credential +needed to see a plan); `--help` and `--dry-run` are always offline. Without +`--dry-run`, a missing token exits 1 with +`Error: TEMPEST_TOKEN not set...` before any request is attempted. + +## Recipe 6: JSON error paths you'll actually see + +```bash +tempest current --station-id 99999999 +# Error: Station 99999999 not found. (exit 1) + +tempest obs --device-id 123 # hub or wrong device +# Error: API error (404): ... (exit 1) + +unset TEMPEST_TOKEN; tempest stations +# Error: TEMPEST_TOKEN not set. Get one at https://weatherflow.com (exit 1) +``` + +The client maps 401 → token message, 403 → access-denied message, 404 → +not-found-with-path, and any other ≥400 dumps the response body. In `--json` +mode errors still go to stderr as text; only success payloads print to stdout, +so `jq` pipelines fail loudly instead of parsing prose. + +## Sources + +- https://apidocs.tempestwx.com/reference/quick-start (token setup, REST examples, primary-source guidance) +- https://apidocs.tempestwx.com/reference/get_stations (StationSet shape feeding the stations command) +- https://apidocs.tempestwx.com/reference/getobservationsbydeviceid (device observation parameters used by current/obs) +- https://apidocs.tempestwx.com/reference/get_better-forecast-1 (forecast unit selection used by recipe 2) +- https://weatherflow.github.io/Tempest/api/udp/v171/ (UDP message families used by recipes 3–4) diff --git a/tempest/references/observation-layouts-and-units.md b/tempest/references/observation-layouts-and-units.md new file mode 100644 index 0000000..be40fb9 --- /dev/null +++ b/tempest/references/observation-layouts-and-units.md @@ -0,0 +1,175 @@ +# Observation Layouts and Units (obs_st, obs_air, obs_sky) + +Observations arrive as **positional arrays**: a list of values whose meaning +depends on the array index. The `type` field on the containing object selects +the layout (`obs_st` = Tempest all-in-one, `obs_air` = Air, `obs_sky` = Sky). +There are no field names on the wire — any decoder is a table like the ones +below, and reading the wrong index silently yields a wrong value (e.g. +treating index 6 pressure as index 7 temperature). + +Two different record lengths exist for obs_st: REST returns **22 positions** +and the UDP broadcast returns **18** (the four Nearcast/analysis fields are +REST-only). obs_air is 8 positions in both transports; obs_sky is 17 over +REST and 14 over UDP. + +## obs_st — Tempest all-in-one (REST record, 22 positions) + +| Index | Field | Units | Notes | +|---:|---|---|---| +| 0 | timestamp | epoch seconds, UTC | | +| 1 | wind lull | m/s | minimum 3-second sample | +| 2 | wind average | m/s | average over report interval | +| 3 | wind gust | m/s | maximum 3-second sample | +| 4 | wind direction | degrees | 0 = N | +| 5 | wind sample interval | seconds | | +| 6 | station pressure | MB (millibars) | ≡ hPa; raw sensor pressure, not sea-level | +| 7 | air temperature | °C | | +| 8 | relative humidity | % | | +| 9 | illuminance | lux | | +| 10 | UV | index | | +| 11 | solar radiation | W/m² | | +| 12 | rain accumulation | mm | during the reporting interval | +| 13 | precipitation type | enum | 0 none, 1 rain, 2 hail, 3 rain + hail (experimental) | +| 14 | lightning strike average distance | km | | +| 15 | lightning strike count | count | during the reporting interval | +| 16 | battery | volts | ≈2.4 nominal; below ≈2.3 plan service | +| 17 | report interval | minutes | | +| 18 | local day rain accumulation | mm | midnight-to-midnight, station timezone | +| 19 | Nearcast rain accumulation | mm | REST only | +| 20 | local day Nearcast rain accumulation | mm | REST only | +| 21 | precipitation analysis type | enum | 0 none, 1 Nearcast display on, 2 off — REST only | + +UDP `obs_st` datagrams end at index 17 (see udp-broadcast-protocol.md). + +## obs_air — Air sensor (8 positions, both transports) + +| Index | Field | Units | Notes | +|---:|---|---|---| +| 0 | timestamp | epoch seconds, UTC | | +| 1 | station pressure | MB (millibars) | ≡ hPa | +| 2 | air temperature | °C | | +| 3 | relative humidity | % | | +| 4 | lightning strike count | count | during the reporting interval | +| 5 | lightning strike average distance | km | | +| 6 | battery | volts | | +| 7 | report interval | minutes | | + +## obs_sky — Sky sensor (REST record, 17 positions) + +| Index | Field | Units | Notes | +|---:|---|---|---| +| 0 | timestamp | epoch seconds, UTC | | +| 1 | illuminance | lux | | +| 2 | UV | index | | +| 3 | rain accumulation | mm | during the reporting interval | +| 4 | wind lull | m/s | | +| 5 | wind average | m/s | | +| 6 | wind gust | m/s | | +| 7 | wind direction | degrees | | +| 8 | battery | volts | | +| 9 | report interval | minutes | | +| 10 | solar radiation | W/m² | | +| 11 | local day rain accumulation | mm | **always null over UDP** — REST supplies it | +| 12 | precipitation type | enum | 0 none, 1 rain, 2 hail, 3 rain + hail | +| 13 | wind sample interval | seconds | | +| 14 | Nearcast rain accumulation | mm | REST only | +| 15 | local day Nearcast rain accumulation | mm | REST only | +| 16 | precipitation analysis type | enum | 0 none, 1 Nearcast display on, 2 off — REST only | + +UDP `obs_sky` datagrams end at index 13 and always carry `null` at index 11. + +## Daily summary records (obs_*_ext) + +The API also emits midnight-to-midnight daily summaries with their own +discriminators: `obs_st_ext` (34 positions — avg/high/low pressure, +temperature, humidity, illuminance, UV, solar, wind stats, strikes, battery, +day rain, precipitation minutes), `obs_air_ext` (14), and `obs_sky_ext` (22). +They appear in stats/history contexts, not in the minute firehose. Decode +them only from their own `type` — never with the minute-record tables. + +## The units story: metric-native, caller converts + +Every raw value is metric: wind **m/s**, rain **mm**, temperature **°C**, +pressure **MB** (millibars — numerically identical to hPa, *not* kPa), +distance **km**, illuminance **lux**, solar radiation **W/m²**, battery +**volts**. Nothing on the wire is imperial; conversions are the consumer's +job: + +| Wire unit | Imperial | Formula | +|---|---|---| +| °C | °F | `c * 9/5 + 32` | +| m/s | mph | `mps * 2.23694` (≈ ×2.237) | +| m/s | km/h | `mps * 3.6` | +| m/s | knots | `mps * 1.94384` | +| MB (hPa) | inHg | `mb * 0.02953` | +| mm | inches | `mm / 25.4` | +| km | miles | `km / 1.60934` | + +Two traps: + +1. **`/better_forecast` is unit-selectable, not Celsius-locked.** It defaults + to metric (`units_temp=c`), honors overrides (`units_temp=f`, + `units_wind=mph`, `units_pressure=inhg`, `units_precip=in`, + `units_distance=mi`), and reports what it used in `response.units`. Read + `units` before converting anything, or a Fahrenheit response gets + double-converted into absurd values. +2. **Station vs sea-level pressure.** Index 6 / index 1 pressure is the raw + station pressure. The Tempest app's "relative pressure" adds an elevation + adjustment — don't compare your raw value against the app and conclude the + sensor is broken. + +The bundled CLI keeps `--json` output in metric-native wire units (raw, +lossless — convert with your own jq) and converts only in human display. +Decode positionally with jq like: + +```bash +tempest current --json \ + | jq '{temp_c: .observation.air_temperature, + temp_f: (.observation.air_temperature * 9 / 5 + 32), + wind_mps: .observation.wind_avg, + wind_mph: (.observation.wind_avg * 2.237), + pressure_mb: .observation.station_pressure}' +``` + +## Field type traps in /better_forecast + +The forecast endpoint uses epoch integers where you'd expect date strings, +and field names that differ from what common sense suggests: + +| Field | Actual type | Common mistake | Fix | +|---|---|---|---| +| `daily[].day_start_local` | epoch int (e.g. 1778385600) | assumed ISO string | `datetime.fromtimestamp(ts).strftime(...)` | +| `hourly[].local_hour` | int (0–23) | assumed timestamp string | format directly `{h:02d}:00` | +| `hourly[].local_day` | int (day of month) | N/A | use alongside `local_hour` | +| `hourly[].local_time` | **does not exist** | commonly assumed field | use `local_hour` instead | + +Code looking for `local_time` silently falls back to its default/"?" branch — +no error is raised. + +## Decoding recipe (jq, no script needed) + +Latest REST observation, positionally decoded to named fields: + +```bash +curl -s "https://swd.weatherflow.com/swd/rest/observations/device/$DEVICE_ID?token=$TEMPEST_TOKEN" \ + | jq --argjson layout '["timestamp","wind_lull","wind_avg","wind_gust","wind_direction", + "wind_sample_interval","station_pressure","air_temperature","relative_humidity", + "illuminance","uv","solar_radiation","rain_accumulation","precipitation_type", + "avg_strike_distance","strike_count","battery","report_interval", + "local_day_rain","nc_rain","local_day_nc_rain","precip_analysis_type"]' ' + {type: .type, + obs: (.obs[-1] | [$layout, .] | transpose | map({(.[0]): .[1]}) | add)}' +``` + +The bundled CLI does the same in Python (`decode_obs` in `scripts/tempest`, +driven by the `OBS_ST_FIELDS`/`OBS_AIR_FIELDS`/`OBS_SKY_FIELDS` tables) and +tolerates both UDP-length and REST-length rows. + +## Sources + +- https://apidocs.tempestwx.com/reference/observation-record-format (canonical index tables: obs_st 22, obs_air 8, obs_sky 17, daily _ext records, evt_strike, rapid_wind) +- https://weatherflow.github.io/Tempest/api/swagger/ (legacy response models; better_forecast field types; obs_sky UDP day-rain null note) +- https://weatherflow.github.io/Tempest/api/udp/v171/ (UDP obs_st 18-position record; metric-native units) +- https://apidocs.tempestwx.com/reference/get_better-forecast-1 (unit selection parameters and response `units` object) +- https://apidocs.tempestwx.com/reference/getobservationsbydeviceid (observation set envelope: `obs` array + `type` discriminator) +- https://help.weatherflow.com/hc/en-us/articles/360052101413-Tempest-FAQs (station vs sea-level pressure; battery guidance) diff --git a/tempest/references/rest-api-and-auth.md b/tempest/references/rest-api-and-auth.md new file mode 100644 index 0000000..3319a91 --- /dev/null +++ b/tempest/references/rest-api-and-auth.md @@ -0,0 +1,251 @@ +# Tempest REST API and Authentication + +The Tempest REST API is the cloud service at `https://swd.weatherflow.com/swd/rest`. +It is the primary, recommended data source even for programs running on the same +LAN as the hub; the local UDP broadcast (see udp-broadcast-protocol.md) is +officially positioned as an off-grid backup. Base URL used throughout: + +``` +https://swd.weatherflow.com/swd/rest +``` + +## Authentication: the personal access token + +There are exactly two documented authentication methods, and the bundled CLI +uses the first: + +1. **Personal Access Token** — the right choice for scripts and integrations + without a graphical interface. Sign in to the Tempest Web App + (tempestwx.com), then go to **Settings → Data Authorizations → Create + Token**, and copy the generated token. This is what `TEMPEST_TOKEN` + carries. +2. **OAuth 2.0** (Authorization Code, optionally with PKCE) — the documented + choice for production apps with a web UI. Apps are registered from the + account's Developers page; authorization and token endpoints are documented + separately in the OAuth reference. The CLI does not implement OAuth. + +On the wire, the token travels as a **query parameter**: + +``` +GET https://swd.weatherflow.com/swd/rest/stations?token= +``` + +The official quick-start examples use `token=[your_access_token]` and show no +`Authorization` header alternative for this API. Do not send the token as a +header or assume bearer syntax is supported. The OpenAPI document describes the +scheme as `apiKey` with `in: query`, which matches. + +Policy notes (remote-developer-policy): personal-use access covers station +metadata, observations, and forecasts with "rate/volume limits (enough for +personal use)". No numeric quota is published, and no 429 response behavior is +documented. Higher-volume or network-wide access requires a commercial +agreement (TempestONE). Keep personal integrations to your own stations. + +## Endpoint catalog (personal-use surface) + +### GET /stations — your stations with devices + +Parameters: `limit` (int64, default 10000), `next_cursor` (string; present +when more than 10,000 stations are provisioned), optional geographic filters +(`lat_min`/`lon_min`/`lat_max`/`lon_max` bounding box, or +`center_lat`/`center_lon`/`radius` in meters). + +Response is a **StationSet wrapper**, not a bare list: + +```json +{ + "status": { "status_code": 0, "status_message": "SUCCESS" }, + "stations": [ + { + "station_id": 12799, + "location_id": 12799, + "name": "Home", + "public_name": "Home", + "latitude": 42.37, + "longitude": -71.06, + "timezone": "America/New_York", + "timezone_offset_minutes": -300, + "station_meta": { "elevation": 1567.65, "share_with_wf": true, "share_with_wu": true }, + "is_local_mode": false, + "devices": [ + { + "device_id": 60526, + "serial_number": "ST-00012345", + "device_type": "ST", + "hardware_revision": "3", + "firmware_revision": "165", + "device_meta": { "agl": 2.2, "name": "Backyard", "environment": "outdoor" }, + "device_settings": { "show_precip_final": false }, + "notes": "" + } + ], + "station_items": [ { "item": "air_temperature_humidity", "device_id": 60526, "sort": 0 } ] + } + ] +} +``` + +`device_type` values: `HB` (hub — has **no** observation data), +`ST` (Tempest all-in-one), `AR` (Air sensor), `SK` (Sky sensor). The OpenAPI +enum lists exactly these four. Note that `AR`/`SK` are metadata codes for the +Air/Sky hardware; the observation `type` discriminator for the same hardware is +`obs_air`/`obs_sky`. Always filter `HB` out before auto-selecting a device for +observation calls — the hub has no `/observations/device/{id}` data. A null or +missing `serial_number` on a device means inactive hardware per the legacy docs. + +### GET /stations/{station_id} — one station + +Same Station model; documented responses are 200 and 404 ("Station not found"). +Per the legacy Swagger the body still arrives in the `{stations: [...]}`-style +wrapper shape with the selected station inside, so unwrap defensively rather +than assuming a bare station object. + +### GET /observations/device/{device_id} — device observations + +Query parameters (mutually exclusive modes): + +| Parameter | Meaning | +|---|---| +| `day_offset` | Whole UTC day: `0` = current UTC day, `1` = yesterday UTC | +| `time_start` + `time_end` | UTC epoch-seconds range; one-minute resolution guaranteed for ranges ≤ 5 days | +| `latest=true` | Latest single observation (the CLI's `current` default) | +| `format=csv` | CSV instead of JSON | + +Response is an observation set: `obs` (array of positional arrays, oldest to +newest), `type` (`obs_st` | `obs_air` | `obs_sky` — the layout discriminator), +plus device identity/status fields. Field layouts are in +observation-layouts.md. Documented errors: 404 "Device not found". Passing a +hub `HB` device id yields no observation data. + +### GET /observations/stn/{station_id} — station observations + +Note the segment is **`stn`**, not `stations`. Optional parameters: +`time_start`/`time_end`, `bucket` (`1` | `5` | `30` | `180` minutes; mapped to +1 day / 5 days / 30 days / 180 days of history, and the docs mention `1440` ≈ 4 +years), `ob_fields` selection, and the standard unit parameters. Station +observations are **federated from the station's designated primary sensors**; +device observations are one physical device's raw data. Use station +observations when you want "the station's" reading, device observations when +you care about a specific unit. + +### GET /better_forecast — conditions + daily + hourly + +Parameters: `station_id` (or `lat`/`lon` with optional +`snap_to_nearest_owned_station=true` for within-5 km snapping), plus unit +overrides: `units_temp` (`c`|`f`), `units_wind` (`mph`|`kph`|`kts`|`mps`|`bft`| +`lfm`), `units_pressure` (`mb`|`inhg`|`mmhg`|`hpa`), `units_precip` +(`mm`|`cm`|`in`), `units_distance` (`km`|`mi`). + +Response top level: + +```json +{ + "status": { "status_code": 0, "status_message": "SUCCESS" }, + "current_conditions": { "air_temperature": 18.2, "conditions": "Mostly Clear", "icon": "partly-cloudy-day", "relative_humidity": 61, "station_pressure": 1015.4, "wind_avg": 2.1, "wind_direction": 225, "feels_like": 18.2 }, + "forecast": { + "daily": [ { "day_start_local": 1778385600, "air_temp_high": 25.4, "air_temp_low": 15.1, "conditions": "Partly cloudy", "precip_probability": 10, "precip_type": "rain", "sunrise": 1778378400, "sunset": 1778425200 } ], + "hourly": [ { "time": 1778388000, "local_hour": 10, "local_day": 10, "air_temperature": 19.8, "precip_probability": 5, "conditions": "Sunny" } ] + }, + "units": { "units_temp": "c", "units_wind": "mps", "units_precip": "mm", "units_pressure": "mb", "units_distance": "km" }, + "latitude": 42.37, "longitude": -71.06, + "timezone": "America/New_York", "timezone_offset_minutes": -300 +} +``` + +The critical structural fact: **daily and hourly live under the `forecast` +wrapper key**, not at top level. Reading `data["daily"]` returns nothing. + +Unit behavior: the response honors the requested units and reports what it used +in `units`. Default is Celsius/m/s/mm/mb, but the endpoint is **unit-selectable +— not Celsius-locked**. `units_temp=f` is documented and honored. Any consumer +that hard-codes Celsius conversion must first read `units.units_temp`, or it +will double-convert Fahrenheit responses (see units-and-conversions.md). + +Timestamps: `day_start_local`, `sunrise`, `sunset`, and hourly `time` are +integer epoch seconds. Hourly objects carry `local_hour` (int 0–23) and +`local_day` (int day-of-month); there is **no** `local_time` or `time_string` +field — code expecting one silently falls back to its default branch. + +### Other documented endpoints + +- `GET /diagnostics/{station_id}` — latest station status; 200/401/404. +- `GET /stats/station/{station_id}` — daily/weekly/monthly/annual/all-time + high-low-average statistics; 200/401. +- `GET /metadata/network/stations` and `GET /observations/network/stations` — + network-wide access governed by the remote data policy (not part of the + personal single-station flow). +- Lightning endpoints exist but documented access is for paid subscribers. +- The current docs index does not document `/user/devices` for the consumer + surface — use `/stations` and its nested `devices` array. There is no + `/better_forecast/hourly` route; hourly data is `forecast.hourly` inside the + standard `/better_forecast` response. + +## Error signatures + +| Status | Documented meaning | Practical symptom | +|---|---|---| +| 401 | Unauthorized (documented on forecast/diagnostics/stats) | Missing, revoked, or mistyped token — regenerate at tempestwx.com Settings → Data Authorizations | +| 403 | Not documented for this API | Treat as access-denied to that station/device; verify the token belongs to the station owner | +| 404 | "Station not found" / "Device not found" (documented) | Wrong station/device id, or an `HB` hub id passed to an observation endpoint | + +No JSON error-body schema is published, so parse defensively. No numeric rate +limit or 429 behavior is documented; the policy only promises personal-use +volume is acceptable. The CLI maps 401/403/404 to targeted messages and dumps +the response body for anything else. + +## Worked recipes + +### Recipe A: stations → pick sensor → latest observation + +```bash +# 1. List stations (StationSet wrapper) +curl -s "https://swd.weatherflow.com/swd/rest/stations?token=$TEMPEST_TOKEN" +# 2. Choose a device: devices[].device_type must not be "HB"; prefer ST +# 3. Latest observation for that device +curl -s "https://swd.weatherflow.com/swd/rest/observations/device/$DEVICE_ID?token=$TEMPEST_TOKEN" +``` + +The observation response's `type` field selects the positional layout +(`obs_st`: temperature is index 7, epoch is index 0). One command does all +three steps: `tempest current --json`. + +### Recipe B: station forecast with explicit units + +```bash +curl -s "https://swd.weatherflow.com/swd/rest/better_forecast?station_id=$STATION_ID&units_temp=c&units_wind=mps&units_pressure=mb&units_precip=mm&token=$TEMPEST_TOKEN" \ + | jq '{current: .current_conditions.air_temperature, + days: [.forecast.daily[] | {day_start_local, air_temp_high, air_temp_low}], + units: .units.units_temp}' +``` + +Read `units` instead of assuming units. Extract daily/hourly from +`.forecast.daily` / `.forecast.hourly`. + +### Recipe C: a UTC day of device history + +```bash +# day_offset=1 is yesterday UTC; day_offset=0 is today +curl -s "https://swd.weatherflow.com/swd/rest/observations/device/$DEVICE_ID?day_offset=1&token=$TEMPEST_TOKEN" \ + | jq '{type, count: (.obs | length), first: .obs[0], last: .obs[-1]}' +``` + +For a custom range, send both `time_start` and `time_end` as epoch seconds and +keep the span ≤ 5 days to guarantee one-minute resolution. Do not mix +`day_offset` with `time_start`/`time_end` in one call. + +## Sources + +- https://apidocs.tempestwx.com/reference/quick-start (auth flows, REST examples, primary-source guidance) +- https://apidocs.tempestwx.com/reference/oauth (OAuth 2.0 grant types, app registration) +- https://apidocs.tempestwx.com/reference/get_stations (StationSet/Station/Device OpenAPI schemas) +- https://apidocs.tempestwx.com/reference/getstationbyid-1 (single station, 404 semantics) +- https://apidocs.tempestwx.com/reference/getobservationsbydeviceid (device observation parameters, 404) +- https://apidocs.tempestwx.com/reference/get_observations-stn-station-id (station observations, bucket) +- https://apidocs.tempestwx.com/reference/station-vs-device (device vs station observation semantics) +- https://apidocs.tempestwx.com/reference/get_better-forecast-1 (forecast parameters, unit selection) +- https://apidocs.tempestwx.com/reference/get_diagnostics-station-id-1 (diagnostics endpoint) +- https://apidocs.tempestwx.com/reference/get_stats-station-station-id-1 (stats endpoint) +- https://apidocs.tempestwx.com/reference/observation-record-format (type discriminators, record lengths) +- https://apidocs.tempestwx.com/reference/tempest-udp-broadcast (UDP as backup to REST) +- https://weatherflow.github.io/Tempest/api/swagger/ (legacy response models: forecast nesting, obs_sky null day-rain) +- https://weatherflow.github.io/Tempest/api/remote-developer-policy.html (personal-use policy, rate/volume limits) diff --git a/tempest/references/tempest-api-field-layouts.md b/tempest/references/tempest-api-field-layouts.md deleted file mode 100644 index 16c291e..0000000 --- a/tempest/references/tempest-api-field-layouts.md +++ /dev/null @@ -1,128 +0,0 @@ -# Tempest API Field Layouts - -Quick reference for the obs_st (Tempest all-in-one) observation array layout. -The API returns observations as positional arrays — these index maps are -required for any CLI or script that reads raw observation data. - -## obs_st (Tempest Device) - -| Index | Field | Units | Notes | -|-------|-------|-------|-------| -| 0 | epoch | seconds UTC | | -| 1 | wind_lull | m/s | Minimum 3-second sample | -| 2 | wind_avg | m/s | Average over report interval | -| 3 | wind_gust | m/s | Maximum 3-second sample | -| 4 | wind_direction | degrees | 0-360 | -| 5 | wind_sample_interval | seconds | | -| 6 | station_pressure | MB | | -| 7 | air_temperature | C | | -| 8 | relative_humidity | % | | -| 9 | illuminance | lux | | -| 10 | uv | index | | -| 11 | solar_radiation | W/m² | | -| 12 | rain_accumulation | mm | Over last report interval | -| 13 | precipitation_type | 0=none 1=rain 2=hail | | -| 14 | avg_strike_distance | km | | -| 15 | strike_count | count | | -| 16 | battery | volts | | -| 17 | report_interval | minutes | | -| 18 | local_day_rain_accumulation | mm | | -| 19 | nc_rain_accumulation | mm | | -| 20 | local_day_nc_rain_accumulation | mm | | -| 21 | precip_analysis_type | enum | 0=none, 1=RainCheck display on, 2=off | - -## obs_air (Air Sensor) - -| Index | Field | Units | -|-------|-------|-------| -| 0 | epoch | seconds UTC | -| 1 | station_pressure | MB | -| 2 | air_temperature | C | -| 3 | relative_humidity | % | -| 4 | lightning_strike_count | count | -| 5 | lightning_avg_distance | km | -| 6 | battery | volts | -| 7 | report_interval | minutes | - -## obs_sky (Sky Sensor) - -| Index | Field | Units | Notes | -|-------|-------|-------|-------| -| 0 | epoch | seconds UTC | | -| 1 | illuminance | lux | | -| 2 | uv | index | | -| 3 | rain_accumulation | mm | | -| 4 | wind_lull | m/s | | -| 5 | wind_avg | m/s | | -| 6 | wind_gust | m/s | | -| 7 | wind_direction | degrees | | -| 8 | battery | volts | | -| 9 | report_interval | minutes | | -| 10 | solar_radiation | W/m² | | -| 11 | local_day_rain_accumulation | mm | | -| 12 | precipitation_type | 0=none 1=rain 2=hail | | -| 13 | wind_sample_interval | seconds | | -| 14 | nc_rain | mm | | -| 15 | local_day_nc_rain | mm | | -| 16 | precip_analysis_type | 0=none 1=RainCheck on 2=off | | - -## API Response Quirks - -### better_forecast nesting -The `daily` and `hourly` arrays live under a `forecast` wrapper key, NOT at the -top level of the response. If reading from the raw API: - -```python -# Wrong (assumes top-level): -days = data.get("daily", []) # returns [] - -# Right (respects nesting): -fc = data.get("forecast", {}) -days = fc.get("daily", []) -hours = fc.get("hourly", []) -``` - -The top-level keys of `/better_forecast` are: -- `current_conditions` — dict with air_temperature, conditions, icon, etc. -- `forecast` — dict containing `daily` (list) and `hourly` (list) -- `station` — metadata (elevation, agl, station_id) -- `units` — unit system for the response -- `status` — status_code, status_message -- `timezone`, `timezone_offset_minutes`, `latitude`, `longitude`, `location_name` - -### Device types in /stations -Devices within a station have a `device_type` field. Known values: -- `HB` — Hub (cannot query observations — no `/observations/device/{id}` endpoint) -- `ST` — Tempest all-in-one (preferred sensor) -- `SKY` — Sky sensor -- `AIR` — Air sensor - -Always filter out HB devices before auto-selecting a device for observation queries. - -### Auth -Token is passed as query parameter: `?token=XXX` -No header-based auth for the swd.weatherflow.com REST API. -Personal access tokens generated at https://weatherflow.com (account → API Tokens). - -### Units -Raw observations use metric (C, m/s, MB, mm). -The `better_forecast` endpoint returns unit-converted values based on station -preferences. The `units` key in the response documents which units are in use. -All temperature values are in **Celsius** regardless of station preference — CLI -must convert to °F if displaying imperial. Unit labels from the API are authority. - -### Field type traps in better_forecast - -The forecast endpoint uses epoch integers where you'd expect date strings, and -field names that differ from what common sense suggests: - -| Field | Actual type | Common mistake | Fix | -|-------|-----------|---------------|-----| -| `daily[].day_start_local` | epoch int (e.g. 1778385600) | Assumed ISO string "2026-05-10T..." | `datetime.fromtimestamp(ts).strftime(...)` | -| `hourly[].local_hour` | int (e.g. 10) | Assumed ISO timestamp string | Use directly as `{h:02d}:00` | -| `hourly[].local_day` | int (e.g. 10 for the 10th) | N/A | Use alongside `local_hour` for time-of-day | -| `hourly[].local_time` | **does not exist** | Commonly assumed field | Use `local_hour` instead | - -The hourly objects do NOT have a `local_time` or `time_string` field — just -`time` (epoch int), `local_day` (int), and `local_hour` (int, 0-23). Any code -looking for `local_time` will silently fall back to its default/"?" branch. diff --git a/tempest/references/udp-broadcast-protocol.md b/tempest/references/udp-broadcast-protocol.md new file mode 100644 index 0000000..3206965 --- /dev/null +++ b/tempest/references/udp-broadcast-protocol.md @@ -0,0 +1,294 @@ +# Tempest UDP Broadcast Protocol (Port 50222) + +The Tempest hub broadcasts JSON messages to the local network on **UDP port +50222**. A listener on the same LAN receives every message the hub publishes: +observations, rapid wind updates, precipitation and lightning events, and +hub/device status. No subscription, pairing, or token is involved — the hub +broadcasts regardless; point a listener at port 50222 and read. + +Positioning per WeatherFlow: REST/WebSocket are the primary data interfaces, +and the UDP broadcast is officially recommended for completely off-grid +applications or as a backup. It is nevertheless the lowest-latency feed on +your LAN (rapid wind arrives every ~3 seconds; hub status roughly once a +minute). + +## Transport facts + +- **Port:** 50222, UDP, local broadcast. Routed/internet reachability is not + enough — the listener must share the hub's L2 network (same subnet/VLAN, or + a DHCP/helper forwarding broadcasts). +- **Direction:** the hub sends, listeners receive. The protocol defines no + acknowledgement or response message; treat it as listen-only. Bind to + `0.0.0.0:50222` with `SO_REUSEADDR` and read datagrams. +- **Framing:** each UDP datagram carries one complete JSON message (UTF-8). + Never concatenate datagrams or expect TCP-style stream framing. (UTF-8 and + one-JSON-per-datagram are the interoperable reading of the protocol's JSON + examples; the official pages do not spell the encoding out.) +- **No auth:** the broadcast carries no token and cannot be restricted from + the hub; anyone on the LAN can read your station's data. This is why the + broadcast is LAN-only. + +## THE dispatch rule: message families are structurally different + +Every message carries a top-level `"type"`. The payload key and array shape +**change with the type** — a parser that blindly indexes a position will +crash or misread. Dispatch on `type` BEFORE indexing: + +| `type` | Payload key | Payload shape | +|---|---|---| +| `obs_st`, `obs_air`, `obs_sky` | `obs` | list containing observation arrays (one per report): `msg["obs"][0][7]` | +| `rapid_wind` | `ob` | ONE 3-element array: `msg["ob"][1]` is wind speed | +| `evt_precip` | `evt` | ONE 1-element array: `msg["evt"][0]` is epoch | +| `evt_strike` | `evt` | ONE 3-element array: epoch, distance km, energy | +| `hub_status` | (named fields) | no payload array: `uptime`, `rssi`, `seq`, `fs`, `radio_stats`, `mqtt_stats` | +| `device_status` | (named fields) | no payload array: `uptime`, `voltage`, `rssi`, `hub_rssi`, `sensor_status` | + +The observation families nest arrays inside a list; `rapid_wind` and the +events carry a single array under a *different key* (`ob` / `evt`); the +status families carry named scalar fields and small status arrays. Iterating +`rapid_wind`'s `ob` array element-wise the way you would `obs` rows is a +classic crash (TypeError on the epoch number) — this is exactly the trap the +dispatch rule exists for. + +## obs_st — Tempest all-in-one observation (UDP form) + +Broadcast roughly once per report interval (default 1 minute). The UDP +datagram carries **18 positions (indices 0–17)**: + +```json +{ + "serial_number": "ST-00000512", + "type": "obs_st", + "hub_sn": "HB-00013030", + "obs": [[1588948614, 0.18, 0.22, 0.27, 144, 6, 1017.57, 22.37, 50.26, 328, 0.03, 3, 0.000000, 0, 0, 0, 2.410, 1]], + "firmware_revision": 129 +} +``` + +| Index | Field | Units | +|---:|---|---| +| 0 | timestamp | epoch seconds, UTC | +| 1 | wind lull (min 3-second sample) | m/s | +| 2 | wind average | m/s | +| 3 | wind gust (max 3-second sample) | m/s | +| 4 | wind direction | degrees (0 = N) | +| 5 | wind sample interval | seconds | +| 6 | station pressure | MB (millibars; numerically identical to hPa) | +| 7 | air temperature | °C | +| 8 | relative humidity | % | +| 9 | illuminance | lux | +| 10 | UV | index | +| 11 | solar radiation | W/m² | +| 12 | rain accumulation over previous minute | mm | +| 13 | precipitation type | 0 none, 1 rain, 2 hail, 3 rain + hail (experimental) | +| 14 | lightning strike average distance | km | +| 15 | lightning strike count | count | +| 16 | battery | volts (≈2.4 nominal; low below ≈2.3) | +| 17 | report interval | minutes | + +**UDP vs REST length:** the REST observation record extends the same array +with four Nearcast/analysis fields — index 18 local-day rain accumulation +(mm), 19 Nearcast rain accumulation (mm), 20 local-day Nearcast rain +accumulation (mm), 21 precipitation analysis type (0 none, 1 Nearcast display +on, 2 off) — for 22 positions total. The UDP broadcast stops at 17. A decoder +must tolerate both lengths (the bundled `decode_obs` does) and never assume +the extra fields exist over UDP. + +## rapid_wind — 3-second wind snapshot + +Broadcast every ~3 seconds between observation reports. Layout differs from +obs_st: payload key is `ob`, a single 3-element array (speed is already +m/s — no conversion on the wire, only when displaying mph): + +```json +{ + "serial_number": "SK-00008453", + "type": "rapid_wind", + "hub_sn": "HB-00000001", + "ob": [1493322445, 2.3, 128] +} +``` + +| Index | Field | Units | +|---:|---|---| +| 0 | timestamp | epoch seconds, UTC | +| 1 | wind speed | m/s | +| 2 | wind direction | degrees | + +## evt_precip — rain-start event + +Fires when the haptic rain sensor detects the start of rainfall (more than +five seconds of continuous rain). Payload key `evt`, one element: + +```json +{ + "serial_number": "SK-00008453", + "type": "evt_precip", + "hub_sn": "HB-00000001", + "evt": [1493322445] +} +``` + +| Index | Field | Units | +|---:|---|---| +| 0 | timestamp | epoch seconds, UTC | + +## evt_strike — lightning strike event + +Payload key `evt`, three elements. The energy unit is not specified in the +official reference: + +```json +{ + "serial_number": "AR-00004049", + "type": "evt_strike", + "hub_sn": "HB-00000001", + "evt": [1493322445, 27, 3848] +} +``` + +| Index | Field | Units | +|---:|---|---| +| 0 | timestamp | epoch seconds, UTC | +| 1 | distance | km | +| 2 | energy | undocumented unit | + +## hub_status — hub heartbeat (roughly once a minute) + +**No payload array at all** — named scalar fields plus small status arrays. +Note `firmware_revision` arrives as a string here (number in observation +messages): + +```json +{ + "serial_number": "HB-00000001", + "type": "hub_status", + "firmware_revision": "35", + "uptime": 1670133, + "rssi": -62, + "timestamp": 1495724691, + "reset_flags": "BOR,PIN,POR", + "seq": 48, + "fs": [1, 0, 15675411, 524288], + "radio_stats": [2, 1, 0, 3, 2839], + "mqtt_stats": [1, 0] +} +``` + +- `uptime` (s), `rssi` (dBm; closer to 0 is stronger), `timestamp` (epoch + seconds), `seq` (monotonic message counter — gaps mean lost datagrams). +- `reset_flags`: comma-separated reset causes — BOR, PIN, POR, SFT, WDG, + WWD, LPW, HRDFLT. Repeated watchdog flags suggest power trouble. +- `radio_stats`: [version, reboot count, I2C bus error count, radio status, + radio network ID]; radio status 0 = off, 1 = on, 3 = active, 7 = BLE + connected. +- `fs` and `mqtt_stats` are documented as internal use. +- There is no `freq` or `fs_version` field in the current protocol (both + appear in old integration notes; do not read them — they are always + `None`). + +## device_status — sensor device health (roughly once a minute) + +Also named fields, no payload array: + +```json +{ + "serial_number": "AR-00004049", + "type": "device_status", + "hub_sn": "HB-00000001", + "timestamp": 1510855923, + "uptime": 2189, + "voltage": 3.50, + "firmware_revision": 17, + "rssi": -17, + "hub_rssi": -87, + "sensor_status": 0, + "debug": 0 +} +``` + +`sensor_status` is a decimal **bit flag** field: bits indicate lightning +failed / noise / disturber, pressure failed, temperature failed, humidity +failed, wind failed, precipitation failed, light/UV failed, plus power-booster +flags. `0` means all sensors healthy. Unknown high bits are reserved — ignore +them rather than erroring. + +## Legacy sensors: obs_air and obs_sky + +Older Air/Sky hardware still broadcasts with the same envelope: + +**obs_air** (`obs` list, 8 positions): 0 epoch · 1 pressure MB · 2 air temp °C +· 3 relative humidity % · 4 lightning strike count · 5 lightning average +distance km · 6 battery volts · 7 report interval minutes. + +```json +{"serial_number": "AR-00004049", "type": "obs_air", "hub_sn": "HB-00000001", + "obs": [[1493164835, 835.0, 10.0, 45, 0, 0, 3.46, 1]], "firmware_revision": 17} +``` + +**obs_sky** (`obs` list, 14 positions): 0 epoch · 1 illuminance lux · 2 UV · +3 rain mm · 4 wind lull m/s · 5 wind avg m/s · 6 wind gust m/s · 7 wind +direction deg · 8 battery volts · 9 report interval min · 10 solar radiation +W/m² · 11 local-day rain mm (**always null over UDP** — REST provides it) · +12 precipitation type · 13 wind sample interval s. + +```json +{"serial_number": "SK-00008453", "type": "obs_sky", "hub_sn": "HB-00000001", + "obs": [[1493321340, 9000, 10, 0.0, 2.6, 4.6, 7.4, 187, 3.12, 1, 130, null, 0, 3]], + "firmware_revision": 29} +``` + +## Units are metric-native — conversion is the caller's job + +Every value on the wire is metric: wind **m/s**, rain **mm**, temperature +**°C**, pressure **MB** (≡ hPa — NOT kPa), distance **km**, illuminance +**lux**, solar radiation **W/m²**, battery **volts**. The UDP protocol ships +no unit-selection and no conversion tables; imperial output is entirely your +code's job. The bundled CLI converts for human display and leaves `--json` +values in the metric-native wire units. Station pressure (raw sensor) is not +sea-level pressure — the Tempest app's "relative pressure" applies an +elevation adjustment you must compute separately if you want it. + +## Minimal listener + +```bash +# See the raw firehose before writing any code: +tempest udp listen --timeout 30 # decodes families, hides hub_status +tempest udp listen --timeout 30 --show-all # include hub_status and unknown types +``` + +```python +# Zero-dependency decoder skeleton — dispatch on type, then index. +import json, socket + +sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +sock.bind(("0.0.0.0", 50222)) + +while True: + msg = json.loads(sock.recvfrom(65535)[0].decode("utf-8", errors="replace")) + t = msg.get("type") + if t in ("obs_st", "obs_air", "obs_sky"): + row = msg["obs"][-1] # list of report rows + elif t == "rapid_wind": + row = msg["ob"] # ONE array: [epoch, m/s, degrees] + elif t in ("evt_precip", "evt_strike"): + row = msg["evt"] # ONE array: [epoch] / [epoch, km, energy] + elif t in ("hub_status", "device_status"): + continue # named fields, nothing to index + else: + continue # unknown type: skip, don't crash + print(t, msg.get("serial_number"), row[0]) +``` + +The bundled CLI implements this dispatch in `udp_listen` (see +`scripts/tempest`) with per-family decoders and `--json` output. + +## Sources + +- https://weatherflow.github.io/Tempest/api/udp/v171/ (current UDP protocol reference: all message families, layouts, examples) +- https://weatherflow.github.io/Tempest/api/udp/v143/ (prior protocol revision; family set unchanged) +- https://apidocs.tempestwx.com/reference/tempest-udp-broadcast (UDP documented as backup to REST/WebSocket) +- https://apidocs.tempestwx.com/reference/observation-record-format (REST obs_st Nearcast fields 18–21; evt_strike and rapid_wind record tables) +- https://apidocs.tempestwx.com/reference/quick-start (UDP positioned as backup; REST primary guidance) +- https://help.weatherflow.com/hc/en-us/articles/360052101413-Tempest-FAQs (haptic rain-start behavior, RSSI interpretation, station vs sea-level pressure) diff --git a/tempest/scripts/tempest b/tempest/scripts/tempest index bd5b050..0592f09 100755 --- a/tempest/scripts/tempest +++ b/tempest/scripts/tempest @@ -3,20 +3,27 @@ Two data sources: REST API — stations, observations, forecast via WeatherFlow cloud - UDP/local — real-time broadcast from your hub on port 50222 + (primary, documented source; personal-use token) + UDP/local — real-time JSON broadcast from your hub on port 50222 + (LAN-only backup; no auth, listen-only) -Requires TEMPEST_TOKEN env var (personal access token from weatherflow.com). +Message families over UDP are structurally different (obs_* nest rows under +"obs", rapid_wind carries one array under "ob", evt_* under "evt", +hub_status/device_status use named fields) — decode_message() dispatches on +"type" before any positional indexing. + +Requires TEMPEST_TOKEN env var (personal access token created in the Tempest +web app: Settings -> Data Authorizations). Falls back to ~/.tempest.env. """ import argparse import json import os import socket -import struct import sys import time import warnings -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple # === Suppress dependency warnings before imports === @@ -28,9 +35,31 @@ import requests DEFAULT_SERVER = "https://swd.weatherflow.com/swd/rest" DEFAULT_UDP_PORT = 50222 UDP_BROADCAST_ADDR = "0.0.0.0" +ENV_FILE = os.path.join("~", ".tempest.env") + + +def _load_env_file() -> str: + """Fallback token source: ~/.tempest.env with a TEMPEST_TOKEN= line.""" + path = os.path.expanduser(ENV_FILE) + try: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if line.startswith("TEMPEST_TOKEN="): + value = line.split("=", 1)[1].strip() + return value.strip('"').strip("'") + except OSError: + pass + return "" + + +def resolve_token() -> str: + """TEMPEST_TOKEN env var wins; ~/.tempest.env is the fallback.""" + token = os.getenv("TEMPEST_TOKEN", "").strip() + if token: + return token + return _load_env_file() -ENV_TOKEN = os.getenv("TEMPEST_TOKEN", "") -ENV_SERVER = os.getenv("TEMPEST_SERVER", DEFAULT_SERVER) # === Logging === QUIET = False @@ -74,7 +103,7 @@ def _preparse_global_flags(argv: List[str]) -> Tuple[Dict[str, Any], List[str]]: if arg in GLOBAL_BOOLS: flags[arg.lstrip("-").replace("-", "_")] = True i += 1 - elif arg == "--help" or arg == "-h": + elif arg in ("--help", "-h"): return flags, argv # let argparse handle help elif arg == "--": filtered.extend(argv[i:]) @@ -87,15 +116,15 @@ def _preparse_global_flags(argv: List[str]) -> Tuple[Dict[str, Any], List[str]]: # === Tempest API Client === class TempestClient: - """REST API client for WeatherFlow Tempest.""" + """REST API client for WeatherFlow Tempest (swd.weatherflow.com).""" 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.token = token + self.server = (server or os.getenv("TEMPEST_SERVER", DEFAULT_SERVER)).rstrip("/") self.dry_run = dry_run def _get(self, path: str, params: Optional[Dict] = None) -> Any: - """Generic GET with token auth.""" + """Generic GET with token auth (query parameter per official docs).""" url = f"{self.server}{path}" if params is None: params = {} @@ -110,9 +139,10 @@ class TempestClient: 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.") + die("Auth failed (401). Check your TEMPEST_TOKEN or create a new one " + "in the Tempest web app (Settings -> Data Authorizations).") if resp.status_code == 403: - die("Forbidden (403). Your token may not have access to this station/device.") + die("Forbidden (403). Your token does 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: @@ -130,19 +160,37 @@ class TempestClient: # === 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", []) + """List all stations and their devices. - 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.""" + The documented response is a StationSet wrapper ({stations: [...], + status}); the legacy model used {locations: [...]} and some proxies + have returned a bare list — unwrap all three shapes defensively. + """ + data = self._get("/stations") + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("stations", "locations"): + if isinstance(data.get(key), list): + return data[key] + return [] + + def get_observations(self, device_id: int, days_back: int = 0, + time_start: Optional[int] = None, + time_end: Optional[int] = None) -> Dict: + """Get observations for a device. + + day_offset=N fetches whole UTC day N (0 = today); a time_start/time_end + epoch range overrides it (both, <= 5 days for minute resolution); with + no range parameters the API returns only the latest observation. + """ 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" + if time_end: + params["time_end"] = time_end return self._get(f"/observations/device/{device_id}", params) def get_forecast(self, station_id: int) -> Dict: @@ -150,7 +198,10 @@ class TempestClient: return self._get("/better_forecast", {"station_id": station_id}) -# === Observation decoders === +# === Observation decoders (positional arrays; layout per `type`) === +# REST record lengths: obs_st 22, obs_air 8, obs_sky 17. UDP truncates +# obs_st to 18 and obs_sky to 14 (Nearcast fields are REST-only) — decoders +# tolerate both lengths and emit None for missing trailing positions. OBS_ST_FIELDS = [ ("epoch", "seconds_utc"), @@ -166,7 +217,7 @@ OBS_ST_FIELDS = [ ("uv", "index"), ("solar_radiation", "W/m^2"), ("rain_accumulation", "mm"), - ("precipitation_type", "0=none 1=rain 2=hail"), + ("precipitation_type", "0=none 1=rain 2=hail 3=rain+hail"), ("avg_strike_distance", "km"), ("strike_count", "count"), ("battery", "volts"), @@ -201,23 +252,32 @@ OBS_SKY_FIELDS = [ ("report_interval", "minutes"), ("solar_radiation", "W/m^2"), ("local_day_rain_accumulation", "mm"), - ("precipitation_type", "0=none 1=rain 2=hail"), + ("precipitation_type", "0=none 1=rain 2=hail 3=rain+hail"), ("wind_sample_interval", "seconds"), + ("nc_rain_accumulation", "mm"), + ("local_day_nc_rain_accumulation", "mm"), + ("precip_analysis_type", "type"), ] +_OBS_LAYOUTS = { + "obs_st": OBS_ST_FIELDS, + "obs_air": OBS_AIR_FIELDS, + "obs_sky": OBS_SKY_FIELDS, +} + 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: + """Decode one positional observation array into named fields. + + Values stay metric-native (m/s, mm, C, MB) — conversion is the caller's + job. Rows may be shorter than the full layout (UDP truncates) or longer + (REST extends obs_st); missing positions decode as None. + """ + fields = _OBS_LAYOUTS.get(type_str) + if fields is None: return {f"field_{i}": v for i, v in enumerate(obs_array)} - result = {} + result: Dict[str, Any] = {} 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: @@ -238,7 +298,7 @@ def wind_dir_to_cardinal(deg: Optional[float]) -> str: def format_current(obs: Dict) -> str: - """Format current conditions for human display.""" + """Format current conditions for human display (converted from metric).""" lines = [] if obs.get("air_temperature") is not None: temp_c = obs["air_temperature"] @@ -292,10 +352,170 @@ def format_current(obs: Dict) -> str: return "\n".join(lines) if lines else "(no observations)" +# === UDP message-family dispatch === +# Families differ structurally: obs_* nest report rows under "obs"; +# rapid_wind carries ONE array under "ob"; evt_precip/evt_strike carry ONE +# array under "evt"; hub_status/device_status carry named fields only. +# Dispatch on "type" BEFORE any positional indexing. + +def _iso(ts: Any) -> Any: + """Epoch seconds -> ISO string; pass through anything else.""" + if isinstance(ts, (int, float)): + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + return None + + +def _decode_event(msg: Dict, type_str: str) -> Tuple[str, Dict]: + evt = msg.get("evt") or [] + ts = _iso(evt[0]) if len(evt) > 0 else None + payload: Dict[str, Any] = {"type": type_str, + "serial_number": msg.get("serial_number", msg.get("hub_sn", "?")), + "timestamp": ts} + if type_str == "evt_strike": + dist = evt[1] if len(evt) > 1 else None + energy = evt[2] if len(evt) > 2 else None + payload.update({"distance_km": dist, "energy": energy}) + human = f"⚡ Lightning Strike — distance: {dist} km, energy: {energy} [{ts}]" + else: # evt_precip — rain started + human = f"🌧️ Rain started [{ts}]" + return human, payload + + +def _decode_obs_message(msg: Dict, type_str: str) -> List[Tuple[str, Dict]]: + out: List[Tuple[str, Dict]] = [] + label = {"obs_st": "Tempest Observation", "obs_air": "Air Observation", + "obs_sky": "Sky Observation"}.get(type_str, type_str) + sn = msg.get("serial_number", msg.get("hub_sn", "?")) + for obs_arr in msg.get("obs", []) or []: + decoded = decode_obs(obs_arr, type_str) + out.append((f"\n── {label} from {sn} ──\n{format_current(decoded)}", + {"type": type_str, "serial_number": sn, "observation": decoded})) + return out + + +def _decode_rapid_wind(msg: Dict) -> Tuple[str, Dict]: + # rapid_wind payload is a SINGLE 3-element array under "ob": + # [epoch, wind speed m/s, wind direction degrees]. Do NOT iterate it + # element-wise like an obs row list. + ob = msg.get("ob") or [] + sn = msg.get("serial_number", msg.get("hub_sn", "?")) + ts = _iso(ob[0]) if len(ob) > 0 else None + speed = ob[1] if len(ob) > 1 else None + direction = ob[2] if len(ob) > 2 else None + if isinstance(speed, (int, float)) and isinstance(direction, (int, float)): + card = wind_dir_to_cardinal(direction) + mph = speed * 2.237 + human = f"💨 Rapid Wind: {speed} m/s ({mph:.1f} mph) from {card} ({direction}°) [{ts}]" + else: + human = f"💨 Rapid Wind: {speed} m/s from {direction}° [{ts}]" + payload = {"type": "rapid_wind", "serial_number": sn, + "wind_speed_mps": speed, "wind_direction": direction, "timestamp": ts} + return human, payload + + +def _decode_hub_status(msg: Dict) -> Tuple[str, Dict]: + # hub_status carries named fields (uptime, rssi, seq, reset_flags, + # firmware_revision as a string, plus fs/radio_stats/mqtt_stats arrays). + # There is no "freq" or "fs_version" field in the current protocol. + sn = msg.get("serial_number", "?") + payload = {"type": "hub_status", "serial_number": sn, + "firmware_revision": msg.get("firmware_revision"), + "uptime": msg.get("uptime"), "rssi": msg.get("rssi"), + "seq": msg.get("seq"), "reset_flags": msg.get("reset_flags"), + "radio_stats": msg.get("radio_stats")} + human = (f"[hub_status] {sn} — uptime {payload['uptime']}s, " + f"rssi {payload['rssi']}, seq {payload['seq']}, " + f"reset_flags {payload['reset_flags']}") + return human, payload + + +def _decode_device_status(msg: Dict) -> Tuple[str, Dict]: + # device_status: named fields; sensor_status is a decimal bit-flag field + # (0 = all sensors healthy). + sn = msg.get("serial_number", "?") + payload = {"type": "device_status", "serial_number": sn, + "uptime": msg.get("uptime"), "voltage": msg.get("voltage"), + "firmware_revision": msg.get("firmware_revision"), + "rssi": msg.get("rssi"), "hub_rssi": msg.get("hub_rssi"), + "sensor_status": msg.get("sensor_status")} + human = (f"[device_status] {sn} — voltage {payload['voltage']}V, " + f"rssi {payload['rssi']}, hub_rssi {payload['hub_rssi']}, " + f"sensor_status {payload['sensor_status']}") + return human, payload + + +def decode_message(msg: Dict, show_all: bool = False) -> List[Tuple[str, Dict]]: + """Dispatch one decoded UDP datagram on its `type` and decode it. + + Returns a list of (human_text, json_payload) tuples (observations can + carry multiple report rows). Unknown types and status families are + suppressed unless show_all is set. + """ + msg_type = msg.get("type", "unknown") + if msg_type in ("obs_st", "obs_air", "obs_sky"): + return _decode_obs_message(msg, msg_type) + if msg_type == "rapid_wind": + return [_decode_rapid_wind(msg)] + if msg_type in ("evt_precip", "evt_strike"): + return [_decode_event(msg, msg_type)] + if msg_type == "hub_status": + return [_decode_hub_status(msg)] if show_all else [] + if msg_type == "device_status": + return [_decode_device_status(msg)] if show_all else [] + if show_all: + sn = msg.get("serial_number", msg.get("hub_sn", "?")) + return [(f"[{msg_type}] from {sn}", {"type": msg_type, "serial_number": sn, "raw": msg})] + return [] + + +def handle_datagram(data: bytes, show_all: bool = False) -> List[Tuple[str, Dict]]: + """Decode one raw UDP datagram (bytes) — JSON parse, then family dispatch. + + Bad JSON yields [] (or a raw preview when show_all is set). No sockets + are involved, so canned bytes can be fed directly in tests. + """ + try: + msg = json.loads(data.decode("utf-8", errors="replace")) + except json.JSONDecodeError: + if show_all: + preview = data[:200].decode("utf-8", errors="replace") + return [(f"[raw] {preview}", {"type": "unparseable", "raw": preview})] + return [] + if not isinstance(msg, dict): + return [] + return decode_message(msg, show_all) + + # === CLI Commands === -def cmd_stations(client: TempestClient, args: List[str]) -> None: +def _client_for() -> TempestClient: + return TempestClient(token=resolve_token(), dry_run=GLOBAL_FLAGS.get("dry_run", False)) + + +def _pick_device(devices: List[Dict]) -> Dict: + """Auto-select a sensor device: skip hubs, prefer ST, then SKY/SK, then AIR/AR. + + device_type values per the OpenAPI schema are HB/AR/SK/ST; SKY and AIR + are accepted as legacy aliases for SK and AR. + """ + 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). Hubs carry no observations.") + for preferred in ("ST", "SKY", "SK", "AIR", "AR"): + match = next((d for d in sensors if d.get("device_type") == preferred), None) + if match: + return match + return sensors[0] + + +def cmd_stations(args: argparse.Namespace) -> None: # noqa: ARG001 (uniform handler signature) """List stations and attached devices.""" + if GLOBAL_FLAGS.get("dry_run", False): + emit("[dry-run] Would list stations and devices for your token.", + {"dry_run": True, "command": "stations"}) + return + + client = _client_for() stations = client.get_stations() if not stations: emit("No stations found for this token.", {"stations": []}) @@ -317,28 +537,23 @@ def cmd_stations(client: TempestClient, args: List[str]) -> None: emit("\n".join(output_human), {"stations": stations}) -def cmd_current(client: TempestClient, args: List[str]) -> None: +def cmd_current(args: argparse.Namespace) -> None: """Get latest observations from your station.""" - parser = argparse.ArgumentParser(prog="tempest 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: + if GLOBAL_FLAGS.get("dry_run", False): 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}) + "station_id": args.station_id, "device_id": args.device_id}) return + client = _client_for() 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 args.station_id: + station = next((s for s in stations if s.get("station_id") == args.station_id), None) if not station: - die(f"Station {parsed.station_id} not found.") + die(f"Station {args.station_id} not found.") else: station = stations[0] @@ -346,22 +561,12 @@ def cmd_current(client: TempestClient, args: List[str]) -> None: 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) + if args.device_id: + device = next((d for d in devices if d.get("device_id") == args.device_id), None) + if not device: + die(f"Device {args.device_id} not found on this station.") 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] + device = _pick_device(devices) dev_id = device.get("device_id") log(f"Station: {station.get('name', '?')} Device: {device.get('device_type', '?')} (id={dev_id})") @@ -378,61 +583,54 @@ def cmd_current(client: TempestClient, args: List[str]) -> None: 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}) + 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: +def cmd_obs(args: argparse.Namespace) -> None: """Get historical observations.""" - parser = argparse.ArgumentParser(prog="tempest 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: + if GLOBAL_FLAGS.get("dry_run", False): emit("[dry-run] Would fetch historical observations.", {"dry_run": True, "command": "obs", - "device_id": parsed.device_id, "days": parsed.days}) + "device_id": args.device_id, "days": args.days}) return - obs_data = client.get_observations(parsed.device_id, days_back=parsed.days) + client = _client_for() + obs_data = client.get_observations(args.device_id, days_back=args.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}) + emit("", {"device_id": args.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(f"{len(decoded)} observations from the last {args.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: +def cmd_forecast(args: argparse.Namespace) -> None: """Get forecast — current conditions + daily + hourly.""" - parser = argparse.ArgumentParser(prog="tempest 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: + if GLOBAL_FLAGS.get("dry_run", False): emit("[dry-run] Would fetch hyper-local forecast for your station.", {"dry_run": True, "command": "forecast", - "station_id": parsed.station_id, "days": parsed.days}) + "station_id": args.station_id, "days": args.days}) return + client = _client_for() 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) + if args.station_id: + station = next((s for s in stations if s.get("station_id") == args.station_id), None) else: station = stations[0] if not station: @@ -444,11 +642,23 @@ def cmd_forecast(client: TempestClient, args: List[str]) -> None: 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 + # The response honors unit overrides and reports what it used in `units`. + # Never assume Celsius/m/s: converting an already-imperial response + # double-converts it into absurd values. + units = data.get("units", {}) or {} + temp_is_f = units.get("units_temp", "c") == "f" + wind_unit = units.get("units_wind", "mps") + + def show_temp(value: Optional[float]) -> Optional[float]: + if value is None: + return None + return value if temp_is_f else value * 9 / 5 + 32 + current = data.get("current_conditions", {}) if current: print("── Current Conditions ──") @@ -456,32 +666,26 @@ def cmd_forecast(client: TempestClient, args: List[str]) -> None: 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 + temp = show_temp(current.get("air_temperature")) + if temp is not None: + feels = show_temp(current.get("feels_like")) + if feels is not None: + print(f"Temperature: {temp:.0f}°F (feels like {feels:.0f}°F)") + else: + print(f"Temperature: {temp:.0f}°F") 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(f"Wind: {current['wind_avg']} {wind_unit} {wd}".rstrip()) print() - # Daily forecast daily = fc_data.get("daily", []) if daily: - print(f"── {parsed.days}-Day Forecast ──") - for day in daily[: parsed.days]: + print(f"── {args.days}-Day Forecast ──") + for day in daily[: args.days]: day_start = day.get("day_start_local") if isinstance(day_start, (int, float)): day_str = datetime.fromtimestamp(day_start).strftime("%a %b %d") @@ -489,28 +693,25 @@ def cmd_forecast(client: TempestClient, args: List[str]) -> None: 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 + hi = show_temp(day.get("air_temp_high")) + lo = show_temp(day.get("air_temp_low")) 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 "?" + hi_str = f"{hi:.0f}" if hi is not None else "?" + lo_str = f"{lo:.0f}" if lo 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 "?" + temp = show_temp(h.get("air_temperature")) + temp_str = f"{temp:.0f}" if temp is not None else "?" cond = h.get("conditions", "") precip = h.get("precip_probability") precip_str = f" {precip}%" if precip is not None else "" @@ -521,22 +722,24 @@ def cmd_forecast(client: TempestClient, args: List[str]) -> None: # === UDP Commands === -def udp_listen(args: List[str]) -> None: - """Listen for local UDP broadcasts from the Tempest hub.""" - parser = argparse.ArgumentParser(prog="tempest 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) +def udp_listen(args: argparse.Namespace) -> None: + """Listen for local UDP broadcasts from the Tempest hub (port 50222). + + Listen-only: the hub broadcasts, nothing is ever sent back. Requires + being on the same LAN as the hub — routed connectivity is not enough. + """ + port = args.port + timeout = args.timeout + show_all = args.show_all 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) + sock.bind((UDP_BROADCAST_ADDR, port)) + sock.settimeout(timeout if 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") + log(f"Listening for Tempest UDP broadcasts on port {port}...") + if timeout > 0: + log(f"Will stop after {timeout}s\n") try: start = time.time() @@ -547,63 +750,10 @@ def udp_listen(args: List[str]) -> None: 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 + for human, payload in handle_datagram(data, show_all=show_all): + emit(human, payload) - 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: + if timeout > 0 and time.time() - start > timeout: break except KeyboardInterrupt: @@ -612,16 +762,9 @@ def udp_listen(args: List[str]) -> None: 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 === +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="tempest", description="Hyper-local weather from your Tempest station.", @@ -629,67 +772,73 @@ def main() -> None: ) 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") + p_udp = sub.add_parser("udp", help="Local UDP broadcast commands (port 50222, listen-only)") 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") + p_listen.add_argument("--show-all", action="store_true", + help="Show all message types incl. hub_status/device_status") + return parser + + +_HANDLERS = { + "stations": cmd_stations, + "current": cmd_current, + "obs": cmd_obs, + "forecast": cmd_forecast, + "udp": udp_listen, +} + + +# === Main === + +def main(argv: Optional[List[str]] = None) -> None: + global QUIET + base_argv = argv if argv is not None else sys.argv + GLOBAL_FLAGS.clear() + GLOBAL_FLAGS.update({"json": False, "dry_run": False, "force": False, "quiet": False, "verbose": False}) + flags, filtered_argv = _preparse_global_flags(base_argv) + GLOBAL_FLAGS.update(flags) + QUIET = bool(GLOBAL_FLAGS.get("quiet", False)) + if GLOBAL_FLAGS.get("json", False): + warnings.simplefilter("ignore") + + parser = build_parser() 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)), []) + if args.command == "udp": + if args.udp_command != "listen": + parser.error("udp requires a subcommand: listen") + # UDP listening needs no token — the hub broadcast is unauthenticated. + udp_listen(args) + return - 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:]) + # REST commands need a token unless this is a dry-run plan. + if not resolve_token() and not GLOBAL_FLAGS.get("dry_run", False): + die("TEMPEST_TOKEN not set. Get one at https://weatherflow.com " + "(Tempest web app -> Settings -> Data Authorizations) or export TEMPEST_TOKEN.") - 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) + _HANDLERS[args.command](args) if __name__ == "__main__": diff --git a/tempest/scripts/test_tempest.py b/tempest/scripts/test_tempest.py new file mode 100644 index 0000000..edfe085 --- /dev/null +++ b/tempest/scripts/test_tempest.py @@ -0,0 +1,646 @@ +"""Offline test suite for the bundled tempest CLI. + +All HTTP is mocked at the client seam (TempestClient._get is replaced by a +FakeTransport that records paths/params and returns canned REST documents), +and UDP paths are tested by feeding CANNED DATAGRAM BYTES to the pure +handle_datagram()/decode_message() decoders — no socket is ever created or +bound (the suite never touches socket.socket). The suite is fully offline and +passes the proxy-trap rerun. Tempest is a keyed API, so there are deliberately +NO live-call test cases (the AGENTS.md network policy is mock-everything for +keyed APIs). + +Covers the four contract behavior classes: --help output, argument-error +paths, --dry-run plans, and mocked-client logic — plus the documented +multi-step pipelines (stations -> current, stations -> forecast with unit +conversion, obs day history) and every UDP message family (obs_st UDP 18 +positions vs REST 22, obs_air, obs_sky, rapid_wind's single "ob" array, +evt_precip/evt_strike's single "evt" arrays, hub_status/device_status named +fields) dispatched by type. +""" + +import contextlib +import importlib.machinery +import importlib.util +import io +import json +import pathlib +import sys +import unittest +from unittest.mock import patch + +SCRIPT = pathlib.Path(__file__).resolve().parent / "tempest" +LOADER = importlib.machinery.SourceFileLoader("tempest_cli", str(SCRIPT)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +ts = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = ts # so unittest.mock.patch("tempest_cli....") resolves +LOADER.exec_module(ts) + +# --------------------------------------------------------------------------- +# Canned UDP datagrams (bytes, exactly as the hub broadcasts them). +# obs_st uses the documented 18-position UDP record; REST returns 22. +# --------------------------------------------------------------------------- + +OBS_ST_DATAGRAM = ( + b'{"serial_number":"ST-00000512","type":"obs_st","hub_sn":"HB-00013030",' + b'"obs":[[1588948614,0.18,0.22,0.27,144,6,1017.57,22.37,50.26,328,0.03,3,' + b'0.0,0,0,0,2.410,1]],"firmware_revision":129}' +) +RAPID_WIND_DATAGRAM = ( + b'{"serial_number":"SK-00008453","type":"rapid_wind","hub_sn":"HB-00000001",' + b'"ob":[1493322445,2.3,128]}' +) +EVT_PRECIP_DATAGRAM = ( + b'{"serial_number":"SK-00008453","type":"evt_precip","hub_sn":"HB-00000001",' + b'"evt":[1493322445]}' +) +EVT_STRIKE_DATAGRAM = ( + b'{"serial_number":"AR-00004049","type":"evt_strike","hub_sn":"HB-00000001",' + b'"evt":[1493322445,27,3848]}' +) +HUB_STATUS_DATAGRAM = ( + b'{"serial_number":"HB-00000001","type":"hub_status","firmware_revision":"35",' + b'"uptime":1670133,"rssi":-62,"timestamp":1495724691,"reset_flags":"BOR,PIN,POR",' + b'"seq":48,"fs":[1,0,15675411,524288],"radio_stats":[2,1,0,3,2839],"mqtt_stats":[1,0]}' +) +DEVICE_STATUS_DATAGRAM = ( + b'{"serial_number":"AR-00004049","type":"device_status","hub_sn":"HB-00000001",' + b'"timestamp":1510855923,"uptime":2189,"voltage":3.50,"firmware_revision":17,' + b'"rssi":-17,"hub_rssi":-87,"sensor_status":0,"debug":0}' +) +OBS_AIR_DATAGRAM = ( + b'{"serial_number":"AR-00004049","type":"obs_air","hub_sn":"HB-00000001",' + b'"obs":[[1493164835,835.0,10.0,45,0,0,3.46,1]],"firmware_revision":17}' +) +OBS_SKY_DATAGRAM = ( + b'{"serial_number":"SK-00008453","type":"obs_sky","hub_sn":"HB-00000001",' + b'"obs":[[1493321340,9000,10,0.0,2.6,4.6,7.4,187,3.12,1,130,null,0,3]],' + b'"firmware_revision":29}' +) +GARBAGE_DATAGRAM = b"\x00\x01not-json-at-all" + + +def run_main(argv): + """Run the CLI main() with patched stdout; returns (exit_code, stdout). + + SystemExit is caught and converted to a code so error paths can assert + on exit codes without exception plumbing. + """ + out = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()): + try: + ts.main(["tempest"] + argv) + code = 0 + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1) + return code, out.getvalue() + + +def run_main_err(argv): + """Like run_main but also captures stderr: (exit_code, stdout, stderr).""" + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + ts.main(["tempest"] + argv) + code = 0 + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1) + return code, out.getvalue(), err.getvalue() + + +# --------------------------------------------------------------------------- +# Fake REST transport: records requests, replays canned documents +# --------------------------------------------------------------------------- + +STATION_DOC = { + "status": {"status_code": 0, "status_message": "SUCCESS"}, + "stations": [{ + "station_id": 12799, "name": "Home", "public_name": "Home", + "latitude": 42.37, "longitude": -71.06, + "timezone": "America/New_York", "timezone_offset_minutes": -300, + "station_meta": {"elevation": 1567.65, "share_with_wf": True, "share_with_wu": True}, + "is_local_mode": False, + "devices": [ + {"device_id": 60526, "serial_number": "ST-00012345", "device_type": "ST", + "hardware_revision": "3", "firmware_revision": "165", + "device_meta": {"agl": 2.2, "name": "Backyard", "environment": "outdoor"}}, + {"device_id": 60500, "serial_number": "HB-00000001", "device_type": "HB", + "hardware_revision": "3", "firmware_revision": "35", + "device_meta": {"name": "Hub"}}, + {"device_id": 60599, "serial_number": None, "device_type": "SK", + "hardware_revision": "2", "firmware_revision": "29", + "device_meta": {"name": "Old Sky"}}, + ], + "station_items": [], + }], +} + + +class FakeTransport: + """Replaces TempestClient._get; records every request, replays canned docs.""" + + def __init__(self, responses=None): + self.requests = [] + self.responses = responses or {} + + def __call__(self, path, params=None): + self.requests.append({"path": path, "params": dict(params or {})}) + if path in self.responses: + return self.responses[path] + if path.startswith("/observations/device/"): + return {"obs": [OBS_ROW_ST], "type": "obs_st"} + if path == "/better_forecast": + return FORECAST_DOC + if path == "/stations": + return STATION_DOC + raise AssertionError(f"unexpected path {path}") + + +# Canned REST documents +OBS_ROW_ST = [1650843455, 0.18, 0.22, 0.27, 144, 6, 1017.57, 22.37, 50.26, 328, + 0.03, 3, 0.0, 0, 0, 0, 2.410, 1, 5.2, 4.8, 5.2, 1] +FORECAST_DOC = { + "status": {"status_code": 0, "status_message": "SUCCESS"}, + "current_conditions": {"air_temperature": 18.2, "conditions": "Mostly Clear", + "icon": "partly-cloudy-day", "relative_humidity": 61, + "station_pressure": 1015.4, "wind_avg": 2.1, + "wind_direction": 225, "wind_direction_cardinal": "SW", + "feels_like": 18.2}, + "forecast": { + "daily": [ + {"day_start_local": 1778385600, "air_temp_high": 25.4, "air_temp_low": 15.1, + "conditions": "Partly cloudy", "precip_probability": 10, "precip_type": "rain", + "sunrise": 1778378400, "sunset": 1778425200}, + {"day_start_local": 1778472000, "air_temp_high": 22.0, "air_temp_low": 12.0, + "conditions": "Rainy", "precip_probability": 80, "precip_type": "rain"}, + ], + "hourly": [ + {"time": 1778388000, "local_hour": 10, "local_day": 10, "air_temperature": 19.8, + "precip_probability": 5, "conditions": "Sunny"}, + {"time": 1778391600, "local_hour": 11, "local_day": 10, "air_temperature": 20.4, + "precip_probability": 45, "conditions": "Cloudy"}, + ], + }, + "units": {"units_temp": "c", "units_wind": "mps", "units_precip": "mm", + "units_pressure": "mb", "units_distance": "km"}, + "latitude": 42.37, "longitude": -71.06, + "timezone": "America/New_York", "timezone_offset_minutes": -300, +} +FORECAST_DOC_F = json.loads(json.dumps(FORECAST_DOC)) +FORECAST_DOC_F["units"] = {"units_temp": "f", "units_wind": "mph", "units_precip": "in", + "units_pressure": "inhg", "units_distance": "mi"} +FORECAST_DOC_F["current_conditions"]["air_temperature"] = 64.8 +FORECAST_DOC_F["forecast"]["daily"][0]["air_temp_high"] = 77.7 + +STATIONS_ONLY = {"/stations": STATION_DOC} + + +def patch_token(token="tok-test"): + return patch.object(ts, "resolve_token", return_value=token) + + +class CliTestCase(unittest.TestCase): + """Base: fresh GLOBAL_FLAGS per test, stdout captured via run_main.""" + + def setUp(self): + ts.GLOBAL_FLAGS.clear() + ts.GLOBAL_FLAGS.update( + {"json": False, "dry_run": False, "force": False, "quiet": False, "verbose": False}) + ts.QUIET = False + + +# --------------------------------------------------------------------------- +# Class 1: --help output +# --------------------------------------------------------------------------- + +class HelpTests(CliTestCase): + def test_help_lists_all_subcommands(self): + code, out = run_main(["--help"]) + self.assertEqual(code, 0) + for noun in ("stations", "current", "obs", "forecast", "udp"): + self.assertIn(noun, out) + + def test_udp_help_documents_listen(self): + code, out = run_main(["udp", "--help"]) + self.assertEqual(code, 0) + self.assertIn("listen", out) + self.assertIn("50222", out + ts.build_parser().format_help()) + + def test_forecast_help_shows_flags(self): + code, out = run_main(["forecast", "--help"]) + self.assertEqual(code, 0) + self.assertIn("--station-id", out) + self.assertIn("--days", out) + + def test_main_help_epilog_documents_global_flag_positions(self): + code, out = run_main(["--help"]) + self.assertEqual(code, 0) + self.assertIn("anywhere", out) + + +# --------------------------------------------------------------------------- +# Class 2: argument-error paths +# --------------------------------------------------------------------------- + +class ArgumentErrorsTests(CliTestCase): + def test_no_command_prints_help_and_exits_1(self): + out = io.StringIO() + with contextlib.redirect_stdout(out): + with self.assertRaises(SystemExit) as ctx: + ts.main(["tempest"]) + self.assertEqual(ctx.exception.code, 1) + self.assertIn("usage", out.getvalue()) + + def test_udp_without_subcommand_is_an_error(self): + code, _, err = run_main_err(["udp"]) + self.assertEqual(code, 2) + self.assertIn("udp requires a subcommand", err) + + def test_obs_requires_device_id(self): + code, _, err = run_main_err(["obs"]) + self.assertEqual(code, 2) + self.assertIn("--device-id", err) + + def test_missing_token_dies_with_guidance(self): + with patch_token(""): + code, _, err = run_main_err(["stations"]) + self.assertEqual(code, 1) + self.assertIn("TEMPEST_TOKEN not set", err) + + def test_missing_token_is_fine_for_dry_run(self): + with patch_token(""): + code, out = run_main(["stations", "--dry-run", "--json"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out)["dry_run"], True) + + def test_unknown_station_id_exits_1(self): + fake = FakeTransport(STATIONS_ONLY) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + code, _, err = run_main_err(["current", "--station-id", "99999999"]) + self.assertEqual(code, 1) + self.assertIn("99999999 not found", err) + + +# --------------------------------------------------------------------------- +# Class 3: --dry-run behavior (plans are JSON, exit 0, zero network) +# --------------------------------------------------------------------------- + +class DryRunTests(CliTestCase): + def test_current_dry_run_plan_shape(self): + code, out = run_main(["current", "--station-id", "12799", "--device-id", "60526", + "--dry-run", "--json"]) + self.assertEqual(code, 0) + plan = json.loads(out) + self.assertEqual(plan["dry_run"], True) + self.assertEqual(plan["command"], "current") + self.assertEqual(plan["station_id"], 12799) + self.assertEqual(plan["device_id"], 60526) + + def test_forecast_dry_run_plan_shape(self): + code, out = run_main(["forecast", "--station-id", "12799", "--days", "3", + "--dry-run", "--json"]) + self.assertEqual(code, 0) + plan = json.loads(out) + self.assertEqual(plan["command"], "forecast") + self.assertEqual(plan["days"], 3) + + def test_obs_dry_run_plan_shape(self): + code, out = run_main(["obs", "--device-id", "60526", "--days", "2", "--dry-run", "--json"]) + self.assertEqual(code, 0) + plan = json.loads(out) + self.assertEqual(plan["command"], "obs") + self.assertEqual(plan["device_id"], 60526) + self.assertEqual(plan["days"], 2) + + def test_stations_dry_run_plan_shape(self): + code, out = run_main(["stations", "--dry-run", "--json"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out)["command"], "stations") + + +# --------------------------------------------------------------------------- +# Class 4: mocked REST client logic (no real network anywhere) +# --------------------------------------------------------------------------- + +class RestClientTests(CliTestCase): + def test_stations_json_unwraps_stationset_wrapper(self): + fake = FakeTransport(STATIONS_ONLY) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + code, out = run_main(["stations", "--json"]) + self.assertEqual(code, 0) + doc = json.loads(out) + self.assertEqual(doc["stations"][0]["station_id"], 12799) + self.assertEqual(len(doc["stations"][0]["devices"]), 3) + + def test_current_pipeline_stations_then_latest_observation(self): + fake = FakeTransport(STATIONS_ONLY) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + code, out = run_main(["current", "--json"]) + self.assertEqual(code, 0) + doc = json.loads(out) + # auto-selection picked the ST device and skipped the HB hub + self.assertEqual(doc["device_id"], 60526) + self.assertEqual(doc["type"], "obs_st") + obs = doc["observation"] + self.assertIsInstance(obs["air_temperature"], (int, float)) + self.assertEqual(obs["air_temperature"], 22.37) + self.assertEqual(obs["air_temperature_unit"], "C") + self.assertEqual(fake.requests[-1]["path"], "/observations/device/60526") + # latest-only mode sends no day_offset / time range + self.assertNotIn("day_offset", fake.requests[-1]["params"]) + + def test_current_positional_flag_consumption_from_handler_argv(self): + # handler-owns-flags dispatch: "--device-id 60526" after "current" + fake = FakeTransport(STATIONS_ONLY) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + code, out = run_main(["current", "--device-id", "60526", "--json"]) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out)["device_id"], 60526) + + def test_obs_pipeline_requests_day_offset_and_decodes_rows(self): + fake = FakeTransport(STATIONS_ONLY) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + code, out = run_main(["obs", "--device-id", "60526", "--days", "2", "--json"]) + self.assertEqual(code, 0) + doc = json.loads(out) + self.assertEqual(doc["count"], 1) + self.assertEqual(doc["observations"][0]["local_day_rain_accumulation"], 5.2) + self.assertEqual(fake.requests[-1]["params"]["day_offset"], 2) + + def test_forecast_pipeline_reads_nested_forecast_key(self): + fake = FakeTransport(STATIONS_ONLY) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + code, out = run_main(["forecast", "--json"]) + self.assertEqual(code, 0) + doc = json.loads(out) + self.assertEqual(doc["station_id"], 12799) + daily = doc["forecast"]["forecast"]["daily"] + self.assertEqual(daily[0]["air_temp_high"], 25.4) + self.assertEqual(doc["forecast"]["units"]["units_temp"], "c") + + def test_forecast_human_output_celsius_station_converts_to_f(self): + fake = FakeTransport(STATIONS_ONLY) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + code, out = run_main(["forecast"]) + self.assertEqual(code, 0) + # 25.4C * 9/5 + 32 = 77.72 -> displayed as 78 with :.0f + self.assertIn("78", out) + # hourly local_hour rendered HH:00 + self.assertIn("10:00", out) + + def test_forecast_human_output_fahrenheit_station_not_double_converted(self): + fake = FakeTransport({**STATIONS_ONLY, "/better_forecast": FORECAST_DOC_F}) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + code, out = run_main(["forecast"]) + self.assertEqual(code, 0) + # units_temp=f: 77.7 stays 77.7 -> displayed 78; a double conversion + # would render 172 (77.7*9/5+32), which must not appear. + self.assertIn("78", out) + self.assertNotIn("172", out) + + def test_client_sends_token_as_query_parameter(self): + # Documented auth: token travels as a query parameter (apiKey in:query), + # never as a header. Verified at the requests.get seam. + class FakeResp: + status_code = 200 + text = "" + def json(self): + return STATION_DOC + recorded = {} + + def fake_get(url, params=None, timeout=None): + recorded["url"] = url + recorded["params"] = params + return FakeResp() + + with patch.object(ts.requests, "get", side_effect=fake_get): + ts.TempestClient(token="tok-query").get_stations() + self.assertEqual(recorded["params"]["token"], "tok-query") + self.assertIn("/stations", recorded["url"]) + self.assertIn("swd.weatherflow.com", recorded["url"]) + + def test_client_401_message_names_token(self): + class Err401: + status_code = 401 + text = "" + with patch.object(ts.requests, "get", return_value=Err401()): + client = ts.TempestClient(token="bad") + with contextlib.redirect_stderr(io.StringIO()) as err: + with self.assertRaises(SystemExit) as ctx: + client._get("/stations") + self.assertEqual(ctx.exception.code, 1) + self.assertIn("401", err.getvalue()) + + def test_env_file_fallback_token(self): + with patch.dict("os.environ", {"TEMPEST_TOKEN": ""}), \ + patch.object(ts, "ENV_FILE", "/nonexistent/.tempest.env"): + self.assertEqual(ts.resolve_token(), "") + with patch.dict("os.environ", {"TEMPEST_TOKEN": " "}), \ + patch.object(ts, "ENV_FILE", "/nonexistent/.tempest.env"): + self.assertEqual(ts.resolve_token(), "") + + +# --------------------------------------------------------------------------- +# UDP decoding from canned datagram bytes — no sockets, no binds +# --------------------------------------------------------------------------- + +class UdpDecoderTests(CliTestCase): + def test_obs_st_datagram_decodes_all_18_udp_positions(self): + results = ts.handle_datagram(OBS_ST_DATAGRAM) + self.assertEqual(len(results), 1) + _, payload = results[0] + self.assertEqual(payload["type"], "obs_st") + self.assertEqual(payload["serial_number"], "ST-00000512") + obs = payload["observation"] + self.assertEqual(obs["epoch"], 1588948614) + self.assertEqual(obs["wind_avg"], 0.22) # index 2 + self.assertEqual(obs["wind_direction"], 144) # index 4 + self.assertEqual(obs["station_pressure"], 1017.57) # index 6 (MB) + self.assertEqual(obs["air_temperature"], 22.37) # index 7 (C) + self.assertEqual(obs["rain_accumulation"], 0.0) # index 12 (mm) + self.assertEqual(obs["battery"], 2.410) # index 16 + self.assertEqual(obs["report_interval"], 1) # index 17 (last UDP position) + # UDP record ends at index 17: REST-only Nearcast fields decode as None + self.assertIsNone(obs["nc_rain_accumulation"]) + self.assertIsNone(obs["precip_analysis_type"]) + # metric-native units preserved on the payload + self.assertEqual(obs["wind_avg_unit"], "m/s") + self.assertEqual(obs["air_temperature_unit"], "C") + self.assertEqual(obs["rain_accumulation_unit"], "mm") + + def test_decode_obs_handles_full_rest_22_position_row(self): + decoded = ts.decode_obs(OBS_ROW_ST, "obs_st") + self.assertEqual(decoded["local_day_rain_accumulation"], 5.2) + self.assertEqual(decoded["nc_rain_accumulation"], 4.8) + self.assertEqual(decoded["precip_analysis_type"], 1) + + def test_decode_obs_tolerates_short_rows_with_none(self): + decoded = ts.decode_obs([1588948614, 0.18, 0.22], "obs_st") + self.assertEqual(decoded["wind_avg"], 0.22) + self.assertIsNone(decoded["air_temperature"]) + self.assertIsNone(decoded["battery"]) + + def test_rapid_wind_single_ob_array_not_iterated_elementwise(self): + # Regression: the old handler iterated msg["ob"] like an obs row list + # (TypeError: unsupported operand type(s) for -: 'int' and 'str'-style + # crash on the epoch number). rapid_wind carries ONE array under "ob". + results = ts.handle_datagram(RAPID_WIND_DATAGRAM) + self.assertEqual(len(results), 1) + _, payload = results[0] + self.assertEqual(payload["type"], "rapid_wind") + self.assertEqual(payload["wind_speed_mps"], 2.3) + self.assertEqual(payload["wind_direction"], 128) + self.assertIsNotNone(payload["timestamp"]) + + def test_evt_precip_single_evt_array(self): + results = ts.handle_datagram(EVT_PRECIP_DATAGRAM) + self.assertEqual(len(results), 1) + _, payload = results[0] + self.assertEqual(payload["type"], "evt_precip") + self.assertIsNotNone(payload["timestamp"]) + + def test_evt_strike_distance_and_energy(self): + results = ts.handle_datagram(EVT_STRIKE_DATAGRAM) + _, payload = results[0] + self.assertEqual(payload["type"], "evt_strike") + self.assertEqual(payload["distance_km"], 27) + self.assertEqual(payload["energy"], 3848) + + def test_hub_status_named_fields_dispatch(self): + # hub_status carries named fields (no payload array). The old handler + # printed msg["freq"], which does not exist in the current protocol. + results = ts.handle_datagram(HUB_STATUS_DATAGRAM, show_all=True) + self.assertEqual(len(results), 1) + _, payload = results[0] + self.assertEqual(payload["type"], "hub_status") + self.assertEqual(payload["serial_number"], "HB-00000001") + self.assertEqual(payload["uptime"], 1670133) + self.assertEqual(payload["reset_flags"], "BOR,PIN,POR") + self.assertEqual(payload["radio_stats"], [2, 1, 0, 3, 2839]) + + def test_hub_status_hidden_by_default(self): + self.assertEqual(ts.handle_datagram(HUB_STATUS_DATAGRAM), []) + + def test_device_status_named_fields(self): + results = ts.handle_datagram(DEVICE_STATUS_DATAGRAM, show_all=True) + _, payload = results[0] + self.assertEqual(payload["type"], "device_status") + self.assertEqual(payload["voltage"], 3.50) + self.assertEqual(payload["sensor_status"], 0) + + def test_obs_air_and_obs_sky_dispatch(self): + air = ts.handle_datagram(OBS_AIR_DATAGRAM)[0][1] + self.assertEqual(air["observation"]["station_pressure"], 835.0) + self.assertEqual(air["observation"]["air_temperature"], 10.0) + sky = ts.handle_datagram(OBS_SKY_DATAGRAM)[0][1] + self.assertEqual(sky["observation"]["illuminance"], 9000) + self.assertIsNone(sky["observation"]["local_day_rain_accumulation"]) # null over UDP + + def test_garbage_datagram_returns_no_results(self): + self.assertEqual(ts.handle_datagram(GARBAGE_DATAGRAM), []) + # show_all surfaces a raw preview instead of crashing + results = ts.handle_datagram(GARBAGE_DATAGRAM, show_all=True) + self.assertEqual(len(results), 1) + self.assertEqual(results[0][1]["type"], "unparseable") + + def test_unknown_type_ignored_by_default_and_listed_with_show_all(self): + weird = b'{"type":"something_new","serial_number":"XX-1"}' + self.assertEqual(ts.handle_datagram(weird), []) + results = ts.handle_datagram(weird, show_all=True) + self.assertEqual(results[0][1]["type"], "something_new") + + def test_decode_message_dispatches_on_type_before_indexing(self): + # non-obs families must never be routed into the obs positional decoder + self.assertEqual(ts.decode_message({"type": "rapid_wind", "ob": [1, 2.3, 128]})[0][1]["wind_speed_mps"], 2.3) + self.assertEqual(ts.decode_message({"type": "evt_precip", "evt": [1493322445]})[0][1]["type"], "evt_precip") + self.assertEqual(ts.decode_message({"type": "hub_status", "uptime": 5, "seq": 1}, show_all=True)[0][1]["type"], "hub_status") + + def test_listen_handler_consumes_canned_datagrams_without_sockets(self): + # udp_listen's socket is fully mocked: canned datagram BYTES are fed + # to the decoder through a fake recvfrom, so the suite never creates + # or binds a real socket anywhere. + canned = [OBS_ST_DATAGRAM, RAPID_WIND_DATAGRAM, EVT_PRECIP_DATAGRAM] + fake_sock = unittest.mock.MagicMock() + fake_sock.recvfrom.side_effect = [ + (canned[0], ("127.0.0.1", 50222)), + (canned[1], ("127.0.0.1", 50222)), + (canned[2], ("127.0.0.1", 50222)), + ts.socket.timeout("stop"), + ] + out = io.StringIO() + args = type("A", (), {"port": 50222, "timeout": 1, "show_all": False})() + with contextlib.redirect_stdout(out): + with patch.object(ts.socket, "socket", return_value=fake_sock): + ts.udp_listen(args) + text = out.getvalue() + self.assertIn("ST-00000512", text) + self.assertIn("Rapid Wind", text) + self.assertIn("Rain started", text) + fake_sock.close.assert_called_once() + + def test_listen_json_stream_carries_family_payloads(self): + fake_sock = unittest.mock.MagicMock() + fake_sock.recvfrom.side_effect = [ + (RAPID_WIND_DATAGRAM, ("127.0.0.1", 50222)), + ts.socket.timeout("stop"), + ] + ts.GLOBAL_FLAGS["json"] = True + out = io.StringIO() + args = type("A", (), {"port": 50222, "timeout": 1, "show_all": False})() + with contextlib.redirect_stdout(out): + with patch.object(ts.socket, "socket", return_value=fake_sock): + ts.udp_listen(args) + doc = json.loads(out.getvalue().strip().splitlines()[-1]) + self.assertEqual(doc["type"], "rapid_wind") + self.assertEqual(doc["wind_speed_mps"], 2.3) + fake_sock.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Documented pipeline wiring: each stage's output feeds the next +# --------------------------------------------------------------------------- + +class PipelineTests(CliTestCase): + def test_station_ids_from_stations_feed_current(self): + fake = FakeTransport(STATIONS_ONLY) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + _, stations_out = run_main(["stations", "--json"]) + sid = json.loads(stations_out)["stations"][0]["station_id"] + did = next(d["device_id"] for d in + json.loads(stations_out)["stations"][0]["devices"] + if d["device_type"] == "ST") + self.assertIsInstance(sid, int) + self.assertIsInstance(did, int) + _, current_out = run_main(["current", "--station-id", str(sid), + "--device-id", str(did), "--json"]) + doc = json.loads(current_out) + self.assertEqual(doc["device_id"], did) + # observation dict carries metric-native numeric types for jq math + self.assertIsInstance(doc["observation"]["air_temperature"], float) + self.assertIsInstance(doc["observation"]["rain_accumulation"], (int, float)) + + def test_rain_watch_pipeline_obs_day_total_then_evt_precip_stream(self): + fake = FakeTransport(STATIONS_ONLY) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + _, obs_out = run_main(["obs", "--device-id", "60526", "--days", "1", "--json"]) + doc = json.loads(obs_out) + self.assertEqual(doc["type"], "obs_st") + total = doc["observations"][-1]["local_day_rain_accumulation"] + self.assertEqual(total, 5.2) + # live half: evt_precip datagram decodes with a timestamp for the stream + _, payload = ts.handle_datagram(EVT_PRECIP_DATAGRAM)[0] + self.assertEqual(payload["type"], "evt_precip") + self.assertIsNotNone(payload["timestamp"]) + + def test_forecast_json_fields_are_jq_addressable(self): + fake = FakeTransport(STATIONS_ONLY) + with patch_token(), patch.object(ts.TempestClient, "_get", fake): + _, out = run_main(["forecast", "--json"]) + doc = json.loads(out) + # documented nesting: .forecast.forecast.daily / .forecast.units.units_temp + self.assertEqual(doc["forecast"]["units"]["units_temp"], "c") + self.assertEqual(doc["forecast"]["forecast"]["hourly"][0]["local_hour"], 10) + self.assertIsInstance(doc["forecast"]["forecast"]["daily"][0]["air_temp_high"], (int, float)) + + +if __name__ == "__main__": + unittest.main()