Skill: mogglemoss/tempest-weather-wf (version 1.0.0)

--- name: tempest-weather description: Fetches live weather data from a WeatherFlow Tempest weather station and returns structured JSON with current conditions, wind, precipitation, and lightning. Use when the user asks about current weather, outdoor conditions, their Tempest station, wind speed, rain, lightning nearby, or any live sensor readings — even if they don't mention Tempest or API explicitly. version: 1.0.0 metadata: openclaw: emoji: "🌤️" homepage: https://weatherflow.github.io/Tempest/api/ primaryEnv: TEMPEST_TOKEN requires: env: - TEMPEST_TOKEN - TEMPEST_STATION_ID bins: - python3 anyBins: - curl - python3 --- # Tempest Weather Skill Fetches on-demand weather data from a WeatherFlow Tempest station via the REST API and returns clean, structured JSON. ## Configuration Credentials are read from environment variables — never hardcoded: | Env Var | Description | |---|---| | `TEMPEST_TOKEN` | Personal Access Token from tempestwx.com → Settings → Data Authorizations | | `TEMPEST_STATION_ID` | Numeric station ID (find it by calling the `/stations` endpoint with your token) | If either env var is missing, inform the user and show them how to set them: ```bash export TEMPEST_TOKEN="your_token_here" export TEMPEST_STATION_ID="your_station_id_here" ``` --- ## Primary Endpoint ``` GET https://swd.weatherflow.com/swd/rest/observations/station/{STATION_ID}?[REDACTED_SECRET] ``` This returns the latest observation for the station, including all sensor data. Fallback (by device): `GET https://swd.weatherflow.com/swd/rest/observations/?device_id={DEVICE_ID}&[REDACTED_SECRET]` To list available stations/devices: `GET https://swd.weatherflow.com/swd/rest/stations?[REDACTED_SECRET]` --- ## Workflow 1. **Check for credentials** — If the user hasn't provided a token and station ID, ask for them. 2. **Fetch the observation** — Use bash (curl) or Python to call the REST endpoint. 3. **Parse the response** — Extract the relevant fields from `obs` (see field reference below). 4. **Return structured JSON** — Output a clean, normalized JSON object (see Output Schema). 5. **Handle errors gracefully** — 401 = bad token, 404 = station not found, empty obs = no data yet. ### Fetching with curl ```bash curl -s "https://swd.weatherflow.com/swd/rest/observations/station/${STATION_ID}?[REDACTED_SECRET]" ``` ### Fetching with Python ```python import requests, json url = f"https://swd.weatherflow.com/swd/rest/observations/station/{STATION_ID}" resp = requests.get(url, params={"token": TEMPEST_TOKEN}) resp.raise_for_status() data = resp.json() ``` --- ## Output Schema Always return data in this normalized JSON structure: ```json { "station_id": 12345, "station_name": "My Backyard", "timestamp": "2024-01-15T14:32:00Z", "timestamp_epoch": 1705329120, "conditions": { "temperature_c": 18.5, "temperature_f": 65.3, "humidity_pct": 62, "pressure_mb": 1013.4, "pressure_trend": "steady" }, "wind": { "speed_avg_ms": 3.2, "speed_avg_mph": 7.2, "speed_lull_ms": 1.1, "speed_gust_ms": 5.8, "speed_gust_mph": 13.0, "direction_deg": 247, "direction_cardinal": "WSW" }, "precipitation": { "rain_accumulated_mm": 0.0, "rain_daily_mm": 2.4, "precip_type": "none", "precip_analysis": "rain_check_on" }, "lightning": { "strike_count": 0, "avg_distance_km": null }, "solar": { "uv_index": 3, "solar_radiation_wm2": 412, "illuminance_lux": 28500 }, "battery_volts": 2.42, "data_source": "tempest_rest_api" } ``` --- ## Field Reference See `references/obs_fields.md` for the complete field mapping from Tempest API array indices to human-readable names, units, and conversion formulas. --- ## Conversion Helpers - **°C → °F**: `(C * 9/5) + 32` - **m/s → mph**: `ms * 2.237` - **Degrees → Cardinal**: Read the lookup table in `references/obs_fields.md` - **Precip type codes**: `0 = none`, `1 = rain`, `2 = hail` - **Precip analysis codes**: `0 = none`, `1 = rain_check_on`, `2 = rain_check_off` --- ## Error Handling | HTTP Status | Meaning | Action | |---|---|---| | 200 | Success | Parse and return data | | 401 | Invalid token | Ask user to re-check their token | | 403 | Forbidden | Token doesn't have access to that station | | 404 | Station not found | Ask user to confirm their station ID | | 200 + empty obs | No data | Station may be offline; inform user | If `obs` is an empty array or `null`, report: "Station found but no recent observations available — the device may be offline." --- ## Example User Interactions **"What's the weather at my Tempest station?"** → Fetch latest obs, return full JSON output. **"Is it raining?"** → Fetch obs, check `precipitation.precip_type` and `precipitation.rain_accumulated_mm`, return focused JSON. **"Any lightning nearby?"** → Fetch obs, check `lightning.strike_count` and `lightning.avg_distance_km`, return lightning sub-object. **"How windy is it?"** → Return `wind` sub-object including gust, lull, avg, direction.

path: README.md
content: # 🌤️ tempest-weather

An [OpenClaw](https://openclaw.ai) skill that fetches live weather data from a [WeatherFlow Tempest](https://weatherflow.com/tempest-weather-system/) station on demand and returns structured JSON.

## What it does

Ask your OpenClaw agent anything about current conditions and it will call the Tempest REST API and return clean, normalized JSON covering:

- **Conditions** — temperature (°C/°F), humidity, barometric pressure, pressure trend
- **Wind** — average, lull, gust (m/s and mph), direction (degrees + cardinal)
- **Precipitation** — rain last hour, daily total, precipitation type (rain/hail/none)
- **Lightning** — strike count, last strike distance, 1hr and 3hr totals
- **Solar** — UV index, solar radiation, illuminance

## Requirements

- Python 3
- `curl` or `requests` (`pip install requests`)
- A [Tempest Personal Access Token](https://tempestwx.com) and Station ID

## Setup

**1. Get your credentials**

Generate a token at tempestwx.com → Settings → Data Authorizations → Create Token.

Find your Station ID:
```bash
curl -s "https://swd.weatherflow.com/swd/rest/stations?[REDACTED_SECRET]" | python3 -m json.tool
```
Look for `"station_id"` in the response.

**2. Set environment variables**

Add these to your `~/.zshrc` (or `~/.bashrc`):
```bash
export TEMPEST_TOKEN="your_token_here"
export TEMPEST_STATION_ID="your_station_id_here"
```
Then reload: `source ~/.zshrc`

**3. Install the skill**

```bash
clawhub install tempest-weather
```

Or manually copy the skill folder to your OpenClaw workspace:
```bash
scp -r tempest-weather [REDACTED_SECRET]:~/.openclaw/workspace/skills/
```

## Example interactions

> "What's the weather at my station?"

> "Is it raining?"

> "How windy is it right now?"

> "Any lightning nearby?"

> "What's the UV index?"

## Output schema

```json
{
  "station_id": 12345,
  "station_name": "My Backyard",
  "timestamp": "2024-01-15T14:32:00Z",
  "conditions": {
    "temperature_c": 18.5,
    "temperature_f": 65.3,
    "humidity_pct": 62,
    "pressure_mb": 1013.4,
    "pressure_trend": "steady"
  },
  "wind": {
    "speed_avg_ms": 3.2,
    "speed_avg_mph": 7.2,
    "speed_gust_ms": 5.8,
    "speed_gust_mph": 13.0,
    "direction_deg": 247,
    "direction_cardinal": "WSW"
  },
  "precipitation": {
    "rain_last_1hr_mm": 0.0,
    "rain_daily_mm": 2.4,
    "precip_type": "none"
  },
  "lightning": {
    "strike_count_interval": 0,
    "strike_count_last_1hr": 0,
    "last_strike_distance_km": null
  },
  "solar": {
    "uv_index": 3,
    "solar_radiation_wm2": 412,
    "illuminance_lux": 28500
  },
  "battery_volts": 2.42
}
```

## Files

```
tempest-weather/
├── SKILL.md                    # Skill instructions and frontmatter
├── scripts/
│   └── fetch_tempest.py        # Standalone fetch + normalize script
└── references/
    └── obs_fields.md           # Full field reference and unit conversions
```

## License

MIT


path: references/obs_fields.md
content: # Tempest Observation Field Reference

Complete mapping of Tempest REST API observation fields for the `obs_st` (Tempest device) response type.

---

## Station Observation Response Structure

The REST endpoint `GET /observations/station/{id}` returns:

```json
{
  "station_id": 12345,
  "station_name": "My Station",
  "public_name": "My Station",
  "latitude": 37.123,
  "longitude": -122.456,
  "timezone": "America/Los_Angeles",
  "elevation": 52.0,
  "is_public": false,
  "status": { "status_code": 0, "status_message": "SUCCESS" },
  "obs": [
    {
      "timestamp": 1705329120,
      "wind_lull": 0.9,
      "wind_avg": 2.3,
      "wind_gust": 4.1,
      "wind_direction": 245,
      "wind_sample_interval": 6,
      "station_pressure": 1013.4,
      "sea_level_pressure": 1016.2,
      "pressure_trend": "steady",
      "air_temperature": 18.5,
      "relative_humidity": 62,
      "uv": 3,
      "solar_radiation": 412,
      "illuminance": 28500,
      "lightning_strike_last_epoch": null,
      "lightning_strike_last_distance": null,
      "lightning_strike_count": 0,
      "lightning_strike_count_last_1hr": 0,
      "lightning_strike_count_last_3hr": 0,
      "feels_like": 16.2,
      "heat_index": 17.1,
      "wind_chill": 16.2,
      "dew_point": 11.3,
      "wet_bulb_temperature": 14.1,
      "delta_t": 4.4,
      "air_density": 1.214,
      "precip_accum_last_1hr": 0.0,
      "precip_accum_local_day": 2.4,
      "precip_accum_local_yesterday": 0.0,
      "precip_minutes_local_day": 0,
      "precip_type": 0,
      "precip_analysis_type_filtered": 1,
      "battery": 2.42,
      "report_interval": 1
    }
  ]
}
```

---

## Key Field Descriptions

### Conditions
| Field | Type | Units | Notes |
|---|---|---|---|
| `air_temperature` | float | °C | Convert to °F: `(C * 9/5) + 32` |
| `relative_humidity` | int | % | 0–100 |
| `station_pressure` | float | mb | Raw station pressure (not sea-level corrected) |
| `sea_level_pressure` | float | mb | Derived: sea-level adjusted |
| `pressure_trend` | string | — | `"rising"`, `"falling"`, `"steady"` |
| `feels_like` | float | °C | Derived; uses heat index or wind chill depending on conditions |
| `dew_point` | float | °C | Derived |

### Wind
| Field | Type | Units | Notes |
|---|---|---|---|
| `wind_avg` | float | m/s | Average over report interval |
| `wind_lull` | float | m/s | Minimum 3-second sample in interval |
| `wind_gust` | float | m/s | Maximum 3-second sample in interval |
| `wind_direction` | int | degrees | 0=N, 90=E, 180=S, 270=W |
| `wind_sample_interval` | int | seconds | How often samples were taken |

### Precipitation
| Field | Type | Units | Notes |
|---|---|---|---|
| `precip_accum_last_1hr` | float | mm | Rain in the last 60 minutes |
| `precip_accum_local_day` | float | mm | Rain since midnight local time |
| `precip_accum_local_yesterday` | float | mm | Prior day total |
| `precip_minutes_local_day` | int | minutes | Minutes of precipitation today |
| `precip_type` | int | code | `0=none`, `1=rain`, `2=hail` |
| `precip_analysis_type_filtered` | int | code | `0=none`, `1=Rain Check on`, `2=Rain Check off` |

### Lightning
| Field | Type | Units | Notes |
|---|---|---|---|
| `lightning_strike_count` | int | count | Strikes in report interval |
| `lightning_strike_count_last_1hr` | int | count | Strikes in last 1 hour |
| `lightning_strike_count_last_3hr` | int | count | Strikes in last 3 hours |
| `lightning_strike_last_epoch` | int or null | seconds | Unix timestamp of last strike |
| `lightning_strike_last_distance` | int or null | km | Distance to last strike |

### Solar
| Field | Type | Units | Notes |
|---|---|---|---|
| `uv` | float | index | UV index |
| `solar_radiation` | int | W/m² | Solar irradiance |
| `illuminance` | int | lux | Visible light level |

---

## Wind Direction → Cardinal Conversion

```
  0–11°   = N
 12–33°   = NNE
 34–56°   = NE
 57–78°   = ENE
 79–101°  = E
102–123°  = ESE
124–146°  = SE
147–168°  = SSE
169–191°  = S
192–213°  = SSW
214–236°  = SW
237–258°  = WSW
259–281°  = W
282–303°  = WNW
304–326°  = NW
327–348°  = NNW
349–360°  = N
```

---

## Websocket obs_st Array Format (for reference)

If using the WebSocket API instead of REST, `obs_st` observations arrive as arrays. Index mapping:

| Index | Field | Units |
|---|---|---|
| 0 | Time Epoch | Seconds |
| 1 | Wind Lull | m/s |
| 2 | Wind Avg | m/s |
| 3 | Wind Gust | m/s |
| 4 | Wind Direction | Degrees |
| 5 | Wind Sample Interval | Seconds |
| 6 | Station Pressure | mb |
| 7 | Air Temperature | °C |
| 8 | Relative Humidity | % |
| 9 | Illuminance | Lux |
| 10 | UV | Index |
| 11 | Solar Radiation | W/m² |
| 12 | Rain Accumulated | mm |
| 13 | Precipitation Type | 0/1/2 |
| 14 | Lightning Strike Avg Distance | km |
| 15 | Lightning Strike Count | count |
| 16 | Battery | Volts |
| 17 | Report Interval | Minutes |
| 18 | Local Daily Rain Accumulation | mm |
| 19 | Rain Accumulated Final (Rain Check) | mm |
| 20 | Local Daily Rain Accumulation Final (Rain Check) | mm |
| 21 | Precipitation Analysis Type | 0/1/2 |

---

## Authentication

All requests require `?[REDACTED_SECRET]` as a query parameter.

- **Personal Access Token**: Generated at tempestwx.com → Settings → Data Authorizations → Create Token
- **OAuth**: For multi-user applications (see Tempest OAuth docs)

Token is passed as a query param, not a header.

---

## Rate Limits

Tempest enforces rate limits on personal tokens. For on-demand use, polling more than once per minute is unnecessary since observations are published every 1–5 minutes. Avoid polling loops; use the WebSocket API if real-time data is needed.


path: scripts/fetch_tempest.py
content: #!/usr/bin/env python3
"""
fetch_tempest.py — Fetch and normalize latest observation from a Tempest weather station.

Usage:
    python3 fetch_tempest.py --token YOUR_TOKEN --station 12345
    python3 fetch_tempest.py --token YOUR_TOKEN --station 12345 --pretty

Environment variables (alternative to flags):
    TEMPEST_TOKEN
    TEMPEST_STATION_ID
"""

import argparse
import json
import math
import os
import sys
from datetime import datetime, timezone

try:
    import requests
except ImportError:
    print(json.dumps({"error": "Missing dependency: pip install requests"}))
    sys.exit(1)

BASE_URL = "https://swd.weatherflow.com/swd/rest"


def deg_to_cardinal(deg):
    if deg is None:
        return None
    dirs = ["N","NNE","NE","ENE","E","ESE","SE","SSE",
            "S","SSW","SW","WSW","W","WNW","NW","NNW"]
    ix = round(deg / 22.5) % 16
    return dirs[ix]


def c_to_f(c):
    if c is None:
        return None
    return round((c * 9 / 5) + 32, 1)


def ms_to_mph(ms):
    if ms is None:
        return None
    return round(ms * 2.237, 1)


PRECIP_TYPES = {0: "none", 1: "rain", 2: "hail"}
PRECIP_ANALYSIS = {0: "none", 1: "rain_check_on", 2: "rain_check_off"}


def fetch_observation(token, station_id):
    url = f"{BASE_URL}/observations/station/{station_id}"
    resp = requests.get(url, params={"token": token}, timeout=10)
    
    if resp.status_code == 401:
        return {"error": "Invalid API token (401). Check your token at tempestwx.com."}
    if resp.status_code == 403:
        return {"error": f"Token does not have access to station {station_id} (403)."}
    if resp.status_code == 404:
        return {"error": f"Station {station_id} not found (404). Check your station ID."}
    
    resp.raise_for_status()
    data = resp.json()
    
    if data.get("status", {}).get("status_code") != 0:
        msg = data.get("status", {}).get("status_message", "Unknown error")
        return {"error": f"API error: {msg}"}
    
    obs_list = data.get("obs", [])
    if not obs_list:
        return {
            "error": "No recent observations available. The station may be offline.",
            "station_id": data.get("station_id"),
            "station_name": data.get("station_name")
        }
    
    obs = obs_list[0]  # Most recent observation
    
    ts = obs.get("timestamp")
    ts_iso = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() if ts else None
    
    return {
        "station_id": data.get("station_id"),
        "station_name": data.get("station_name") or data.get("public_name"),
        "latitude": data.get("latitude"),
        "longitude": data.get("longitude"),
        "timezone": data.get("timezone"),
        "elevation_m": data.get("elevation"),
        "timestamp": ts_iso,
        "timestamp_epoch": ts,
        "conditions": {
            "temperature_c": obs.get("air_temperature"),
            "temperature_f": c_to_f(obs.get("air_temperature")),
            "humidity_pct": obs.get("relative_humidity"),
            "pressure_mb": obs.get("station_pressure"),
            "sea_level_pressure_mb": obs.get("sea_level_pressure"),
            "pressure_trend": obs.get("pressure_trend"),
            "feels_like_c": obs.get("feels_like"),
            "feels_like_f": c_to_f(obs.get("feels_like")),
            "dew_point_c": obs.get("dew_point"),
            "dew_point_f": c_to_f(obs.get("dew_point")),
            "wet_bulb_temperature_c": obs.get("wet_bulb_temperature"),
            "air_density_kgm3": obs.get("air_density"),
        },
        "wind": {
            "speed_avg_ms": obs.get("wind_avg"),
            "speed_avg_mph": ms_to_mph(obs.get("wind_avg")),
            "speed_lull_ms": obs.get("wind_lull"),
            "speed_lull_mph": ms_to_mph(obs.get("wind_lull")),
            "speed_gust_ms": obs.get("wind_gust"),
            "speed_gust_mph": ms_to_mph(obs.get("wind_gust")),
            "direction_deg": obs.get("wind_direction"),
            "direction_cardinal": deg_to_cardinal(obs.get("wind_direction")),
            "sample_interval_sec": obs.get("wind_sample_interval"),
        },
        "precipitation": {
            "rain_last_1hr_mm": obs.get("precip_accum_last_1hr"),
            "rain_daily_mm": obs.get("precip_accum_local_day"),
            "rain_yesterday_mm": obs.get("precip_accum_local_yesterday"),
            "rain_minutes_today": obs.get("precip_minutes_local_day"),
            "precip_type": PRECIP_TYPES.get(obs.get("precip_type"), "unknown"),
            "precip_analysis": PRECIP_ANALYSIS.get(obs.get("precip_analysis_type_filtered"), "unknown"),
        },
        "lightning": {
            "strike_count_interval": obs.get("lightning_strike_count"),
            "strike_count_last_1hr": obs.get("lightning_strike_count_last_1hr"),
            "strike_count_last_3hr": obs.get("lightning_strike_count_last_3hr"),
            "last_strike_epoch": obs.get("lightning_strike_last_epoch"),
            "last_strike_distance_km": obs.get("lightning_strike_last_distance"),
        },
        "solar": {
            "uv_index": obs.get("uv"),
            "solar_radiation_wm2": obs.get("solar_radiation"),
            "illuminance_lux": obs.get("illuminance"),
        },
        "battery_volts": obs.get("battery"),
        "report_interval_min": obs.get("report_interval"),
        "data_source": "tempest_rest_api"
    }


def main():
    parser = argparse.ArgumentParser(description="Fetch Tempest weather station data")
    parser.add_argument("--token", default=os.environ.get("TEMPEST_TOKEN"), help="API token")
    parser.add_argument("--station", default=os.environ.get("TEMPEST_STATION_ID"), help="Station ID")
    parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON output")
    args = parser.parse_args()

    if not args.[REDACTED_SECRET]"error": "Missing --token or TEMPEST_TOKEN env var"}))
        sys.exit(1)
    if not args.station:
        print(json.dumps({"error": "Missing --station or TEMPEST_STATION_ID env var"}))
        sys.exit(1)

    result = fetch_observation(args.token, args.station)
    indent = 2 if args.pretty else None
    print(json.dumps(result, indent=indent))


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)