package main import ( "database/sql" "encoding/json" "fmt" "io" "log" "net/http" "os" "strconv" "sync" "time" "github.com/nathan-osman/go-sunrise" _ "modernc.org/sqlite" "gocv.io/x/gocv" ) type CloudResult struct { ID int `json:"id"` Timestamp time.Time `json:"timestamp"` CloudCover float64 `json:"cloud_cover"` Error string `json:"error,omitempty"` } type Summary struct { From time.Time `json:"from"` To time.Time `json:"to"` Avg float64 `json:"avg"` Min float64 `json:"min"` Max float64 `json:"max"` SampleCount int `json:"sample_count"` } var ( imageURL = getEnv("CLOUD_IMAGE_URL", "http://clouds.local:8082/snapshot") refreshSecs = getEnvInt("CLOUD_REFRESH_SECONDS", 30) dbPath = getEnv("CLOUD_DB_PATH", "clouds.db") db *sql.DB lock sync.Mutex ) func isNight(lat, lon float64) bool { now := time.Now() sunriseTime, sunsetTime := sunrise.SunriseSunset(lat, lon, now.Year(), now.Month(), now.Day()) return now.Before(sunriseTime) || now.After(sunsetTime) } func main() { initDB() go backgroundCollector() http.HandleFunc("/", dashboardHandler) http.HandleFunc("/cloudcover", handleLatest) http.HandleFunc("/cloudtrend", handleTrend) http.HandleFunc("/cloudsummary", handleSummary) fmt.Printf("☁️ Cloud cover API started at http://localhost:8080\n") fmt.Printf("🔁 Refresh every %ds | Source: %s | DB: %s\n", refreshSecs, imageURL, dbPath) log.Fatal(http.ListenAndServe(":8080", nil)) } func estimateCloudCover(url string) (float64, error) { resp, err := http.Get(url) if err != nil { return 0, fmt.Errorf("failed to fetch image: %v", err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return 0, fmt.Errorf("failed to read image: %v", err) } img, err := gocv.IMDecode(data, gocv.IMReadColor) if err != nil { return 0, fmt.Errorf("failed to decode image: %v", err) } defer img.Close() hsv := gocv.NewMat() defer hsv.Close() gocv.CvtColor(img, &hsv, gocv.ColorBGRToHSV) channels := gocv.Split(hsv) s, v := channels[1], channels[2] defer func() { for _, ch := range channels { ch.Close() } }() satMask := gocv.NewMat() valMask := gocv.NewMat() cloudMask := gocv.NewMat() defer satMask.Close() defer valMask.Close() defer cloudMask.Close() gocv.InRangeWithScalar(s, gocv.NewScalar(0, 0, 0, 0), gocv.NewScalar(60, 0, 0, 0), &satMask) gocv.InRangeWithScalar(v, gocv.NewScalar(120, 0, 0, 0), gocv.NewScalar(255, 0, 0, 0), &valMask) gocv.BitwiseAnd(satMask, valMask, &cloudMask) totalPixels := cloudMask.Rows() * cloudMask.Cols() if totalPixels == 0 { return 0, fmt.Errorf("no pixels found in image") } cloudPixels := gocv.CountNonZero(cloudMask) cloudCover := 100 * float64(cloudPixels) / float64(totalPixels) return cloudCover, nil } func backgroundCollector() { lat, lon := 44.3897, -68.8040 // your location (New York example) for { cover, err := estimateCloudCover(imageURL) result := CloudResult{Timestamp: time.Now(), CloudCover: cover} if err != nil { result.Error = err.Error() } //lat, lon := 40.7128, -74.0060 // your location (New York example) if !isNight(lat, lon) { storeResult(result) fmt.Printf("[%s] Cloud cover: %.2f%%\n", result.Timestamp.Format("15:04:05"), result.CloudCover) } else { fmt.Println("🌙 It's night — skipping cloud capture") } time.Sleep(time.Duration(refreshSecs) * time.Second) } } func initDB() { var err error db, err = sql.Open("sqlite", dbPath) if err != nil { log.Fatalf("failed to open database: %v", err) } _, err = db.Exec(` CREATE TABLE IF NOT EXISTS cloud_trend ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, cloud_cover REAL, error TEXT ) `) if err != nil { log.Fatalf("failed to create table: %v", err) } } func storeResult(r CloudResult) { lock.Lock() defer lock.Unlock() _, err := db.Exec(`INSERT INTO cloud_trend (timestamp, cloud_cover, error) VALUES (?, ?, ?)`, r.Timestamp.Format(time.RFC3339), r.CloudCover, r.Error) if err != nil { log.Printf("failed to insert record: %v", err) } } func getLatest() (*CloudResult, error) { row := db.QueryRow(`SELECT id, timestamp, cloud_cover, error FROM cloud_trend ORDER BY id DESC LIMIT 1`) var r CloudResult var ts string if err := row.Scan(&r.ID, &ts, &r.CloudCover, &r.Error); err != nil { return nil, err } r.Timestamp, _ = time.Parse(time.RFC3339, ts) return &r, nil } func getAll(limit int) ([]CloudResult, error) { rows, err := db.Query(`SELECT id, timestamp, cloud_cover, error FROM cloud_trend ORDER BY id DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var results []CloudResult for rows.Next() { var r CloudResult var ts string if err := rows.Scan(&r.ID, &ts, &r.CloudCover, &r.Error); err != nil { return nil, err } r.Timestamp, _ = time.Parse(time.RFC3339, ts) results = append(results, r) } // reverse chronological order for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 { results[i], results[j] = results[j], results[i] } return results, nil } func getSummary() (*Summary, error) { since := time.Now().Add(-24 * time.Hour) rows := db.QueryRow(` SELECT AVG(cloud_cover), MIN(cloud_cover), MAX(cloud_cover), COUNT(*) FROM cloud_trend WHERE timestamp > ? `, since.Format(time.RFC3339)) var s Summary var avg, min, max sql.NullFloat64 var count int if err := rows.Scan(&avg, &min, &max, &count); err != nil { return nil, err } s.From = since s.To = time.Now() s.SampleCount = count if avg.Valid { s.Avg = avg.Float64 s.Min = min.Float64 s.Max = max.Float64 } return &s, nil } func handleLatest(w http.ResponseWriter, r *http.Request) { result, err := getLatest() if err != nil { http.Error(w, "no data yet", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) } func handleTrend(w http.ResponseWriter, r *http.Request) { results, err := getAll(200) if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(results) } func handleSummary(w http.ResponseWriter, r *http.Request) { summary, err := getSummary() if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(summary) } func dashboardHandler(w http.ResponseWriter, r *http.Request) { html := ` ☁️ Cloud Cover Dashboard

☁️ Cloud Cover Trend

Source: ` + imageURL + `

Loading summary...

` w.Header().Set("Content-Type", "text/html") w.Write([]byte(html)) } func getEnv(key, def string) string { if v, ok := os.LookupEnv(key); ok { return v } return def } func getEnvInt(key string, def int) int { if v, ok := os.LookupEnv(key); ok { if i, err := strconv.Atoi(v); err == nil { return i } } return def }