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 skills
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "time"
8)
9
10// Shared with dash. Coordinates are rounded to two places, which is all
11// open-meteo's grid resolves anyway.
12const (
13 weatherURL = "https://api.open-meteo.com/v1/forecast"
14 weatherLat = 36.13
15 weatherLon = -80.62
16 weatherPlace = "Yadkin Valley, NC"
17)
18
19// Weather answers for one place, which is the constraint that shapes its card:
20// a question naming anywhere else has to fall through rather than quietly
21// answer for here.
22type Weather struct{}
23
24func (Weather) Card() Card {
25 return Card{
26 Name: "weather",
27 Does: "reports the current conditions and the coming week's forecast for the user's own location, and only that location.",
28 Fires: []string{
29 "what is the weather this weekend",
30 "is it going to rain tomorrow",
31 "how cold is it",
32 "what's the forecast",
33 "do i need a jacket today",
34 },
35 NotFor: []string{
36 "what is the weather in tokyo",
37 "why does it rain",
38 "what was the hottest day on record",
39 "how do hurricanes form",
40 "what is the climate like in arizona",
41 },
42 Keywords: []string{"weather", "forecast", "temperature", "rain", "snow",
43 "how hot", "how cold", "how warm", "need a jacket", "need an umbrella"},
44 }
45}
46
47type weatherPayload struct {
48 Current struct {
49 Temperature float64 `json:"temperature_2m"`
50 Apparent float64 `json:"apparent_temperature"`
51 Code int `json:"weather_code"`
52 Wind float64 `json:"wind_speed_10m"`
53 } `json:"current"`
54 Daily struct {
55 Time []string `json:"time"`
56 Max []float64 `json:"temperature_2m_max"`
57 Min []float64 `json:"temperature_2m_min"`
58 Code []int `json:"weather_code"`
59 PrecipChance []int `json:"precipitation_probability_max"`
60 } `json:"daily"`
61}
62
63func (Weather) Run(ctx context.Context, question string, d Deps) (*Result, error) {
64 start := d.now()
65
66 // Naming a different place is the one thing this cannot answer, and the
67 // router is not the only guard against it, because a skill that trusts the
68 // router to be right has no way of declining.
69 if elsewhere(question) {
70 return nil, nil
71 }
72
73 url := fmt.Sprintf("%s?latitude=%.2f&longitude=%.2f"+
74 "¤t=temperature_2m,apparent_temperature,weather_code,wind_speed_10m"+
75 "&daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max"+
76 "&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch"+
77 "&timezone=America%%2FNew_York&forecast_days=7",
78 weatherURL, weatherLat, weatherLon)
79
80 var p weatherPayload
81 if err := getJSON(ctx, d, url, &p); err != nil {
82 return nil, err
83 }
84 if len(p.Daily.Time) == 0 {
85 return nil, nil
86 }
87
88 days := 3
89 if containsAny(strings.ToLower(question), "weekend", "week", "next few") {
90 days = len(p.Daily.Time)
91 }
92 if days > len(p.Daily.Time) {
93 days = len(p.Daily.Time)
94 }
95
96 var b strings.Builder
97 fmt.Fprintf(&b, "**%.0f°F in %s**, feels like %.0f, %s, wind %.0f mph.\n\n",
98 p.Current.Temperature, weatherPlace, p.Current.Apparent,
99 strings.ToLower(describeWeather(p.Current.Code)), p.Current.Wind)
100 for i := 0; i < days; i++ {
101 day := dayLabel(p.Daily.Time[i], i)
102 rain := ""
103 if i < len(p.Daily.PrecipChance) && p.Daily.PrecipChance[i] > 10 {
104 rain = fmt.Sprintf(", **%d%%** chance of rain", p.Daily.PrecipChance[i])
105 }
106 cond := ""
107 if i < len(p.Daily.Code) {
108 cond = ", " + strings.ToLower(describeWeather(p.Daily.Code[i]))
109 }
110 fmt.Fprintf(&b, "- **%s** high **%.0f°** low **%.0f°**%s%s\n",
111 day, p.Daily.Max[i], p.Daily.Min[i], cond, rain)
112 }
113 fmt.Fprintf(&b, "\nFrom open-meteo, read %s.", d.now().Format("3:04 PM on 2 January"))
114
115 return &Result{
116 Skill: "weather", Shape: "factual", Text: b.String(),
117 Sources: []Source{{URL: "https://open-meteo.com/", Title: "open-meteo", Site: "open-meteo.com"}},
118 Elapsed: d.now().Sub(start).Round(10 * time.Millisecond).String(),
119 }, nil
120}
121
122// elsewhere spots a question about a place other than home. "in" and "at" are
123// the giveaways, and the exceptions are the phrasings where they do not name a
124// place at all.
125func elsewhere(q string) bool {
126 l := strings.ToLower(q)
127 if containsAny(l, " in the morning", " in the afternoon", " in the evening",
128 " at night", " in a bit", " in an hour", " in the next") {
129 return false
130 }
131 return containsAny(l, " in ", " at ", " for ")
132}
133
134func dayLabel(iso string, i int) string {
135 t, err := time.Parse("2006-01-02", iso)
136 if err != nil {
137 return iso
138 }
139 switch i {
140 case 0:
141 return "Today"
142 case 1:
143 return "Tomorrow"
144 }
145 return t.Format("Monday")
146}
147
148// describeWeather turns a WMO code into words. Same table as dash.
149func describeWeather(code int) string {
150 switch {
151 case code == 0:
152 return "Clear"
153 case code <= 2:
154 return "Partly cloudy"
155 case code == 3:
156 return "Overcast"
157 case code <= 48:
158 return "Fog"
159 case code <= 57:
160 return "Drizzle"
161 case code <= 67:
162 return "Rain"
163 case code <= 77:
164 return "Snow"
165 case code <= 82:
166 return "Showers"
167 case code <= 86:
168 return "Snow showers"
169 default:
170 return "Thunderstorms"
171 }
172}