orchard
mirrorEvery 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
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 "¤t=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