tempest
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 ask
Install
npx skills add https://github.com/magnus919/agent-skills/tree/main/tempest
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
git clone https://github.com/magnus919/agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.
README
Tempest — Hyper-Local Weather from Your Own Station
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
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 (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, andhub_statusmessages 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
| 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
# Create a token in the Tempest web app: Settings -> Data Authorizations -> Create Token
export TEMPEST_TOKEN="your-token-here"
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 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 the
requestslibrary (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.
Skill manifest
tempest — Hyper-local weather from your Tempest station
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
- 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.)
- Export it:
export TEMPEST_TOKEN="<YOUR_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
stations — discover your stations and devices
tempest stations # names, station ids, device types, serials
tempest stations --json | jq '.stations[] | {station_id, name,
devices: [.devices[] | {device_id, device_type, serial_number}]}'
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.
current — latest conditions
tempest current # human-readable, converted
tempest current --json # metric-native, jq-ready
tempest current --station-id 12799 --device-id 60526 # pin exact hardware
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
tempest forecast # current + 5-day daily + next 12 hours
tempest forecast --days 7 --json
tempest forecast --station-id 12799 --days 3
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
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
--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)
tempest udp listen # live stream until Ctrl-C
tempest udp listen --timeout 30 # auto-stop after 30s
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 (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:
| 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 |
Multi-step pipeline recipes
Discover, then observe
# 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 <DEVICE_ID> --json
Rain watch: yesterday's total, then live rain events
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")'
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
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:
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
- Observations are positional arrays, not objects. Raw
obsrows 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. /better_forecastis unit-selectable, not Celsius-locked. It defaults to metric but honorsunits_temp=f,units_wind=mph,units_pressure=inhg,units_precip=in. It reports what it used inresponse.units. Converting an already-Fahrenheit response doubles it (25.4 °C → 77.7 °F → 172 "°F"). Always readunitsbefore converting; the CLI does this for you.- UDP message families differ structurally — dispatch on
typefirst. obs families nest rows underobs;rapid_windcarries one array underob;evt_precip/evt_strikecarry one array underevt;hub_status/device_statuscarry named fields with no payload array. Iteratingrapid_wind'sobelement-wise is the classic TypeError; the bundleddecode_message()shows the correct dispatch. - 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
Nonefor missing tails. - 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.
- Forecast timestamps are epoch integers, never ISO strings.
day_start_local,sunrise,sunset, hourlytimeare epoch seconds; hourly objects carrylocal_hour(0–23) andlocal_day(day of month). There is nolocal_timeortime_stringfield — code expecting one silently falls back to its default branch. - The forecast nests under a
forecastwrapper key.data["daily"]is always empty; readdata["forecast"]["daily"]anddata["forecast"]["hourly"](the CLI's--jsonpreserves the full response, wrapper and all). - Hubs (
HB) have no observations. They only relay. Auto-selection skips them; if you call the API directly, filterdevice_type == "HB"out before hitting/observations/device/{id}(documented 404 otherwise). - 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.
obs_skyUDP day-rain is always null. Local-day rain accumulation (index 11) isnullin UDP SKY broadcasts; REST supplies the real value. Don't build day-rain totals from UDP SKY rows.
When to use
- 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
- 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 | Working with REST endpoints directly: token auth, StationSet shapes, observation parameters, forecast units, error signatures |
| 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 | 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 | Copy-paste multi-step CLI recipes with jq stages, dry-run plans, and expected error paths |
Available Scripts
- scripts/tempest — the CLI:
stations,current,obs,forecast,udp listen; global--json,--dry-run,--quiet,--verboseaccepted in any position; offline dry-run plans for every command. - 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_TOKENfor 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.
Files (agent-skills)
-
evals
-
evals.json 8.4 KB
{ "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 <ID> --device-id <DID> --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" ] } ] }
-
-
references
-
cli-worked-recipes.md 7.6 KB
# 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, and udp listen (plans the bind, creates no socket, safe off-LAN) tempest obs --device-id 60526 --days 2 --dry-run --json tempest udp listen --port 50222 --timeout 30 --dry-run --json # -> {"dry_run": true, "command": "udp", "subcommand": "listen", # "bind_address": "0.0.0.0", "port": 50222, "timeout_seconds": 30, # "show_all": false} # 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. For `udp listen`, dry-run describes the listen parameters (bind address, port, timeout, show-all) and exits 0 without creating or binding any socket — the real listener waits for hub traffic on UDP 50222 and needs the hub's LAN. 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) -
observation-layouts-and-units.md 8.1 KB
# 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) -
rest-api-and-auth.md 12.1 KB
# 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=<YOUR_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) -
udp-broadcast-protocol.md 11.4 KB
# 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)
-
-
scripts
-
tempest 32.3 KB · in bundle
-
test_tempest.py 31.9 KB
"""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") def test_udp_listen_dry_run_plan_shape(self): # VAL-TEMP-011: udp listen honors --dry-run — a plan JSON, exit 0, # and (pinned by the socket patch below) NO socket is ever created # or bound, so the dry run cannot hang waiting for hub traffic. code, out = run_main(["udp", "listen", "--port", "50222", "--timeout", "30", "--dry-run", "--json"]) self.assertEqual(code, 0) plan = json.loads(out) self.assertEqual(plan["dry_run"], True) self.assertEqual(plan["command"], "udp") self.assertEqual(plan["subcommand"], "listen") self.assertEqual(plan["bind_address"], ts.UDP_BROADCAST_ADDR) self.assertEqual(plan["port"], 50222) self.assertEqual(plan["timeout_seconds"], 30) self.assertEqual(plan["show_all"], False) def test_udp_listen_dry_run_defaults_and_show_all(self): # Defaults land in the plan; --show-all propagates. code, out = run_main(["udp", "listen", "--show-all", "--dry-run", "--json"]) self.assertEqual(code, 0) plan = json.loads(out) self.assertEqual(plan["port"], ts.DEFAULT_UDP_PORT) self.assertEqual(plan["timeout_seconds"], 0) self.assertEqual(plan["show_all"], True) def test_udp_listen_dry_run_creates_no_socket(self): # Prove the "binds no socket" half of the contract: if udp_listen # reached its listen path, socket.socket() would be constructed and # this fake's bind() would blow up the test. bound = [] class NoBindSock: def bind(self, *a, **k): bound.append(a) raise AssertionError("dry-run udp listen must not bind a socket") with patch.object(ts.socket, "socket", side_effect=AssertionError( "dry-run udp listen must not create a socket")): code, out = run_main(["udp", "listen", "--dry-run", "--json"]) self.assertEqual(code, 0) self.assertEqual(bound, []) self.assertEqual(json.loads(out)["dry_run"], True) def test_udp_listen_dry_run_without_token_is_fine(self): # UDP needs no token, and the dry run must not demand one either. with patch_token(""): code, out = run_main(["udp", "listen", "--dry-run", "--json"]) self.assertEqual(code, 0) self.assertEqual(json.loads(out)["command"], "udp") # --------------------------------------------------------------------------- # 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()
-
-
README.md 3.6 KB
# Tempest — Hyper-Local Weather from Your Own Station 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 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 (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 | 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 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 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 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. -
SKILL.md 12.9 KB
--- name: tempest 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 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, 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 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. 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>" ``` 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 ### stations — discover your stations and devices ```bash tempest stations # names, station ids, device types, serials tempest stations --json | jq '.stations[] | {station_id, name, devices: [.devices[] | {device_id, device_type, serial_number}]}' ``` 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. ### current — latest conditions ```bash tempest current # human-readable, converted tempest current --json # metric-native, jq-ready tempest current --station-id 12799 --device-id 60526 # pin exact hardware ``` 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 forecast # current + 5-day daily + next 12 hours tempest forecast --days 7 --json tempest forecast --station-id 12799 --days 3 ``` 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 ```bash 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 ``` `--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 # live stream until Ctrl-C tempest udp listen --timeout 30 # auto-stop after 30s 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 (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: | 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 | ## Multi-step pipeline recipes ### Discover, then observe ```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 <DEVICE_ID> --json ``` ### Rain watch: yesterday's total, then live rain events ```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")' ``` `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 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. ## When to use - 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 - **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.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.