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 "net/url"
8 "sort"
9 "strings"
10 "time"
11)
12
13// Conditions where Isaac actually is, beyond the temperature. Three upstreams,
14// all free and keyless, all guarded.
15
16const (
17 // The National Weather Service asks for a contact in the User-Agent and is
18 // otherwise open. This is the one panel that matters at three in the
19 // morning, so it polls faster than the rest of the weather.
20 alertsURL = "https://api.weather.gov/alerts/active?point=%.2f,%.2f"
21 alertsEvery = 10 * time.Minute
22
23 airURL = "https://air-quality-api.open-meteo.com/v1/air-quality"
24 airEvery = 30 * time.Minute
25
26 // pollen.com's own front end calls this, and it refuses a request without
27 // a Referer pointing back at the matching page. Unofficial, so it is the
28 // most likely thing here to break, and the panel drops the row rather than
29 // the section when it does.
30 pollenURL = "https://www.pollen.com/api/forecast/current/pollen/"
31 pollenZip = "27055"
32 pollenEvery = 6 * time.Hour
33)
34
35// Alert is one active NWS warning.
36type Alert struct {
37 Event string `json:"event"`
38 Severity string `json:"severity"`
39 Urgency string `json:"urgency"`
40 Area string `json:"area"`
41 Office string `json:"office"`
42 Headline string `json:"headline"`
43 Starts string `json:"starts"`
44 Until string `json:"until"`
45 UntilUnix int64 `json:"until_unix"`
46}
47
48type alertPayload struct {
49 Features []struct {
50 Properties struct {
51 Event string `json:"event"`
52 Severity string `json:"severity"`
53 Urgency string `json:"urgency"`
54 Headline string `json:"headline"`
55 AreaDesc string `json:"areaDesc"`
56 Onset string `json:"onset"`
57 Effective string `json:"effective"`
58 Ends string `json:"ends"`
59 Expires string `json:"expires"`
60 } `json:"properties"`
61 } `json:"features"`
62}
63
64// The NWS writes every headline off one template, "<event> issued <time> until
65// <time> by <office>", so beside a row that already carries the event and the
66// end time it is the same sentence a third time. The office is the only part
67// of it that is not already on the row.
68func alertOffice(headline string) string {
69 i := strings.LastIndex(headline, " by ")
70 if i < 0 {
71 return ""
72 }
73 return strings.ToUpper(strings.TrimPrefix(strings.TrimSpace(headline[i+4:]), "NWS "))
74}
75
76// Empty for the generated template, which every watch and warning gets, and the
77// text itself for the rare one where a forecaster wrote something.
78func alertHeadline(event, headline string) string {
79 h := strings.TrimSpace(headline)
80 if h == "" || strings.HasPrefix(strings.ToUpper(h), strings.ToUpper(strings.TrimSpace(event))) {
81 return ""
82 }
83 if i := strings.LastIndex(h, " by "); i > 0 {
84 h = strings.TrimSpace(h[:i])
85 }
86 return h
87}
88
89// areaDesc is a semicolon separated list of every county an alert covers, which
90// runs to nine names on a watch and will not fit on a phone. The home county
91// leads when it is in the list, since the row is read to find out whether the
92// thing is overhead, and the rest becomes a count.
93func alertArea(desc string) string {
94 seen := make(map[string]bool)
95 names := make([]string, 0, 8)
96 for _, part := range strings.Split(desc, ";") {
97 n := strings.TrimSpace(part)
98 if i := strings.LastIndex(n, ","); i > 0 {
99 n = strings.TrimSpace(n[:i])
100 }
101 n = strings.ToUpper(n)
102 if n == "" || seen[n] {
103 continue
104 }
105 seen[n] = true
106 names = append(names, n)
107 }
108 if len(names) == 0 {
109 return ""
110 }
111
112 lead := names[0]
113 for _, n := range names {
114 if n == homeCounty {
115 lead = n
116 break
117 }
118 }
119 if len(names) == 1 {
120 return lead
121 }
122 return fmt.Sprintf("%s +%d", lead, len(names)-1)
123}
124
125func fetchAlerts(ctx context.Context, g *Guard) ([]Alert, error) {
126 url := fmt.Sprintf(alertsURL, weatherLat, weatherLon)
127
128 var payload alertPayload
129 if err := getJSONHeaders(ctx, g, "nws", url, map[string]string{
130 // The NWS asks that automated clients identify themselves and say how
131 // to be contacted about a misbehaving one.
132 "User-Agent": "dash.bythewood.me ([email protected])",
133 "Accept": "application/geo+json",
134 }, &payload); err != nil {
135 return nil, err
136 }
137
138 alerts := make([]Alert, 0, len(payload.Features))
139 now := time.Now()
140 for _, f := range payload.Features {
141 p := f.Properties
142 a := Alert{
143 Event: strings.ToUpper(p.Event),
144 Severity: strings.ToLower(p.Severity),
145 Urgency: strings.ToLower(p.Urgency),
146 Area: alertArea(p.AreaDesc),
147 Office: alertOffice(p.Headline),
148 Headline: alertHeadline(p.Event, p.Headline),
149 }
150
151 ends := p.Ends
152 if ends == "" {
153 ends = p.Expires
154 }
155 if t, err := time.Parse(time.RFC3339, ends); err == nil {
156 a.Until = strings.ToUpper(t.In(easternTime()).Format("Mon 3:04pm"))
157 a.UntilUnix = t.Unix()
158 }
159
160 // A watch for this evening and one already overhead are different rows,
161 // and the start time is the only thing that says which.
162 onset := p.Onset
163 if onset == "" {
164 onset = p.Effective
165 }
166 if t, err := time.Parse(time.RFC3339, onset); err == nil && t.After(now) {
167 a.Starts = strings.ToUpper(t.In(easternTime()).Format("Mon 3:04pm"))
168 }
169
170 alerts = append(alerts, a)
171 }
172
173 // Worst first, so a tornado warning is never below a frost advisory.
174 rank := map[string]int{"extreme": 0, "severe": 1, "moderate": 2, "minor": 3}
175 sort.SliceStable(alerts, func(i, j int) bool {
176 ri, ok := rank[alerts[i].Severity]
177 if !ok {
178 ri = 4
179 }
180 rj, ok := rank[alerts[j].Severity]
181 if !ok {
182 rj = 4
183 }
184 return ri < rj
185 })
186 return alerts, nil
187}
188
189// Air is the air quality panel, and pollen when the unofficial source answers.
190type Air struct {
191 AQI int `json:"aqi"`
192 AQIState string `json:"aqi_state"`
193 PM25 string `json:"pm25"`
194 Known bool `json:"known"`
195
196 Pollen string `json:"pollen"`
197 PollenState string `json:"pollen_state"`
198 PollenTop string `json:"pollen_top"`
199 PollenKnown bool `json:"pollen_known"`
200
201 // Each reading placed on its own scale, so UV, air quality and pollen read
202 // as three of the same instrument rather than as three unrelated numbers
203 // that happen to sit beside each other. Level is the severity step the
204 // colour comes from.
205 AQIFill float64 `json:"aqi_fill"`
206 AQILevel int `json:"aqi_level"`
207 PollenFill float64 `json:"pollen_fill"`
208 PollenLevel int `json:"pollen_level"`
209}
210
211// gauge places a reading on a scale that ends where the scale's own top band
212// begins, and buckets it into four steps for colour.
213func gauge(v, top float64, edges ...float64) (float64, int) {
214 fill := math.Round(math.Min(100, math.Max(0, v/top*100))*10) / 10
215
216 level := 0
217 for _, e := range edges {
218 if v >= e {
219 level++
220 }
221 }
222 return fill, level
223}
224
225type airPayload struct {
226 Current struct {
227 USAQI float64 `json:"us_aqi"`
228 PM25 float64 `json:"pm2_5"`
229 } `json:"current"`
230}
231
232func fetchAir(ctx context.Context, g *Guard) (Air, error) {
233 q := url.Values{}
234 q.Set("latitude", fmt.Sprintf("%.2f", weatherLat))
235 q.Set("longitude", fmt.Sprintf("%.2f", weatherLon))
236 q.Set("current", "us_aqi,pm2_5")
237 q.Set("timezone", "America/New_York")
238
239 var payload airPayload
240 if err := getJSON(ctx, g, "openmeteo", airURL+"?"+q.Encode(), &payload); err != nil {
241 return Air{}, err
242 }
243
244 aqi := int(payload.Current.USAQI)
245 air := Air{
246 AQI: aqi,
247 AQIState: aqiBand(aqi),
248 PM25: fmt.Sprintf("%.1f", payload.Current.PM25),
249 Known: true,
250 }
251 // Scaled to 200 rather than to the 500 the index runs to, since anything
252 // over 200 here would be smoke from a fire two states away and the bar has
253 // to say something on an ordinary day.
254 air.AQIFill, air.AQILevel = gauge(float64(aqi), 200, 50, 100, 150)
255 return air, nil
256}
257
258// aqiBand is the EPA's own scale, which is what the number means and what every
259// other reading of it will say.
260func aqiBand(aqi int) string {
261 switch {
262 case aqi <= 50:
263 return "GOOD"
264 case aqi <= 100:
265 return "MODERATE"
266 case aqi <= 150:
267 return "SENSITIVE GROUPS"
268 case aqi <= 200:
269 return "UNHEALTHY"
270 case aqi <= 300:
271 return "VERY UNHEALTHY"
272 default:
273 return "HAZARDOUS"
274 }
275}
276
277type pollenPayload struct {
278 Location struct {
279 Periods []struct {
280 Type string `json:"Type"`
281 Index float64 `json:"Index"`
282 Triggers []struct {
283 Name string `json:"Name"`
284 } `json:"Triggers"`
285 } `json:"periods"`
286 } `json:"Location"`
287}
288
289// fetchPollen returns today's index. open-meteo carries pollen for Europe only,
290// so there is no keyless first party source for North America and this is the
291// one the pollen.com front end uses.
292func fetchPollen(ctx context.Context, g *Guard) (index float64, top string, err error) {
293 var payload pollenPayload
294 if err := getJSONHeaders(ctx, g, "pollen", pollenURL+pollenZip, map[string]string{
295 "Referer": "https://www.pollen.com/forecast/current/pollen/" + pollenZip,
296 }, &payload); err != nil {
297 return 0, "", err
298 }
299
300 for _, p := range payload.Location.Periods {
301 if !strings.EqualFold(p.Type, "Today") {
302 continue
303 }
304 // Two, because a third wraps the cell onto a second line and the panel
305 // is a readout rather than a list of what is in the air.
306 names := make([]string, 0, 2)
307 for _, t := range p.Triggers {
308 if len(names) == 2 {
309 break
310 }
311 names = append(names, strings.ToUpper(t.Name))
312 }
313 return p.Index, strings.Join(names, " ยท "), nil
314 }
315 return 0, "", fmt.Errorf("pollen: no reading for today")
316}
317
318// pollenBand is pollen.com's own 0 to 12 scale.
319func pollenBand(index float64) string {
320 switch {
321 case index < 2.5:
322 return "LOW"
323 case index < 4.9:
324 return "LOW-MED"
325 case index < 7.3:
326 return "MEDIUM"
327 case index < 9.7:
328 return "MED-HIGH"
329 default:
330 return "HIGH"
331 }
332}