From 59326ecf7e589b6677a946bb1789d5f11c59e903 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Fri, 24 Oct 2025 10:47:18 -0400 Subject: [PATCH] Adding trend endpoint and example envrc --- envrc.example | 2 + main.go | 227 +++++++++++++++++++++++++------------------------- 2 files changed, 115 insertions(+), 114 deletions(-) create mode 100644 envrc.example diff --git a/envrc.example b/envrc.example new file mode 100644 index 0000000..f27287b --- /dev/null +++ b/envrc.example @@ -0,0 +1,2 @@ +export CLOUD_IMAGE_URL="http://clouds.local:8082/snapshot" +example CLOUD_REFRESH_SECONDS=60 diff --git a/main.go b/main.go index 61b7d0b..a69940f 100644 --- a/main.go +++ b/main.go @@ -1,150 +1,149 @@ package main import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "sync" - "time" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "sync" + "time" - "gocv.io/x/gocv" + "gocv.io/x/gocv" ) type CloudResult struct { - URL string `json:"url"` - CloudCover float64 `json:"cloud_cover"` - LastUpdated string `json:"last_updated"` - Error string `json:"error,omitempty"` + Timestamp time.Time `json:"timestamp"` + CloudCover float64 `json:"cloud_cover"` + Error string `json:"error,omitempty"` } var ( - mu sync.Mutex - cache = make(map[string]CloudResult) - refreshFreq = 30 * time.Second // refresh interval + imageURL = getEnv("CLOUD_IMAGE_URL", "http://clouds.local:8082/snapshot") + refreshSecs = getEnvInt("CLOUD_REFRESH_SECONDS", 30) + trendHistory []CloudResult + trendLock sync.Mutex ) func estimateCloudCover(url string) (float64, error) { - // Fetch image - resp, err := http.Get(url) - if err != nil { - return 0, fmt.Errorf("failed to fetch image: %v", err) - } - defer resp.Body.Close() + 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 data: %v", err) - } + 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() + img, err := gocv.IMDecode(data, gocv.IMReadColor) + if err != nil { + return 0, fmt.Errorf("failed to decode image: %v", err) + } + defer img.Close() - // Convert to HSV - hsv := gocv.NewMat() - defer hsv.Close() - gocv.CvtColor(img, &hsv, gocv.ColorBGRToHSV) + 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() - } - }() + 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() + satMask := gocv.NewMat() + valMask := gocv.NewMat() + cloudMask := gocv.NewMat() + defer satMask.Close() + defer valMask.Close() + defer cloudMask.Close() - lowerS := gocv.NewMatFromScalar(gocv.NewScalar(0, 0, 0, 0), gocv.MatTypeCV8U) - upperS := gocv.NewMatFromScalar(gocv.NewScalar(60, 0, 0, 0), gocv.MatTypeCV8U) - lowerV := gocv.NewMatFromScalar(gocv.NewScalar(120, 0, 0, 0), gocv.MatTypeCV8U) - upperV := gocv.NewMatFromScalar(gocv.NewScalar(255, 0, 0, 0), gocv.MatTypeCV8U) - defer lowerS.Close() - defer upperS.Close() - defer lowerV.Close() - defer upperV.Close() + // s < 60 (low saturation) + gocv.InRangeWithScalar(s, gocv.NewScalar(0, 0, 0, 0), gocv.NewScalar(60, 0, 0, 0), &satMask) + // v > 120 (bright) + gocv.InRangeWithScalar(v, gocv.NewScalar(120, 0, 0, 0), gocv.NewScalar(255, 0, 0, 0), &valMask) - gocv.InRange(s, lowerS, upperS, &satMask) - gocv.InRange(v, lowerV, upperV, &valMask) - gocv.BitwiseAnd(satMask, valMask, &cloudMask) + gocv.BitwiseAnd(satMask, valMask, &cloudMask) - totalPixels := cloudMask.Rows() * cloudMask.Cols() - if totalPixels == 0 { - return 0, fmt.Errorf("no pixels found in image") - } + 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) + cloudPixels := gocv.CountNonZero(cloudMask) + cloudCover := 100 * float64(cloudPixels) / float64(totalPixels) - return cloudCover, nil + return cloudCover, nil } -func updateCache(url string) { - cover, err := estimateCloudCover(url) - mu.Lock() - defer mu.Unlock() +func backgroundCollector() { + for { + cover, err := estimateCloudCover(imageURL) + result := CloudResult{Timestamp: time.Now(), CloudCover: cover} + if err != nil { + result.Error = err.Error() + } - result := CloudResult{ - URL: url, - CloudCover: cover, - LastUpdated: time.Now().Format(time.RFC3339), - } - if err != nil { - result.Error = err.Error() - } + trendLock.Lock() + trendHistory = append(trendHistory, result) + // Keep last 100 entries + if len(trendHistory) > 100 { + trendHistory = trendHistory[len(trendHistory)-100:] + } + 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) { - go func() { - for { - updateCache(url) - time.Sleep(refreshFreq) - } - }() +func getEnv(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def } -func handleCloudCover(w http.ResponseWriter, r *http.Request) { - url := r.URL.Query().Get("url") - if url == "" { - url = "http://clouds.service:8081/snapshot" - } - - mu.Lock() - 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 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 } func main() { - http.HandleFunc("/cloudcover", handleCloudCover) - http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) - }) + go backgroundCollector() - fmt.Printf("☁️ Cloud cover API running on http://localhost:8080\n") - fmt.Printf(" Example: curl 'http://localhost:8080/cloudcover?url=http://clouds.local:8082/snapshot'\n") - log.Fatal(http.ListenAndServe(":8080", nil)) + http.HandleFunc("/cloudcover", func(w http.ResponseWriter, r *http.Request) { + trendLock.Lock() + 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)) }