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

9.4 KB · 333 lines · Go Raw History
  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}