Adding trend endpoint and example envrc

This commit is contained in:
2025-10-24 10:47:18 -04:00
parent 3202d5f54e
commit 59326ecf7e
2 changed files with 115 additions and 114 deletions

2
envrc.example Normal file
View File

@ -0,0 +1,2 @@
export CLOUD_IMAGE_URL="http://clouds.local:8082/snapshot"
example CLOUD_REFRESH_SECONDS=60

227
main.go
View File

@ -1,150 +1,149 @@
package main package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"sync" "os"
"time" "strconv"
"sync"
"time"
"gocv.io/x/gocv" "gocv.io/x/gocv"
) )
type CloudResult struct { type CloudResult struct {
URL string `json:"url"` Timestamp time.Time `json:"timestamp"`
CloudCover float64 `json:"cloud_cover"` CloudCover float64 `json:"cloud_cover"`
LastUpdated string `json:"last_updated"` Error string `json:"error,omitempty"`
Error string `json:"error,omitempty"`
} }
var ( var (
mu sync.Mutex imageURL = getEnv("CLOUD_IMAGE_URL", "http://clouds.local:8082/snapshot")
cache = make(map[string]CloudResult) refreshSecs = getEnvInt("CLOUD_REFRESH_SECONDS", 30)
refreshFreq = 30 * time.Second // refresh interval trendHistory []CloudResult
trendLock sync.Mutex
) )
func estimateCloudCover(url string) (float64, error) { func estimateCloudCover(url string) (float64, error) {
// Fetch image resp, err := http.Get(url)
resp, err := http.Get(url) if err != nil {
if err != nil { return 0, fmt.Errorf("failed to fetch image: %v", err)
return 0, fmt.Errorf("failed to fetch image: %v", err) }
} defer resp.Body.Close()
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body) data, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to read image data: %v", err) return 0, fmt.Errorf("failed to read image: %v", err)
} }
img, err := gocv.IMDecode(data, gocv.IMReadColor) img, err := gocv.IMDecode(data, gocv.IMReadColor)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to decode image: %v", err) return 0, fmt.Errorf("failed to decode image: %v", err)
} }
defer img.Close() defer img.Close()
// Convert to HSV hsv := gocv.NewMat()
hsv := gocv.NewMat() defer hsv.Close()
defer hsv.Close() gocv.CvtColor(img, &hsv, gocv.ColorBGRToHSV)
gocv.CvtColor(img, &hsv, gocv.ColorBGRToHSV)
channels := gocv.Split(hsv) channels := gocv.Split(hsv)
s, v := channels[1], channels[2] s, v := channels[1], channels[2]
defer func() { defer func() {
for _, ch := range channels { for _, ch := range channels {
ch.Close() ch.Close()
} }
}() }()
satMask := gocv.NewMat() satMask := gocv.NewMat()
valMask := gocv.NewMat() valMask := gocv.NewMat()
cloudMask := gocv.NewMat() cloudMask := gocv.NewMat()
defer satMask.Close() defer satMask.Close()
defer valMask.Close() defer valMask.Close()
defer cloudMask.Close() defer cloudMask.Close()
lowerS := gocv.NewMatFromScalar(gocv.NewScalar(0, 0, 0, 0), gocv.MatTypeCV8U) // s < 60 (low saturation)
upperS := gocv.NewMatFromScalar(gocv.NewScalar(60, 0, 0, 0), gocv.MatTypeCV8U) gocv.InRangeWithScalar(s, gocv.NewScalar(0, 0, 0, 0), gocv.NewScalar(60, 0, 0, 0), &satMask)
lowerV := gocv.NewMatFromScalar(gocv.NewScalar(120, 0, 0, 0), gocv.MatTypeCV8U) // v > 120 (bright)
upperV := gocv.NewMatFromScalar(gocv.NewScalar(255, 0, 0, 0), gocv.MatTypeCV8U) gocv.InRangeWithScalar(v, gocv.NewScalar(120, 0, 0, 0), gocv.NewScalar(255, 0, 0, 0), &valMask)
defer lowerS.Close()
defer upperS.Close()
defer lowerV.Close()
defer upperV.Close()
gocv.InRange(s, lowerS, upperS, &satMask) gocv.BitwiseAnd(satMask, valMask, &cloudMask)
gocv.InRange(v, lowerV, upperV, &valMask)
gocv.BitwiseAnd(satMask, valMask, &cloudMask)
totalPixels := cloudMask.Rows() * cloudMask.Cols() totalPixels := cloudMask.Rows() * cloudMask.Cols()
if totalPixels == 0 { if totalPixels == 0 {
return 0, fmt.Errorf("no pixels found in image") return 0, fmt.Errorf("no pixels found in image")
} }
cloudPixels := gocv.CountNonZero(cloudMask) cloudPixels := gocv.CountNonZero(cloudMask)
cloudCover := 100 * float64(cloudPixels) / float64(totalPixels) cloudCover := 100 * float64(cloudPixels) / float64(totalPixels)
return cloudCover, nil return cloudCover, nil
} }
func updateCache(url string) { func backgroundCollector() {
cover, err := estimateCloudCover(url) for {
mu.Lock() cover, err := estimateCloudCover(imageURL)
defer mu.Unlock() result := CloudResult{Timestamp: time.Now(), CloudCover: cover}
if err != nil {
result.Error = err.Error()
}
result := CloudResult{ trendLock.Lock()
URL: url, trendHistory = append(trendHistory, result)
CloudCover: cover, // Keep last 100 entries
LastUpdated: time.Now().Format(time.RFC3339), if len(trendHistory) > 100 {
} trendHistory = trendHistory[len(trendHistory)-100:]
if err != nil { }
result.Error = err.Error() trendLock.Unlock()
}
cache[url] = result fmt.Printf("[%s] Cloud cover: %.2f%%\n", result.Timestamp.Format("15:04:05"), result.CloudCover)
time.Sleep(time.Duration(refreshSecs) * time.Second)
}
} }
func startAutoRefresh(url string) { func getEnv(key, def string) string {
go func() { if v, ok := os.LookupEnv(key); ok {
for { return v
updateCache(url) }
time.Sleep(refreshFreq) return def
}
}()
} }
func handleCloudCover(w http.ResponseWriter, r *http.Request) { func getEnvInt(key string, def int) int {
url := r.URL.Query().Get("url") if v, ok := os.LookupEnv(key); ok {
if url == "" { if i, err := strconv.Atoi(v); err == nil {
url = "http://clouds.service:8081/snapshot" return i
} }
}
mu.Lock() return def
result, ok := cache[url]
mu.Unlock()
if !ok {
// first-time fetch triggers background updates
updateCache(url)
startAutoRefresh(url)
mu.Lock()
result = cache[url]
mu.Unlock()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
} }
func main() { func main() {
http.HandleFunc("/cloudcover", handleCloudCover) go backgroundCollector()
http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
fmt.Printf("☁️ Cloud cover API running on http://localhost:8080\n") http.HandleFunc("/cloudcover", func(w http.ResponseWriter, r *http.Request) {
fmt.Printf(" Example: curl 'http://localhost:8080/cloudcover?url=http://clouds.local:8082/snapshot'\n") trendLock.Lock()
log.Fatal(http.ListenAndServe(":8080", nil)) defer trendLock.Unlock()
if len(trendHistory) == 0 {
http.Error(w, "No data collected yet", http.StatusServiceUnavailable)
return
}
latest := trendHistory[len(trendHistory)-1]
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(latest)
})
http.HandleFunc("/cloudtrend", func(w http.ResponseWriter, r *http.Request) {
trendLock.Lock()
defer trendLock.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(trendHistory)
})
fmt.Printf("☁️ Cloud cover API started at http://localhost:8080\n")
fmt.Printf("🔁 Refresh interval: %ds | Source: %s\n", refreshSecs, imageURL)
log.Fatal(http.ListenAndServe(":8080", nil))
} }