repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

7.9 KB · 261 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"math"
  7	"strings"
  8	"time"
  9)
 10
 11// open-meteo needs no key and no account, which is the reason it is here rather
 12// than any of the services that would put a credential in this repo for a panel
 13// showing the temperature.
 14const weatherURL = "https://api.open-meteo.com/v1/forecast"
 15
 16type Weather struct {
 17	Place       string  `json:"place"`
 18	Temperature string  `json:"temperature"`
 19	Feels       string  `json:"feels"`
 20	High        string  `json:"high"`
 21	Low         string  `json:"low"`
 22	Rain        string  `json:"rain"`
 23	Condition   string  `json:"condition"`
 24	Wind        string  `json:"wind"`
 25	Sunrise     string  `json:"sunrise"`
 26	Sunset      string  `json:"sunset"`
 27	UV          string  `json:"uv"`
 28	UVState     string  `json:"uv_state"`
 29	UVFill      float64 `json:"uv_fill"`
 30	UVLevel     int     `json:"uv_level"`
 31	DayPercent  int     `json:"day_percent"`
 32	Unavailable bool    `json:"unavailable"`
 33
 34	// The next several hours, which is the half of a forecast anyone actually
 35	// acts on. It comes out of the same request the rest of this panel already
 36	// makes, so it costs nothing.
 37	Hours []Hour `json:"hours"`
 38}
 39
 40// Hour is one column of the strip. Rain is a percentage rather than a depth
 41// because a chance is what decides whether to take a coat.
 42type Hour struct {
 43	Label string `json:"label"`
 44	Temp  string `json:"temp"`
 45	Rain  int    `json:"rain"`
 46	Warm  int    `json:"warm"`
 47}
 48
 49type weatherPayload struct {
 50	Current struct {
 51		Temperature float64 `json:"temperature_2m"`
 52		Apparent    float64 `json:"apparent_temperature"`
 53		Wind        float64 `json:"wind_speed_10m"`
 54		Code        int     `json:"weather_code"`
 55	} `json:"current"`
 56	Hourly struct {
 57		Time         []string  `json:"time"`
 58		Temperature  []float64 `json:"temperature_2m"`
 59		PrecipChance []int     `json:"precipitation_probability"`
 60	} `json:"hourly"`
 61	Daily struct {
 62		Max          []float64 `json:"temperature_2m_max"`
 63		Min          []float64 `json:"temperature_2m_min"`
 64		PrecipChance []int     `json:"precipitation_probability_max"`
 65		Sunrise      []string  `json:"sunrise"`
 66		Sunset       []string  `json:"sunset"`
 67		UVMax        []float64 `json:"uv_index_max"`
 68	} `json:"daily"`
 69}
 70
 71func fetchWeather(ctx context.Context, g *Guard) (Weather, error) {
 72	url := fmt.Sprintf(
 73		"%s?latitude=%.2f&longitude=%.2f"+
 74			"&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m"+
 75			"&hourly=temperature_2m,precipitation_probability"+
 76			"&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,sunrise,sunset,uv_index_max"+
 77			"&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch"+
 78			// Two days, because the strip runs past midnight for most of the
 79			// evening and one day of hours would run out at 11pm.
 80			"&timezone=America%%2FNew_York&forecast_days=2",
 81		weatherURL, weatherLat, weatherLon)
 82
 83	var payload weatherPayload
 84	if err := getJSON(ctx, g, "openmeteo", url, &payload); err != nil {
 85		return Weather{Place: weatherPlace, Unavailable: true}, err
 86	}
 87
 88	w := Weather{
 89		Place:       weatherPlace,
 90		Temperature: fmt.Sprintf("%.0f", payload.Current.Temperature),
 91		Feels:       fmt.Sprintf("%.0f", payload.Current.Apparent),
 92		Wind:        fmt.Sprintf("%.0f mph", payload.Current.Wind),
 93	}
 94	w.Condition = describeWeather(payload.Current.Code)
 95
 96	if len(payload.Daily.Max) > 0 {
 97		w.High = fmt.Sprintf("%.0f", payload.Daily.Max[0])
 98	}
 99	if len(payload.Daily.Min) > 0 {
100		w.Low = fmt.Sprintf("%.0f", payload.Daily.Min[0])
101	}
102	if len(payload.Daily.PrecipChance) > 0 {
103		w.Rain = fmt.Sprintf("%d%%", payload.Daily.PrecipChance[0])
104	}
105	if len(payload.Daily.UVMax) > 0 {
106		uv := payload.Daily.UVMax[0]
107		w.UV = fmt.Sprintf("%.0f", uv)
108		w.UVState = uvBand(uv)
109		// Eleven is where the WHO scale stops naming steps and starts saying
110		// extreme, so it is the top of the bar.
111		w.UVFill, w.UVLevel = gauge(uv, 11, 3, 6, 8)
112	}
113
114	// open-meteo returns these as local wall clock with no offset, which is
115	// what timezone=America/New_York asked for, so they are parsed in that
116	// location rather than as UTC.
117	if len(payload.Daily.Sunrise) > 0 && len(payload.Daily.Sunset) > 0 {
118		rise, errRise := time.ParseInLocation("2006-01-02T15:04", payload.Daily.Sunrise[0], easternTime())
119		set, errSet := time.ParseInLocation("2006-01-02T15:04", payload.Daily.Sunset[0], easternTime())
120		if errRise == nil && errSet == nil {
121			w.Sunrise = rise.Format("3:04pm")
122			w.Sunset = set.Format("3:04pm")
123			w.DayPercent = dayProgress(time.Now(), rise, set)
124		}
125	}
126	w.Hours = buildHours(payload, time.Now())
127	return w, nil
128}
129
130// hoursShown is how far ahead the strip looks. Eight covers the rest of an
131// evening or a working day, and more than that stops being a forecast anyone
132// reads off a dashboard.
133const hoursShown = 8
134
135// buildHours takes the hourly series from the hour containing now. open-meteo
136// returns whole days from midnight, so most of what comes back is already past.
137func buildHours(p weatherPayload, now time.Time) []Hour {
138	h := p.Hourly
139	if len(h.Time) == 0 {
140		return nil
141	}
142
143	start := -1
144	cutoff := now.In(easternTime()).Truncate(time.Hour)
145	for i, at := range h.Time {
146		t, err := time.ParseInLocation("2006-01-02T15:04", at, easternTime())
147		if err != nil || t.Before(cutoff) {
148			continue
149		}
150		start = i
151		break
152	}
153	if start < 0 {
154		return nil
155	}
156
157	// The strip colours its temperatures against its own range, so a mild
158	// evening still shows a gradient instead of eight identical cells.
159	lo, hi := math.Inf(1), math.Inf(-1)
160	for i := start; i < len(h.Temperature) && i < start+hoursShown; i++ {
161		lo, hi = math.Min(lo, h.Temperature[i]), math.Max(hi, h.Temperature[i])
162	}
163
164	out := make([]Hour, 0, hoursShown)
165	for i := start; i < len(h.Time) && len(out) < hoursShown; i++ {
166		t, err := time.ParseInLocation("2006-01-02T15:04", h.Time[i], easternTime())
167		if err != nil || i >= len(h.Temperature) {
168			continue
169		}
170
171		cell := Hour{
172			Label: strings.ToUpper(t.Format("3PM")),
173			Temp:  fmt.Sprintf("%.0f", h.Temperature[i]),
174			Warm:  warmStep(h.Temperature[i], lo, hi),
175		}
176		if i < len(h.PrecipChance) {
177			cell.Rain = h.PrecipChance[i]
178		}
179		out = append(out, cell)
180	}
181	return out
182}
183
184// warmStep buckets a temperature into four steps across the strip's own range,
185// so the row reads as a gradient rather than as eight numbers.
186func warmStep(v, lo, hi float64) int {
187	if hi-lo < 1 {
188		return 1
189	}
190	return int(math.Min(3, math.Max(0, (v-lo)/(hi-lo)*4)))
191}
192
193// dayProgress is how far through the daylight hours it is, clamped at both
194// ends so the bar reads empty before dawn and full after dusk.
195func dayProgress(now, rise, set time.Time) int {
196	span := set.Sub(rise)
197	if span <= 0 {
198		return 0
199	}
200	switch pct := int(now.Sub(rise) * 100 / span); {
201	case pct < 0:
202		return 0
203	case pct > 100:
204		return 100
205	default:
206		return pct
207	}
208}
209
210// uvBand is the WHO scale, which is what the number means anywhere else it is
211// reported.
212func uvBand(uv float64) string {
213	switch {
214	case uv < 3:
215		return "LOW"
216	case uv < 6:
217		return "MODERATE"
218	case uv < 8:
219		return "HIGH"
220	case uv < 11:
221		return "VERY HIGH"
222	default:
223		return "EXTREME"
224	}
225}
226
227// describeWeather maps a WMO 4677 present-weather code to words. The ranges are
228// the ones open-meteo documents, collapsed to the distinctions worth making on
229// a card this size. No glyph, because an emoji is the one texture that would
230// give the whole page away as a web page.
231func describeWeather(code int) string {
232	switch {
233	case code == 0:
234		return "Clear"
235	case code <= 2:
236		return "Partly cloudy"
237	case code == 3:
238		return "Overcast"
239	case code >= 45 && code <= 48:
240		return "Fog"
241	case code >= 51 && code <= 57:
242		return "Drizzle"
243	case code >= 61 && code <= 67:
244		return "Rain"
245	case code >= 71 && code <= 77:
246		return "Snow"
247	case code >= 80 && code <= 82:
248		return "Showers"
249	case code >= 85 && code <= 86:
250		return "Snow showers"
251	case code >= 95:
252		return "Thunderstorms"
253	default:
254		return "Unknown"
255	}
256}
257
258// weatherEvery is generous because the temperature does not move fast and this
259// is the one upstream here with no interest in being polled harder.
260const weatherEvery = 15 * time.Minute