weathervane 0.8.0

Weather data, air quality, and alerts from public APIs. Fetches, parses, and returns clean Rust types.
Documentation
# Weathervane Wire Contract (v1)

This document freezes the JSON shapes that cross process boundaries: future
`weathervaned` D-Bus payloads, CLI output, and anything else that serializes
the public types. From the commit that introduces this file, **changing any
shape documented here is a breaking change.**

Enforcement: `tests/wire_contract.rs` — the committed snapshots under
`tests/snapshots/` are the canonical examples; this document quotes them.
Rust API reference: `API.md`.

## Conventions

- **Style:** serde defaults. snake_case field names, PascalCase enum variant
  strings. There are no `rename_all` attributes; new types conform by doing
  nothing.
- **Exception (the only one):** `AqiCategory` is adjacent-tagged:
  `{"standard": "Us", "level": "Good"}`. Standards vocabulary: `"Us"`, `"Eu"`.
- **Option policy:** documented keys are always present; `null` means no data.
  Absent keys never carry meaning.
- **Missing strings** are `""` (e.g. `Alert.description` for providers that
  send none, `LocationResult.country` when geocoding omits it) — not null.
- **Timestamps:** alert times are UTC instants, RFC3339 with `Z`
  (`"2026-06-11T22:00:00Z"`). Forecast/hourly times are location-local
  wall-clock naive ISO strings (`"2026-06-11T10:00"`); convert with the
  top-level `utc_offset_seconds`. Rule of thumb: *alerts = instants,
  forecasts = what a wall clock at the location reads.*
- **Versioning:** D-Bus payload versioning is the interface name
  (`dev.jcrenshaw.Weathervane1`); a breaking change ships `Weathervane2`
  alongside. CLI output carries `"format_version": 1`. JSON shapes are frozen
  as of this document; the `Weathervane1` interface itself freezes after the
  dogfood milestone.
- **PII rule:** error payloads (`kind`, `message`) never contain search query
  text or coordinates. Enforced by tests.

## Payloads

### WeatherData
```json
{
  "current": {
    "temperature": 71.3,
    "weathercode": 2,
    "condition": "PartlyCloudy",
    "windspeed": 8.5,
    "humidity": 54,
    "feels_like": 69.8,
    "wind_direction": 305,
    "compass_direction": "NW",
    "wind_gusts": 12.4,
    "uv_index": 6.5,
    "visibility": 24135.0,
    "pressure": 1015.2,
    "cloud_cover": 40,
    "dew_point": 52.7
  },
  "hourly": [
    {
      "time": "2026-06-11T10:00",
      "temperature": 71.3,
      "weathercode": 2,
      "condition": "PartlyCloudy",
      "precipitation_probability": 20,
      "precipitation": 0.1,
      "windspeed": 8.5,
      "wind_gusts": 12.4
    }
  ],
  "forecast": [
    {
      "date": "2026-06-11",
      "temp_max": 78.4,
      "temp_min": 58.1,
      "weathercode": 3,
      "condition": "Overcast",
      "sunrise": "2026-06-11T05:21",
      "sunset": "2026-06-11T20:29"
    }
  ],
  "utc_offset_seconds": -14400
}
```

### AirQualityData
US standard:
```json
{
  "aqi": 42,
  "category": {
    "standard": "Us",
    "level": "Good"
  },
  "pm2_5": 8.1,
  "pm10": 14.9,
  "ozone": 61.3,
  "nitrogen_dioxide": 9.4,
  "carbon_monoxide": 142.0,
  "aqi_source": "OpenMeteo"
}
```

EU standard:
```json
{
  "aqi": 35,
  "category": {
    "standard": "Eu",
    "level": "Fair"
  },
  "pm2_5": 8.1,
  "pm10": 14.9,
  "ozone": 61.3,
  "nitrogen_dioxide": 9.4,
  "carbon_monoxide": 142.0,
  "aqi_source": "OpenMeteo"
}
```

### Alert (array element of the alerts payload)
```json
{
  "id": "NWS-IDP-PROD-123",
  "event": "Severe Thunderstorm Warning",
  "severity": "Severe",
  "headline": "Severe thunderstorm until 10 PM EDT",
  "description": "Wind gusts to 60 mph expected.",
  "expires": "2026-06-11T22:00:00Z"
}
```

### PollenData
Inside CAMS European coverage:
```json
{
  "alder": 0.0,
  "birch": 12.4,
  "grass": 3.1,
  "mugwort": 0.0,
  "olive": 0.0,
  "ragweed": 0.7
}
```

Outside coverage (US, Asia, …) the value is `null` — present, never absent,
never zero-filled.

### LocationResult (array element of search results)
```json
{
  "latitude": 45.5152,
  "longitude": -122.6784,
  "display_name": "Portland, Oregon, United States",
  "country": "United States"
}
```

### DetectedLocation
```json
{
  "latitude": 45.52,
  "longitude": -122.68,
  "display_name": "Portland, United States",
  "country": "United States"
}
```

### SavedLocation
```json
{
  "name": "Home",
  "latitude": 45.5152,
  "longitude": -122.6784
}
```

## Errors

### WireError

`{"kind": ..., "message": ...}` — `kind` is the machine-matchable field, one of:
`Timeout`, `Network`, `HttpStatus`, `Parse`, `HttpClient`, `NoResults`,
`LocationDetection`, `Dbus`. Sub-detail (network kind, HTTP status code) rides
in `message`. Example:
```json
{
  "kind": "HttpStatus",
  "message": "http status 429"
}
```

### D-Bus method errors (request layer)

Methods fail with native D-Bus errors named
`dev.jcrenshaw.Weathervane1.Error.<Kind>` using the same kind vocabulary:

```
dev.jcrenshaw.Weathervane1.Error.Timeout
dev.jcrenshaw.Weathervane1.Error.Network
dev.jcrenshaw.Weathervane1.Error.HttpStatus
dev.jcrenshaw.Weathervane1.Error.Parse
dev.jcrenshaw.Weathervane1.Error.HttpClient
dev.jcrenshaw.Weathervane1.Error.NoResults
dev.jcrenshaw.Weathervane1.Error.LocationDetection
dev.jcrenshaw.Weathervane1.Error.Dbus
```

The error message is the `WireError.message` text.

## State layer (daemon properties)

Each domain (weather, air quality, alerts, pollen) is one property holding an
`Envelope`:

- `data` — last-good payload; never wiped by a failed refresh; `null` only
  before the first successful fetch.
- `fetched_at` — when `data` was obtained (UTC RFC3339); `null` iff `data` is.
- `error` — most recent attempt's failure (`kind`/`message`/`at`); `null` when
  the last attempt succeeded.

Healthy:
```json
{
  "data": {
    "current": {
      "temperature": 71.3,
      "weathercode": 2,
      "condition": "PartlyCloudy",
      "windspeed": 8.5,
      "humidity": 54,
      "feels_like": 69.8,
      "wind_direction": 305,
      "compass_direction": "NW",
      "wind_gusts": 12.4,
      "uv_index": 6.5,
      "visibility": 24135.0,
      "pressure": 1015.2,
      "cloud_cover": 40,
      "dew_point": 52.7
    },
    "hourly": [
      {
        "time": "2026-06-11T10:00",
        "temperature": 71.3,
        "weathercode": 2,
        "condition": "PartlyCloudy",
        "precipitation_probability": 20,
        "precipitation": 0.1,
        "windspeed": 8.5,
        "wind_gusts": 12.4
      }
    ],
    "forecast": [
      {
        "date": "2026-06-11",
        "temp_max": 78.4,
        "temp_min": 58.1,
        "weathercode": 3,
        "condition": "Overcast",
        "sunrise": "2026-06-11T05:21",
        "sunset": "2026-06-11T20:29"
      }
    ],
    "utc_offset_seconds": -14400
  },
  "fetched_at": "2026-06-11T13:50:00Z",
  "error": null
}
```

Stale (upstream failing, last-good retained):
```json
{
  "data": {
    "current": {
      "temperature": 71.3,
      "weathercode": 2,
      "condition": "PartlyCloudy",
      "windspeed": 8.5,
      "humidity": 54,
      "feels_like": 69.8,
      "wind_direction": 305,
      "compass_direction": "NW",
      "wind_gusts": 12.4,
      "uv_index": 6.5,
      "visibility": 24135.0,
      "pressure": 1015.2,
      "cloud_cover": 40,
      "dew_point": 52.7
    },
    "hourly": [
      {
        "time": "2026-06-11T10:00",
        "temperature": 71.3,
        "weathercode": 2,
        "condition": "PartlyCloudy",
        "precipitation_probability": 20,
        "precipitation": 0.1,
        "windspeed": 8.5,
        "wind_gusts": 12.4
      }
    ],
    "forecast": [
      {
        "date": "2026-06-11",
        "temp_max": 78.4,
        "temp_min": 58.1,
        "weathercode": 3,
        "condition": "Overcast",
        "sunrise": "2026-06-11T05:21",
        "sunset": "2026-06-11T20:29"
      }
    ],
    "utc_offset_seconds": -14400
  },
  "fetched_at": "2026-06-11T13:50:00Z",
  "error": {
    "kind": "Timeout",
    "message": "request timed out",
    "at": "2026-06-11T14:20:00Z"
  }
}
```

Never fetched:
```json
{
  "data": null,
  "fetched_at": null,
  "error": {
    "kind": "Network",
    "message": "network error: connect",
    "at": "2026-06-11T14:20:00Z"
  }
}
```

## CLI output

stdout on success:

```json
{ "format_version": 1, "data": { ... }, "fetched_at": "2026-06-11T13:50:00Z" }
```

stderr on failure (nonzero exit): a `WireError` object.