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