Adding trend endpoint and example envrc
This commit is contained in:
2
envrc.example
Normal file
2
envrc.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export CLOUD_IMAGE_URL="http://clouds.local:8082/snapshot"
|
||||||
|
example CLOUD_REFRESH_SECONDS=60
|
||||||
227
main.go
227
main.go
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user