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

119
main.go
View File

@ -6,6 +6,8 @@ import (
"io" "io"
"log" "log"
"net/http" "net/http"
"os"
"strconv"
"sync" "sync"
"time" "time"
@ -13,20 +15,19 @@ import (
) )
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)
@ -35,7 +36,7 @@ func estimateCloudCover(url string) (float64, error) {
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)
@ -44,7 +45,6 @@ func estimateCloudCover(url string) (float64, error) {
} }
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)
@ -64,17 +64,11 @@ func estimateCloudCover(url string) (float64, error) {
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.InRange(v, lowerV, upperV, &valMask)
gocv.BitwiseAnd(satMask, valMask, &cloudMask) gocv.BitwiseAnd(satMask, valMask, &cloudMask)
totalPixels := cloudMask.Rows() * cloudMask.Cols() totalPixels := cloudMask.Rows() * cloudMask.Cols()
@ -88,63 +82,68 @@ func estimateCloudCover(url string) (float64, error) {
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}
result := CloudResult{
URL: url,
CloudCover: cover,
LastUpdated: time.Now().Format(time.RFC3339),
}
if err != nil { if err != nil {
result.Error = err.Error() result.Error = err.Error()
} }
cache[url] = result trendLock.Lock()
trendHistory = append(trendHistory, result)
// Keep last 100 entries
if len(trendHistory) > 100 {
trendHistory = trendHistory[len(trendHistory)-100:]
}
trendLock.Unlock()
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()
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()
} }
return def
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) http.HandleFunc("/cloudcover", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok")) 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)
}) })
fmt.Printf("☁️ Cloud cover API running on http://localhost:8080\n") http.HandleFunc("/cloudtrend", func(w http.ResponseWriter, r *http.Request) {
fmt.Printf(" Example: curl 'http://localhost:8080/cloudcover?url=http://clouds.local:8082/snapshot'\n") 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)) log.Fatal(http.ListenAndServe(":8080", nil))
} }