539 lines
10 KiB
Go
539 lines
10 KiB
Go
// Copyright (C) 2025 Marius Schellenberger
|
|
|
|
package main
|
|
|
|
import (
|
|
"cmp"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
version string
|
|
listen string
|
|
)
|
|
|
|
func main() {
|
|
flag.StringVar(&listen, "l", ":7878", "listen addr")
|
|
flag.Parse()
|
|
|
|
if len(cityList) != len(fileList) {
|
|
fatal(errors.New("city and file list length differ"))
|
|
}
|
|
|
|
ic := &ImageCrawler{
|
|
url: "https://uvi.bfs.de/Tagesgrafiken",
|
|
c: &http.Client{},
|
|
m: make(map[int]*Image),
|
|
}
|
|
http.HandleFunc("/set", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "POST" {
|
|
r.ParseForm()
|
|
loc := r.PostForm.Get("loc")
|
|
_, ok := locFromString(loc)
|
|
if !ok {
|
|
http.Redirect(w, r, "/", http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
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: DefaultCity,
|
|
Secure: true,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
}
|
|
http.SetCookie(w, c)
|
|
} else {
|
|
c = cc[0]
|
|
}
|
|
num, ok := locFromString(c.Value)
|
|
if !ok {
|
|
fmt.Fprintf(w, indexPage,
|
|
"000000",
|
|
"Error",
|
|
"Error",
|
|
time.Now().Format(time.RFC1123),
|
|
"Error",
|
|
version,
|
|
)
|
|
return
|
|
}
|
|
img, mod, err := ic.getImage(num)
|
|
mod = mod.Local()
|
|
location := cityList[num]
|
|
if err != nil {
|
|
fmt.Fprintf(w, indexPage,
|
|
"000000",
|
|
"Error",
|
|
mod.Format(time.RFC1123),
|
|
time.Now().Format(time.RFC1123),
|
|
location,
|
|
version,
|
|
)
|
|
return
|
|
}
|
|
vals := getUV(img)
|
|
if len(vals) == 0 {
|
|
fmt.Fprintf(w, indexPage,
|
|
"000000",
|
|
"Error",
|
|
mod.Format(time.RFC1123),
|
|
time.Now().Format(time.RFC1123),
|
|
location,
|
|
version,
|
|
)
|
|
return
|
|
}
|
|
last := vals[len(vals)-1]
|
|
fmt.Fprintf(w, indexPage,
|
|
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)
|
|
}
|
|
}
|
|
|
|
func fatal(err error) {
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
type ImageCrawler struct {
|
|
mu sync.RWMutex
|
|
url string
|
|
c *http.Client
|
|
m map[int]*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 int) (img image.Image, mod time.Time, err error) {
|
|
file := fileList[loc]
|
|
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
|
|
}
|
|
req.Header.Set("user-agent", "uvgo/"+version)
|
|
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")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func between[T cmp.Ordered](v, min, max T) bool {
|
|
return v >= min && v <= max
|
|
}
|
|
|
|
func getColor(v float64) string {
|
|
switch {
|
|
case between(v, 0, 1):
|
|
return "006300"
|
|
case between(v, 1, 2):
|
|
return "00a014"
|
|
case between(v, 2, 3):
|
|
return "81c600"
|
|
case between(v, 3, 4):
|
|
return "fff800"
|
|
case between(v, 4, 5):
|
|
return "ffd100"
|
|
case between(v, 5, 6):
|
|
return "ffa600"
|
|
case between(v, 6, 7):
|
|
return "ff7200"
|
|
case between(v, 7, 8):
|
|
return "ff4a00"
|
|
case between(v, 8, 9):
|
|
return "ff0000"
|
|
case between(v, 9, 10):
|
|
return "ff0078"
|
|
case v >= 10:
|
|
return "a80080"
|
|
}
|
|
return "000000"
|
|
}
|
|
|
|
func colorIs(c color.RGBA, v uint8) bool {
|
|
return c.R == v && c.G == v && c.B == v
|
|
}
|
|
|
|
func isWhite(col color.Color) bool {
|
|
col = color.RGBAModel.Convert(col)
|
|
c, ok := col.(color.RGBA)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return colorIs(c, 255)
|
|
}
|
|
|
|
func isBlack(col color.Color) bool {
|
|
col = color.RGBAModel.Convert(col)
|
|
c, ok := col.(color.RGBA)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return colorIs(c, 0)
|
|
}
|
|
|
|
func isStop(col color.Color) bool {
|
|
col = color.RGBAModel.Convert(col)
|
|
c, ok := col.(color.RGBA)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if colorIs(c, 241) || colorIs(c, 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 colorIs(c, 241) || colorIs(c, 214) || colorIs(c, 0) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DefaultCity is Muenchen
|
|
// Array index 24
|
|
const DefaultCity = "24"
|
|
|
|
var fileList = []string{
|
|
"EEr_Andernach_today.png",
|
|
"EEr_Berlin_Potsdamer_Platz_today.png",
|
|
"EEr_Bonn_today.png",
|
|
"EEr_Boesel_B_today.png",
|
|
"EEr_Chieming_today.png",
|
|
"EEr_Cuxhaven_B_today.png",
|
|
"EEr_Dortmund_today.png",
|
|
"EEr_Duderstadt_B_today.png",
|
|
"EEr_Eckernfoerde_today.png",
|
|
"EEr_Fichtelberg_today.png",
|
|
"EEr_Friedrichshafen_today.png",
|
|
"EEr_Genthin_today.png",
|
|
"EEr_Giessen-Wettenberg_today.png",
|
|
"EEr_Goerlitz_today.png",
|
|
"EEr_Groemitz_today.png",
|
|
"EEr_Hamburg_today.png",
|
|
"EEr_Hohenpeissenberg_today.png",
|
|
"EEr_Kassel_today.png",
|
|
"EEr_Klippeneck_today.png",
|
|
"EEr_Kulmbach_today.png",
|
|
"EEr_Langen_today.png",
|
|
"EEr_Lindenberg_today.png",
|
|
"EEr_Lueneburg_today.png",
|
|
"EEr_Melpitz_today.png",
|
|
"EEr_Muenchen_today.png",
|
|
"EEr_Norderney_today.png",
|
|
"EEr_Belm-Osnabrueck_today.png",
|
|
"EEr_Salzgitter_today.png",
|
|
"EEr_Sankt Augustin_today.png",
|
|
"EEr_Schauinsland_today.png",
|
|
"EEr_Schweinfurt_Wasserlosen_today.png",
|
|
"EEr_Stuttgart_today.png",
|
|
"EEr_Sylt_Tinnum_today.png",
|
|
"EEr_Tholey_today.png",
|
|
"EEr_Todendorf_today.png",
|
|
"EEr_Waldhof_Falkenstein_today.png",
|
|
"EEr_Waldmuenchen_today.png",
|
|
"EEr_Wasserkuppe bei Fulda_today.png",
|
|
"EEr_Weissenburg_today.png",
|
|
"EEr_Wurmberg_B_today.png",
|
|
"EEr_Zingst_today.png",
|
|
"EEr_Zirchow_today.png",
|
|
"EEr_Schneefernerhaus_today.png",
|
|
}
|
|
|
|
func locFromString(s string) (i int, ok bool) {
|
|
i, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return
|
|
}
|
|
return i, validLoc(i)
|
|
}
|
|
|
|
func validLoc(loc int) bool {
|
|
return loc >= 0 && loc < len(cityList)
|
|
}
|
|
|
|
var cityList = []string{
|
|
"Andernach",
|
|
"Berlin",
|
|
"Bonn",
|
|
"Bösel",
|
|
"Chieming",
|
|
"Cuxhaven",
|
|
"Dortmund",
|
|
"Duderstadt",
|
|
"Eckernförde",
|
|
"Fichtelberg",
|
|
"Friedrichshafen",
|
|
"Genthin",
|
|
"Gießen",
|
|
"Görlitz",
|
|
"Grömitz",
|
|
"Hamburg",
|
|
"Hohenpeißenberg",
|
|
"Kassel",
|
|
"Klippeneck",
|
|
"Kulmbach",
|
|
"Langen",
|
|
"Lindenberg",
|
|
"Lüneburg",
|
|
"Melpitz",
|
|
"München",
|
|
"Norderney",
|
|
"Osnabrück",
|
|
"Salzgitter",
|
|
"Sankt Augustin",
|
|
"Schauinsland",
|
|
"Schweinfurt",
|
|
"Stuttgart",
|
|
"Sylt (Westerland)",
|
|
"Tholey",
|
|
"Todendorf",
|
|
"Waldhof",
|
|
"Waldmünchen",
|
|
"Wasserkuppe",
|
|
"Weißenburg",
|
|
"Wurmberg",
|
|
"Zingst",
|
|
"Zirchow",
|
|
"Zugspitze (Schneefernerhaus)",
|
|
}
|
|
|
|
func generateLocPage() string {
|
|
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 i, v := range cityList {
|
|
loc += `<option value="` + strconv.Itoa(i) + `">` + v + `</option>`
|
|
}
|
|
loc += `</select><br><input type="submit" style="font-size:5em;" value="Change"></input></form></center></body></html>`
|
|
return loc
|
|
}
|
|
|
|
var locPage = generateLocPage()
|
|
|
|
var indexPage = `<!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:3.5em;">UV Index</h5>
|
|
<h1 style="font-size:15em;color:#%s;">%s</h1>
|
|
<h5 style="font-size:2em;">Updated: %s</h5>
|
|
<h5 style="font-size:2em;">Server: %s</h5>
|
|
<h5 style="font-size:2em;"><a href="/set">Location</a>: %s</h5>
|
|
<footer>UV data copyright belongs to <a href="https://www.bfs.de/DE/themen/opt/uv/uv-index/aktuelle-tagesverlaeufe/aktuell_node.html" rel="nofollow noreferrer noopener" target="_blank">Bundesamt für Strahlenschutz</a><br>
|
|
Use this service at your own risk.<br>
|
|
I take no responsibility for missing or incorrect data.<br>
|
|
© <a href="https://git.giftfish.de/ston1th/uvgo" rel="nofollow noreferrer noopener" target="_blank">uvgo</a> v%s<br><br>
|
|
</footer>
|
|
</center>
|
|
</body>
|
|
</html>`
|