// 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 tmp = ` UV Index
UV Index

%s

Updated: %s
Server: %s
Location: %s
` 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 string version string listen string ) 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, version, ) return } vals := getUV(img) if len(vals) == 0 { fmt.Fprintf(w, tmp, "000000", "Err", mod.Format(time.RFC1123), time.Now().Format(time.RFC1123), location, version, ) 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, version, ) }) 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 := ` UV Index Change Location
Change Location

` 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 } } } }