aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortwells46 <tom@wellsth.com>2026-03-07 08:50:19 -0600
committertwells46 <tom@wellsth.com>2026-03-07 09:07:19 -0600
commitd2b6b3060cfbeb17047f1fd1f989677f28f677eb (patch)
tree1f17a8d76ef752bee60f20d338d56fdd4a597fb3
Initial commit
-rw-r--r--.gitignore1
-rw-r--r--README.md29
-rw-r--r--go.mod34
-rw-r--r--go.sum58
-rw-r--r--main.go223
-rw-r--r--makefile2
-rw-r--r--openWeather.go272
7 files changed, 619 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3f6eb93
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+porch
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9717c3d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,29 @@
+# Porch
+
+Step onto the `porch` and see the weather!
+
+## Usage
+
+First, place your OpenWeatherMap API key in `~/.config/porch/key.txt`.
+You can get one for free [here](https://openweathermap.org/).
+If you wish, you can place your home city in `~/.config/porch/defaultCity.txt`.
+
+To get weather for your default city (if set), run:
+
+```sh
+porch
+```
+
+To get weather for any city, run:
+
+```sh
+porch <city>
+```
+
+For example:
+
+```sh
+porch New York
+```
+
+`porch` can handle spaces in city names.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2c5550a
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,34 @@
+module git.wellsth.com/twells46/porch
+
+go 1.25.4
+
+require (
+ charm.land/lipgloss/v2 v2.0.0 // indirect
+ github.com/NimbleMarkets/ntcharts v0.4.0 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/bubbles v0.20.0 // indirect
+ github.com/charmbracelet/bubbletea v1.2.2 // indirect
+ github.com/charmbracelet/colorprofile v0.4.2 // indirect
+ github.com/charmbracelet/lipgloss v1.0.0 // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/charmbracelet/x/termios v0.1.1 // indirect
+ github.com/charmbracelet/x/windows v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.15.2 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sync v0.18.0 // indirect
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/text v0.20.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7c1e330
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,58 @@
+charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
+charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
+github.com/NimbleMarkets/ntcharts v0.4.0 h1:BtrER5o6s3xMAebhSDQZpdFdfVMGMpV4Qz8lD+Qiw5g=
+github.com/NimbleMarkets/ntcharts v0.4.0/go.mod h1:zVeRqYkh2n59YPe1bflaSL4O2aD2ZemNmrbdEqZ70hk=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
+github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
+github.com/charmbracelet/bubbletea v1.2.2 h1:EMz//Ky/aFS2uLcKqpCst5UOE6z5CFDGRsUpyXz0chs=
+github.com/charmbracelet/bubbletea v1.2.2/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM=
+github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
+github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
+github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
+github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
+github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=
+github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=
+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e h1:OLwZ8xVaeVrru0xyeuOX+fne0gQTFEGlzfNjipCbxlU=
+github.com/lrstanley/bubblezone v0.0.0-20240914071701-b48c55a5e78e/go.mod h1:NQ34EGeu8FAYGBMDzwhfNJL8YQYoWZP5xYJPRDAwN3E=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
+github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..d191c72
--- /dev/null
+++ b/main.go
@@ -0,0 +1,223 @@
+package main
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "charm.land/lipgloss/v2"
+ "github.com/NimbleMarkets/ntcharts/linechart/timeserieslinechart"
+)
+
+var block = lipgloss.NewStyle().
+ Padding(1).
+ BorderStyle(lipgloss.NormalBorder()).
+ BorderForeground(lipgloss.Magenta)
+var bold = lipgloss.NewStyle().
+ Bold(true)
+var tStyle = bold
+
+// TODO Faint should render based on light/dark terminal theme
+var faint = lipgloss.NewStyle().
+ Foreground(lipgloss.BrightWhite)
+
+var temperature_colors = lipgloss.Blend1D(20,
+ lipgloss.Color("#94006f"),
+ lipgloss.Blue,
+ lipgloss.Blue,
+ lipgloss.Green,
+ lipgloss.Color("#ff5500"),
+ lipgloss.Red,
+)
+
+// TODO Handle light theme
+var rain_colors = lipgloss.Blend1D(10,
+ lipgloss.White,
+ lipgloss.Blue,
+)
+
+func main() {
+ weather, coords := GetWeather()
+ header := fmt.Sprintf("%s, %s (%s, %s)\n",
+ bold.Render(coords.Name),
+ bold.Render(coords.State),
+ faint.Render(ff(weather.Lat)),
+ faint.Render(ff(weather.Lon)),
+ )
+ current := fmtCurrent(weather)
+ minutely := fmtMinutely(weather)
+ hourly := fmtHourly(weather)
+
+ lipgloss.Println(lipgloss.JoinVertical(lipgloss.Center,
+ header,
+ current,
+ minutely,
+ hourly,
+ ))
+}
+
+func fmtHourly(w WeatherResponse) string {
+ hours := make([]string, len(w.Hourly))
+ // TODO Make this not ugly and include units
+ for i, v := range w.Hourly {
+ // For now only show next 8
+ // 48 available later
+ if i > 7 {
+ break
+ }
+ temperature_color := int(v.Temp / 5)
+ tStyle = tStyle.Foreground(temperature_colors[temperature_color])
+ rain_color := int(v.Pop * 9)
+ rStyle := tStyle.Foreground(rain_colors[rain_color])
+ now := time.Unix(v.Dt, 0).Local()
+ hours[i] = block.Render(lipgloss.JoinVertical(lipgloss.Center,
+ now.Format(time.DateOnly),
+ now.Format(time.Kitchen),
+ tStyle.Render(fmt.Sprintf("%g°F", v.Temp)),
+ rStyle.Render(fmt.Sprintf("雨%d%%", int(v.Pop*100))),
+ ))
+ }
+
+ return lipgloss.JoinHorizontal(lipgloss.Center, hours...)
+}
+
+/*
+func fmtHourlyGraph(w WeatherResponse) string {
+ dataset := make([]timeserieslinechart.TimePoint, len(w.Hourly))
+
+ for i, v := range w.Hourly {
+ fmt.Println(linechart.DefaultLabelFormatter()(i, float64(v.Dt)))
+ dataset[i] = timeserieslinechart.TimePoint{
+ Time: time.Unix(v.Dt, 0).Local(),
+ Value: v.Temp,
+ }
+ }
+ chart := timeserieslinechart.New(
+ 80,
+ 10,
+ timeserieslinechart.WithTimeSeries(dataset),
+ timeserieslinechart.WithXLabelFormatter(func(i int, _ float64) string {
+ res := time.Unix(w.Hourly[0].Dt, 0).Add(time.Hour * time.Duration(i))
+ return res.Local().Format(time.Kitchen)
+ }),
+ )
+ chart.DrawBraille()
+
+ return block.Render(chart.View())
+}
+*/
+
+func fmtMinutely(w WeatherResponse) string {
+ dataset := make([]timeserieslinechart.TimePoint, len(w.Minutely))
+
+ empty := true
+ for i, v := range w.Minutely {
+ now := time.Unix(v.Dt, 0).Local()
+ if v.Precipitation != 0 {
+ empty = false
+ }
+ dataset[i] = timeserieslinechart.TimePoint{
+ Time: now,
+ Value: v.Precipitation,
+ }
+ }
+
+ if empty {
+ return ""
+ }
+
+ tslc := timeserieslinechart.New(
+ 80,
+ 10,
+ timeserieslinechart.WithXLabelFormatter(func(_ int, d float64) string {
+ res := (int64(d) - w.Minutely[0].Dt) / 60
+ return strconv.Itoa(int(res))
+ }),
+ timeserieslinechart.WithTimeSeries(dataset),
+ )
+
+ tslc.DrawBraille()
+
+ return block.BorderForeground(lipgloss.Blue).Render(
+ lipgloss.JoinVertical(lipgloss.Center,
+ bold.Render("Precipitation"),
+ lipgloss.JoinHorizontal(lipgloss.Center,
+ "mm/h",
+ tslc.View(),
+ ),
+ "minutes",
+ ),
+ )
+}
+
+func fmtCurrent(w WeatherResponse) string {
+ // TODO This color gradient only handles 0-100
+ temperature_color := int(w.Current.Temp / 5)
+ tStyle = tStyle.Foreground(temperature_colors[temperature_color])
+
+ var b strings.Builder
+
+ // Temperature
+ fmt.Fprintf(&b, "%s°F (%s°F)",
+ tStyle.Render(ff(w.Current.Temp)),
+ tStyle.Render(ff(w.Current.FeelsLike)),
+ )
+ temp := block.Render(b.String())
+ b.Reset()
+
+ fmt.Fprintf(&b, "Humidity: %s%%",
+ bold.Render(strconv.Itoa(w.Current.Humidity)),
+ )
+ humidity := block.Render(b.String())
+ b.Reset()
+
+ fmt.Fprintf(&b, "Clouds: %s%%",
+ bold.Render(strconv.Itoa(w.Current.Clouds)),
+ )
+ clouds := block.Render(b.String())
+ b.Reset()
+
+ fmt.Fprintf(&b, "%s° at %s MPH",
+ bold.Render(strconv.Itoa(w.Current.WindDeg)),
+ bold.Render(ff(w.Current.WindSpeed)),
+ )
+ wind := block.Render(b.String())
+ b.Reset()
+
+ var rain string
+ if w.Current.Rain != nil {
+ rain = block.
+ BorderForeground(lipgloss.Blue).
+ Render(fmt.Sprintf("Rain:\n%v mm/h", w.Current.Rain.Hour))
+ b.Reset()
+ }
+
+ var snow string
+ if w.Current.Snow != nil {
+ snow = block.
+ BorderForeground(lipgloss.Blue).
+ Render(fmt.Sprintf("Snow:\n%v mm/h", w.Current.Snow.Hour))
+ b.Reset()
+ }
+
+ now := time.Unix(w.Current.Dt, 0).Local()
+ subheader := fmt.Sprintf("%s with %s", now.Format(time.Kitchen), w.Current.Weather[0].Description)
+
+ return lipgloss.JoinVertical(lipgloss.Center,
+ subheader,
+ lipgloss.JoinHorizontal(lipgloss.Top,
+ temp,
+ humidity,
+ clouds,
+ wind,
+ rain,
+ snow,
+ ),
+ )
+}
+
+// [f]ormat [f]loat
+func ff(f float64) string {
+ return strconv.FormatFloat(f, 'f', -1, 64)
+}
diff --git a/makefile b/makefile
new file mode 100644
index 0000000..58199ed
--- /dev/null
+++ b/makefile
@@ -0,0 +1,2 @@
+default: main.go openWeather.go
+ go build -ldflags="-s -w" -trimpath
diff --git a/openWeather.go b/openWeather.go
new file mode 100644
index 0000000..84f7cfd
--- /dev/null
+++ b/openWeather.go
@@ -0,0 +1,272 @@
+package main
+
+import (
+ "encoding/csv"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+)
+
+type coordsResponse []struct {
+ Name string `json:"name"`
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+ Country string `json:"country"`
+ State string `json:"state"`
+}
+
+type Coords struct {
+ Name string `json:"name"`
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+ Country string `json:"country"`
+ State string `json:"state"`
+}
+
+type Rain struct {
+ Hour float64 `json:"1h"`
+}
+type Snow struct {
+ Hour float64 `json:"1h"`
+}
+
+type WeatherResponse struct {
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+ Timezone string `json:"timezone"`
+ TimezoneOffset int `json:"timezone_offset"`
+ Current struct {
+ Dt int64 `json:"dt"`
+ Sunrise int `json:"sunrise"`
+ Sunset int `json:"sunset"`
+ Temp float64 `json:"temp"`
+ FeelsLike float64 `json:"feels_like"`
+ Pressure int `json:"pressure"`
+ Humidity int `json:"humidity"`
+ DewPoint float64 `json:"dew_point"`
+ Uvi float64 `json:"uvi"`
+ Clouds int `json:"clouds"`
+ Visibility int `json:"visibility"`
+ WindSpeed float64 `json:"wind_speed"`
+ WindDeg int `json:"wind_deg"`
+ Rain *Rain `json:"rain,omitempty"`
+ Snow *Snow `json:"snow,omitempty"`
+ Weather []struct {
+ ID int `json:"id"`
+ Main string `json:"main"`
+ Description string `json:"description"`
+ Icon string `json:"icon"`
+ } `json:"weather"`
+ } `json:"current"`
+ Minutely []struct {
+ Dt int64 `json:"dt"`
+ Precipitation float64 `json:"precipitation"`
+ } `json:"minutely"`
+ Hourly []struct {
+ Dt int64 `json:"dt"`
+ Temp float64 `json:"temp"`
+ FeelsLike float64 `json:"feels_like"`
+ Pressure int `json:"pressure"`
+ Humidity int `json:"humidity"`
+ DewPoint float64 `json:"dew_point"`
+ Uvi float64 `json:"uvi"`
+ Clouds int `json:"clouds"`
+ Visibility int `json:"visibility,omitempty"`
+ WindSpeed float64 `json:"wind_speed"`
+ WindDeg int `json:"wind_deg"`
+ WindGust float64 `json:"wind_gust"`
+ Weather []struct {
+ ID int `json:"id"`
+ Main string `json:"main"`
+ Description string `json:"description"`
+ Icon string `json:"icon"`
+ } `json:"weather"`
+ Pop float64 `json:"pop"`
+ Rain *Rain `json:"rain,omitempty"`
+ Snow *Snow `json:"snow,omitempty"`
+ } `json:"hourly"`
+ Daily []struct {
+ Dt int64 `json:"dt"`
+ Sunrise int `json:"sunrise"`
+ Sunset int `json:"sunset"`
+ Moonrise int `json:"moonrise"`
+ Moonset int `json:"moonset"`
+ MoonPhase float64 `json:"moon_phase"`
+ Summary string `json:"summary"`
+ Temp struct {
+ Day float64 `json:"day"`
+ Min float64 `json:"min"`
+ Max float64 `json:"max"`
+ Night float64 `json:"night"`
+ Eve float64 `json:"eve"`
+ Morn float64 `json:"morn"`
+ } `json:"temp"`
+ FeelsLike struct {
+ Day float64 `json:"day"`
+ Night float64 `json:"night"`
+ Eve float64 `json:"eve"`
+ Morn float64 `json:"morn"`
+ } `json:"feels_like"`
+ Pressure int `json:"pressure"`
+ Humidity int `json:"humidity"`
+ DewPoint float64 `json:"dew_point"`
+ WindSpeed float64 `json:"wind_speed"`
+ WindDeg int `json:"wind_deg"`
+ WindGust float64 `json:"wind_gust"`
+ Weather []struct {
+ ID int `json:"id"`
+ Main string `json:"main"`
+ Description string `json:"description"`
+ Icon string `json:"icon"`
+ } `json:"weather"`
+ Clouds int `json:"clouds"`
+ Pop float64 `json:"pop"`
+ Rain float64 `json:"rain,omitempty"`
+ Uvi float64 `json:"uvi"`
+ } `json:"daily"`
+}
+
+func GetWeather() (WeatherResponse, Coords) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ log.Fatalf("Failed to read homedir with %v", err)
+ }
+ key := readFile(fmt.Sprint(home, "/.config/porch/key.txt"))
+ var city string
+ if len(os.Args) < 2 {
+ city = readFile(fmt.Sprint(home, "/.config/porch/defaultCity.txt"))
+ } else {
+ var b strings.Builder
+ b.WriteString(os.Args[1])
+ if len(os.Args) > 2 {
+ for _, v := range os.Args[2:] {
+ b.WriteString(fmt.Sprintf(" %s", v))
+ }
+ }
+ city = b.String()
+ }
+
+ cityMemo := fmt.Sprint(home, "/.config/porch/cities.csv")
+ var coords Coords
+ coords, found := memoRead(city, cityMemo)
+ if !found {
+ cityQuery := fmtCityQuery(strings.ReplaceAll(city, " ", "_"), key)
+ resp := getUrlResponse(cityQuery)
+ var coordsResp coordsResponse
+ err := json.Unmarshal(resp, &coordsResp)
+ if err != nil {
+ panic(err)
+ }
+ if len(coordsResp) < 1 {
+ log.Fatalf("No results found for city \"%s\"\n", city)
+ }
+ coords = coordsResp[0]
+ rec := coords.toStringRec()
+ memoWrite(rec, cityMemo)
+ }
+
+ weatherQuery := fmtWeatherQuery(coords.Lat, coords.Lon, key)
+ resp := getUrlResponse(weatherQuery)
+ var weatherResp WeatherResponse
+ err = json.Unmarshal(resp, &weatherResp)
+ if err != nil {
+ panic(err)
+ }
+ return weatherResp, coords
+}
+
+func readFile(keyfile string) string {
+ k, err := os.ReadFile(keyfile)
+ if err != nil {
+ panic(err)
+ }
+ return strings.TrimSpace(string(k))
+}
+
+func fmtCityQuery(city string, key string) string {
+ return fmt.Sprintf("https://api.openweathermap.org/geo/1.0/direct?q=%s&limit=1&appid=%s", city, key)
+}
+
+func fmtWeatherQuery(lat float64, lon float64, key string) string {
+ return fmt.Sprintf("https://api.openweathermap.org/data/3.0/onecall?lat=%f&lon=%f&appid=%s&units=imperial", lat, lon, key)
+}
+
+func getUrlResponse(u string) []byte {
+ resp, err := http.Get(u)
+ if err != nil {
+ panic(err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ panic(err)
+ }
+ return body
+}
+
+func (c Coords) toStringRec() []string {
+ return []string{
+ c.Name,
+ strconv.FormatFloat(c.Lat, 'f', -1, 64),
+ strconv.FormatFloat(c.Lon, 'f', -1, 64),
+ c.Country,
+ c.State,
+ }
+}
+func coordsFromStringRec(rec []string) Coords {
+ lat, err := strconv.ParseFloat(rec[1], 64)
+ if err != nil {
+ panic(err)
+ }
+ lon, err := strconv.ParseFloat(rec[2], 64)
+ if err != nil {
+ panic(err)
+ }
+ return Coords{
+ rec[0],
+ lat,
+ lon,
+ rec[3],
+ rec[4],
+ }
+}
+
+func memoRead(city string, memoP string) (Coords, bool) {
+ f, err := os.OpenFile(memoP, os.O_RDONLY|os.O_CREATE, 0666)
+ if err != nil {
+ panic(err)
+ }
+ defer f.Close()
+
+ reader := csv.NewReader(f)
+ existing, err := reader.ReadAll()
+ if err != nil {
+ panic(err)
+ }
+
+ // Does this record already exist?
+ for _, rec := range existing {
+ if rec[0] == city {
+ return coordsFromStringRec(rec), true
+ }
+ }
+ return Coords{}, false
+}
+
+// Write a record. Does not check for duplication.
+func memoWrite(r []string, memoP string) {
+ f, err := os.OpenFile(memoP, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0666)
+ if err != nil {
+ panic(err)
+ }
+ defer f.Close()
+
+ writer := csv.NewWriter(f)
+ writer.Write(r)
+ writer.Flush()
+}