# 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",
"windspeed_max": 14.2,
"wind_direction": 305,
"compass_direction": "NW",
"precipitation_probability_max": 30,
"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"
}
```
### AlertEntry (array element of the detailed alerts payload)
```json
{
"alert": {
"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"
},
"area_desc": "Warszawa"
}
```
`area_desc` is the provider's name for the area the entry covers; `""` where
the provider sends none (BOM), per the missing-strings rule.
### AlertReport (the detailed alerts payload)
```json
{
"alerts": [
{
"alert": {
"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"
},
"area_desc": "Warszawa"
}
],
"region_filtered": true
}
```
`region_filtered` is `false` only when a MeteoAlarm national feed was returned
unfiltered because no region could be matched for the location, by EMMA_ID, by
area name, or by local-language area name, so the entries are national, not
local. NWS, ECCC and BOM filter by point, polygon and geohash, and an empty
result is trivially filtered, so all of those are `true`.
### 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",
"windspeed_max": 14.2,
"wind_direction": 305,
"compass_direction": "NW",
"precipitation_probability_max": 30,
"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",
"windspeed_max": 14.2,
"wind_direction": 305,
"compass_direction": "NW",
"precipitation_probability_max": 30,
"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.