initial commit
This commit is contained in:
commit
b6c5e09026
6 changed files with 1287 additions and 0 deletions
549
main.go
Normal file
549
main.go
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
// Copyright (C) 2025 Marius Schellenberger
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"iter"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
X = 82
|
||||
maxX = 574
|
||||
maxStart = 150
|
||||
Y = 431
|
||||
)
|
||||
|
||||
func scale(img image.Image) int {
|
||||
startY := Y
|
||||
for y := Y; y > 0; y-- {
|
||||
col := img.At(X, y)
|
||||
if !isBlack(col) {
|
||||
startY = y
|
||||
break
|
||||
}
|
||||
}
|
||||
c := 0
|
||||
for y := startY; y > 0; y-- {
|
||||
col := img.At(X, y)
|
||||
if isWhite(col) {
|
||||
c = startY - y
|
||||
startY = y
|
||||
break
|
||||
}
|
||||
}
|
||||
for y := startY; y > 0; y-- {
|
||||
col := img.At(X, y)
|
||||
if !isWhite(col) {
|
||||
return (startY + c) - y
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func start(img image.Image) int {
|
||||
for x := X; x < maxStart; x++ {
|
||||
col := img.At(x, Y)
|
||||
if !isStart(col) {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func uv(img image.Image, x, s int) float64 {
|
||||
start := Y
|
||||
end := Y
|
||||
for y := start; y > 0; y-- {
|
||||
col := img.At(x, y)
|
||||
if isBlack(col) || isWhite(col) {
|
||||
end = y - 1
|
||||
break
|
||||
}
|
||||
}
|
||||
v := start - end
|
||||
if v <= s {
|
||||
return float64(v) / float64(s)
|
||||
}
|
||||
return float64(v+s) / 2 / float64(s)
|
||||
}
|
||||
|
||||
func getUV(img image.Image) (vals []float64) {
|
||||
s := scale(img)
|
||||
if s == -1 {
|
||||
return
|
||||
}
|
||||
x := start(img)
|
||||
if x == -1 {
|
||||
return
|
||||
}
|
||||
for ; x < maxX; x++ {
|
||||
col := img.At(x, Y)
|
||||
if isStop(col) {
|
||||
break
|
||||
}
|
||||
vals = append(vals, uv(img, x, s))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var listen string
|
||||
|
||||
var tmp = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>UV Index</title>
|
||||
<style>
|
||||
a, a:link, a:visited {
|
||||
color: #007d9c;
|
||||
}
|
||||
</style>
|
||||
<meta name="theme-color" content="#d6d6d6">
|
||||
</head>
|
||||
<body style="background-color:#d6d6d6;">
|
||||
<center>
|
||||
<h5 style="font-size:4em;">UV Index</h5>
|
||||
<h1 style="font-size:18em;color:#%s;">%s</h1>
|
||||
<h5 style="font-size:4em;">Updated: %s</h5>
|
||||
<h5 style="font-size:4em;">Server: %s</h5>
|
||||
<h5 style="font-size:4em;"><a href="/set">Location</a>: %s</h5>
|
||||
</center>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
func getColor(v float64) string {
|
||||
if v < 1.0 {
|
||||
return "006300"
|
||||
} else if v >= 1.0 && v <= 2.0 {
|
||||
return "00a014"
|
||||
} else if v >= 2.0 && v <= 3.0 {
|
||||
return "81c600"
|
||||
} else if v >= 3.0 && v <= 4.0 {
|
||||
return "fff800"
|
||||
} else if v >= 4.0 && v <= 5.0 {
|
||||
return "ffd100"
|
||||
} else if v >= 5.0 && v <= 6.0 {
|
||||
return "ffa600"
|
||||
} else if v >= 6.0 && v <= 7.0 {
|
||||
return "ff7200"
|
||||
} else if v >= 7.0 && v <= 8.0 {
|
||||
return "ff4a00"
|
||||
} else if v >= 8.0 && v <= 9.0 {
|
||||
return "ff0000"
|
||||
} else if v >= 9.0 && v <= 10.0 {
|
||||
return "ff0078"
|
||||
} else if v >= 10.0 {
|
||||
return "a80080"
|
||||
}
|
||||
return "000000"
|
||||
}
|
||||
|
||||
var locPage = ""
|
||||
|
||||
func main() {
|
||||
flag.StringVar(&listen, "l", ":7878", "listen addr")
|
||||
flag.Parse()
|
||||
|
||||
url := "https://uvi.bfs.de/Tagesgrafiken"
|
||||
generateLocPage()
|
||||
|
||||
ic := &ImageCrawler{
|
||||
url: url,
|
||||
c: &http.Client{},
|
||||
m: make(map[string]*Image),
|
||||
}
|
||||
http.HandleFunc("/set", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" {
|
||||
r.ParseForm()
|
||||
loc := r.PostForm.Get("loc")
|
||||
if _, ok := locations[loc]; ok {
|
||||
c := &http.Cookie{
|
||||
Name: "loc",
|
||||
Path: "/",
|
||||
Value: loc,
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
http.SetCookie(w, c)
|
||||
http.Redirect(w, r, "/", http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Write([]byte(locPage))
|
||||
})
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
cc := r.CookiesNamed("loc")
|
||||
var c *http.Cookie
|
||||
if len(cc) == 0 {
|
||||
c = &http.Cookie{
|
||||
Name: "loc",
|
||||
Path: "/",
|
||||
Value: "24",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
http.SetCookie(w, c)
|
||||
} else {
|
||||
c = cc[0]
|
||||
}
|
||||
loc := c.Value
|
||||
img, mod, err := ic.getImage(loc)
|
||||
mod = mod.Local()
|
||||
location := selectListReverse[loc]
|
||||
if err != nil {
|
||||
|
||||
fmt.Fprintf(w, tmp,
|
||||
"000000",
|
||||
"Err",
|
||||
mod.Format(time.RFC1123),
|
||||
time.Now().Format(time.RFC1123),
|
||||
location,
|
||||
)
|
||||
return
|
||||
}
|
||||
vals := getUV(img)
|
||||
if len(vals) == 0 {
|
||||
fmt.Fprintf(w, tmp,
|
||||
"000000",
|
||||
"Err",
|
||||
mod.Format(time.RFC1123),
|
||||
time.Now().Format(time.RFC1123),
|
||||
location,
|
||||
)
|
||||
return
|
||||
}
|
||||
last := vals[len(vals)-1]
|
||||
fmt.Fprintf(w, tmp,
|
||||
getColor(last),
|
||||
fmt.Sprintf("%.2f", last),
|
||||
mod.Format(time.RFC1123),
|
||||
time.Now().Format(time.RFC1123),
|
||||
location,
|
||||
)
|
||||
})
|
||||
err := http.ListenAndServe(listen, nil)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type ImageCrawler struct {
|
||||
mu sync.RWMutex
|
||||
url string
|
||||
c *http.Client
|
||||
m map[string]*Image
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
mu sync.RWMutex
|
||||
ic *ImageCrawler
|
||||
file string
|
||||
img image.Image
|
||||
etag string
|
||||
modified time.Time
|
||||
lastGet time.Time
|
||||
}
|
||||
|
||||
func (ic *ImageCrawler) getImage(loc string) (img image.Image, mod time.Time, err error) {
|
||||
file, ok := locations[loc]
|
||||
if !ok {
|
||||
return nil, time.Time{}, errors.New("invalid location")
|
||||
}
|
||||
ic.mu.RLock()
|
||||
if i, ok := ic.m[loc]; ok {
|
||||
ic.mu.RUnlock()
|
||||
return i.getImage()
|
||||
}
|
||||
ic.mu.RUnlock()
|
||||
ic.mu.Lock()
|
||||
i := &Image{
|
||||
ic: ic,
|
||||
file: file,
|
||||
}
|
||||
ic.m[loc] = i
|
||||
ic.mu.Unlock()
|
||||
return i.getImage()
|
||||
}
|
||||
|
||||
func (i *Image) getImage() (img image.Image, mod time.Time, err error) {
|
||||
i.mu.RLock()
|
||||
if !i.lastGet.IsZero() &&
|
||||
time.Now().Sub(i.lastGet) < time.Minute*3 {
|
||||
defer i.mu.RUnlock()
|
||||
return i.img, i.modified, nil
|
||||
}
|
||||
i.mu.RUnlock()
|
||||
req, err := http.NewRequest("GET", i.ic.url+"/"+i.file, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
i.mu.RLock()
|
||||
if !i.modified.IsZero() {
|
||||
req.Header.Set("if-modified-since", i.modified.Format(time.RFC1123))
|
||||
}
|
||||
if i.etag != "" {
|
||||
req.Header.Set("if-none-match", i.etag)
|
||||
}
|
||||
i.mu.RUnlock()
|
||||
resp, err := i.ic.c.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
return i.img, i.modified, nil
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
img, err = png.Decode(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.img = img
|
||||
modTime, err := time.Parse(time.RFC1123, resp.Header.Get("last-modified"))
|
||||
if err == nil {
|
||||
i.modified = modTime
|
||||
}
|
||||
i.etag = resp.Header.Get("etag")
|
||||
i.lastGet = time.Now()
|
||||
defer i.mu.Unlock()
|
||||
return i.img, i.modified, nil
|
||||
}
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
if i.img != nil {
|
||||
return i.img, i.modified, nil
|
||||
}
|
||||
return nil, time.Time{}, errors.New("error getting image")
|
||||
}
|
||||
|
||||
func isWhite(col color.Color) bool {
|
||||
col = color.RGBAModel.Convert(col)
|
||||
c, ok := col.(color.RGBA)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if c.R == 255 &&
|
||||
c.G == 255 &&
|
||||
c.B == 255 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBlack(col color.Color) bool {
|
||||
col = color.RGBAModel.Convert(col)
|
||||
c, ok := col.(color.RGBA)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if c.R == 0 &&
|
||||
c.G == 0 &&
|
||||
c.B == 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isStop(col color.Color) bool {
|
||||
col = color.RGBAModel.Convert(col)
|
||||
c, ok := col.(color.RGBA)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if c.R == 241 &&
|
||||
c.G == 241 &&
|
||||
c.B == 241 {
|
||||
return true
|
||||
}
|
||||
if c.R == 88 &&
|
||||
c.G == 88 &&
|
||||
c.B == 88 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isStart(col color.Color) bool {
|
||||
col = color.RGBAModel.Convert(col)
|
||||
c, ok := col.(color.RGBA)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if c.R == 241 &&
|
||||
c.G == 241 &&
|
||||
c.B == 241 {
|
||||
return true
|
||||
}
|
||||
if c.R == 214 &&
|
||||
c.G == 214 &&
|
||||
c.B == 214 {
|
||||
return true
|
||||
}
|
||||
if c.R == 0 &&
|
||||
c.G == 0 &&
|
||||
c.B == 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func generateLocPage() {
|
||||
loc := `<!doctype html><html>
|
||||
<head>
|
||||
<title>UV Index Change Location</title>
|
||||
<meta name="theme-color" content="#d6d6d6">
|
||||
</head>
|
||||
<body style="background-color:#d6d6d6;">
|
||||
<center>
|
||||
<h5 style="font-size:4em;">Change Location</h5>
|
||||
<form action="/set" method="post">
|
||||
<select name="loc" style="font-size:5em;margin-bottom:50px;">`
|
||||
for k, v := range sortedMap(selectList) {
|
||||
loc += `<option value="` + v + `">` + k + `</option>`
|
||||
}
|
||||
loc += `</select><br><input type="submit" style="font-size:5em;" value="Change"></input></form></center></body></html>`
|
||||
locPage = loc
|
||||
}
|
||||
|
||||
var locations = map[string]string{
|
||||
"0": "EEr_Andernach_today.png",
|
||||
"1": "EEr_Berlin_Potsdamer_Platz_today.png",
|
||||
"2": "EEr_Bonn_today.png",
|
||||
"3": "EEr_Boesel_B_today.png",
|
||||
"4": "EEr_Chieming_today.png",
|
||||
"5": "EEr_Cuxhaven_B_today.png",
|
||||
"6": "EEr_Dortmund_today.png",
|
||||
"7": "EEr_Duderstadt_B_today.png",
|
||||
"8": "EEr_Eckernfoerde_today.png",
|
||||
"9": "EEr_Fichtelberg_today.png",
|
||||
"10": "EEr_Friedrichshafen_today.png",
|
||||
"11": "EEr_Genthin_today.png",
|
||||
"12": "EEr_Giessen-Wettenberg_today.png",
|
||||
"13": "EEr_Goerlitz_today.png",
|
||||
"14": "EEr_Groemitz_today.png",
|
||||
"15": "EEr_Hamburg_today.png",
|
||||
"16": "EEr_Hohenpeissenberg_today.png",
|
||||
"17": "EEr_Kassel_today.png",
|
||||
"18": "EEr_Klippeneck_today.png",
|
||||
"19": "EEr_Kulmbach_today.png",
|
||||
"20": "EEr_Langen_today.png",
|
||||
"21": "EEr_Lindenberg_today.png",
|
||||
"22": "EEr_Lueneburg_today.png",
|
||||
"23": "EEr_Melpitz_today.png",
|
||||
"24": "EEr_Muenchen_today.png",
|
||||
"25": "EEr_Norderney_today.png",
|
||||
"26": "EEr_Belm-Osnabrueck_today.png",
|
||||
"27": "EEr_Salzgitter_today.png",
|
||||
"28": "EEr_Sankt Augustin_today.png",
|
||||
"29": "EEr_Schauinsland_today.png",
|
||||
"30": "EEr_Schweinfurt_Wasserlosen_today.png",
|
||||
"31": "EEr_Stuttgart_today.png",
|
||||
"32": "EEr_Sylt_Tinnum_today.png",
|
||||
"33": "EEr_Tholey_today.png",
|
||||
"34": "EEr_Todendorf_today.png",
|
||||
"35": "EEr_Waldhof_Falkenstein_today.png",
|
||||
"36": "EEr_Waldmuenchen_today.png",
|
||||
"37": "EEr_Wasserkuppe bei Fulda_today.png",
|
||||
"38": "EEr_Weissenburg_today.png",
|
||||
"39": "EEr_Wurmberg_B_today.png",
|
||||
"40": "EEr_Zingst_today.png",
|
||||
"41": "EEr_Zirchow_today.png",
|
||||
"42": "EEr_Schneefernerhaus_today.png",
|
||||
}
|
||||
|
||||
var selectList = map[string]string{
|
||||
"Andernach": "0",
|
||||
"Berlin": "1",
|
||||
"Bonn": "2",
|
||||
"Bösel": "3",
|
||||
"Chieming": "4",
|
||||
"Cuxhaven": "5",
|
||||
"Dortmund": "6",
|
||||
"Duderstadt": "7",
|
||||
"Eckernförde": "8",
|
||||
"Fichtelberg": "9",
|
||||
"Friedrichshafen": "10",
|
||||
"Genthin": "11",
|
||||
"Gießen": "12",
|
||||
"Görlitz": "13",
|
||||
"Grömitz": "14",
|
||||
"Hamburg": "15",
|
||||
"Hohenpeißenberg": "16",
|
||||
"Kassel": "17",
|
||||
"Klippeneck": "18",
|
||||
"Kulmbach": "19",
|
||||
"Langen": "20",
|
||||
"Lindenberg": "21",
|
||||
"Lüneburg": "22",
|
||||
"Melpitz": "23",
|
||||
"München": "24",
|
||||
"Norderney": "25",
|
||||
"Osnabrück": "26",
|
||||
"Salzgitter": "27",
|
||||
"Sankt Augustin": "28",
|
||||
"Schauinsland": "29",
|
||||
"Schweinfurt": "30",
|
||||
"Stuttgart": "31",
|
||||
"Sylt (Westerland)": "32",
|
||||
"Tholey": "33",
|
||||
"Todendorf": "34",
|
||||
"Waldhof": "35",
|
||||
"Waldmünchen": "36",
|
||||
"Wasserkuppe": "37",
|
||||
"Weißenburg": "38",
|
||||
"Wurmberg": "39",
|
||||
"Zingst": "40",
|
||||
"Zirchow": "41",
|
||||
"Zugspitze (Schneefernerhaus)": "42",
|
||||
}
|
||||
|
||||
var selectListReverse = reverseMap(selectList)
|
||||
|
||||
func reverseMap[K, V comparable](v map[K]V) map[V]K {
|
||||
m := make(map[V]K)
|
||||
for k, v := range v {
|
||||
m[v] = k
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func sortedMap[K cmp.Ordered, V any](m map[K]V) iter.Seq2[K, V] {
|
||||
a := make([]K, len(m))
|
||||
i := 0
|
||||
for k := range m {
|
||||
a[i] = k
|
||||
i++
|
||||
}
|
||||
slices.Sort(a)
|
||||
return func(yield func(K, V) bool) {
|
||||
for _, k := range a {
|
||||
v := m[k]
|
||||
if !yield(k, v) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue