Files
cloudcover/main.go

277 lines
6.8 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"sync"
"time"
_ "modernc.org/sqlite" // lightweight pure-Go SQLite driver
"gocv.io/x/gocv"
)
type CloudResult struct {
ID int `json:"id"`
Timestamp time.Time `json:"timestamp"`
CloudCover float64 `json:"cloud_cover"`
Error string `json:"error,omitempty"`
}
var (
imageURL = getEnv("CLOUD_IMAGE_URL", "http://clouds.local:8082/snapshot")
refreshSecs = getEnvInt("CLOUD_REFRESH_SECONDS", 30)
dbPath = getEnv("CLOUD_DB_PATH", "clouds.db")
db *sql.DB
lock sync.Mutex
)
func main() {
initDB()
go backgroundCollector()
http.HandleFunc("/", dashboardHandler)
http.HandleFunc("/cloudcover", handleLatest)
http.HandleFunc("/cloudtrend", handleTrend)
fmt.Printf("☁️ Cloud cover API started at http://localhost:8080\n")
fmt.Printf("🔁 Refresh every %ds | Source: %s | DB: %s\n", refreshSecs, imageURL, dbPath)
log.Fatal(http.ListenAndServe(":8080", nil))
}
func estimateCloudCover(url string) (float64, error) {
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: %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()
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()
}
}()
satMask := gocv.NewMat()
valMask := gocv.NewMat()
cloudMask := gocv.NewMat()
defer satMask.Close()
defer valMask.Close()
defer cloudMask.Close()
gocv.InRangeWithScalar(s, gocv.NewScalar(0, 0, 0, 0), gocv.NewScalar(60, 0, 0, 0), &satMask)
gocv.InRangeWithScalar(v, gocv.NewScalar(120, 0, 0, 0), gocv.NewScalar(255, 0, 0, 0), &valMask)
gocv.BitwiseAnd(satMask, valMask, &cloudMask)
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)
return cloudCover, nil
}
func backgroundCollector() {
for {
cover, err := estimateCloudCover(imageURL)
result := CloudResult{Timestamp: time.Now(), CloudCover: cover}
if err != nil {
result.Error = err.Error()
}
storeResult(result)
fmt.Printf("[%s] Cloud cover: %.2f%%\n", result.Timestamp.Format("15:04:05"), result.CloudCover)
time.Sleep(time.Duration(refreshSecs) * time.Second)
}
}
func initDB() {
var err error
db, err = sql.Open("sqlite", dbPath)
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS cloud_trend (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATETIME,
cloud_cover REAL,
error TEXT
)
`)
if err != nil {
log.Fatalf("failed to create table: %v", err)
}
}
func storeResult(r CloudResult) {
lock.Lock()
defer lock.Unlock()
_, err := db.Exec(`INSERT INTO cloud_trend (timestamp, cloud_cover, error) VALUES (?, ?, ?)`,
r.Timestamp.Format(time.RFC3339), r.CloudCover, r.Error)
if err != nil {
log.Printf("failed to insert record: %v", err)
}
}
func getLatest() (*CloudResult, error) {
row := db.QueryRow(`SELECT id, timestamp, cloud_cover, error FROM cloud_trend ORDER BY id DESC LIMIT 1`)
var r CloudResult
var ts string
if err := row.Scan(&r.ID, &ts, &r.CloudCover, &r.Error); err != nil {
return nil, err
}
r.Timestamp, _ = time.Parse(time.RFC3339, ts)
return &r, nil
}
func getAll(limit int) ([]CloudResult, error) {
rows, err := db.Query(`SELECT id, timestamp, cloud_cover, error FROM cloud_trend ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []CloudResult
for rows.Next() {
var r CloudResult
var ts string
if err := rows.Scan(&r.ID, &ts, &r.CloudCover, &r.Error); err != nil {
return nil, err
}
r.Timestamp, _ = time.Parse(time.RFC3339, ts)
results = append(results, r)
}
// Reverse to chronological order
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
}
return results, nil
}
func handleLatest(w http.ResponseWriter, r *http.Request) {
result, err := getLatest()
if err != nil {
http.Error(w, "no data yet", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
func handleTrend(w http.ResponseWriter, r *http.Request) {
results, err := getAll(200)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
html := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>☁️ Cloud Cover Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body { font-family: sans-serif; background: #111; color: #eee; text-align: center; margin: 0; padding: 1em; }
canvas { background: #222; border-radius: 10px; margin-top: 20px; max-width: 90vw; }
</style>
</head>
<body>
<h1>☁️ Cloud Cover Trend</h1>
<p>Source: <code>` + imageURL + `</code></p>
<canvas id="chart" width="800" height="400"></canvas>
<script>
const ctx = document.getElementById('chart');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Cloud Cover (%)',
data: [],
borderColor: '#4fc3f7',
backgroundColor: 'rgba(79,195,247,0.2)',
fill: true,
tension: 0.3
}]
},
options: {
scales: {
x: { title: { display: true, text: 'Time' }, ticks: { color: '#aaa' } },
y: { beginAtZero: true, title: { display: true, text: 'Cloud %' }, ticks: { color: '#aaa' } }
},
plugins: {
legend: { labels: { color: '#ccc' } }
}
}
});
async function fetchData() {
const res = await fetch('/cloudtrend');
const data = await res.json();
chart.data.labels = data.map(d => new Date(d.timestamp).toLocaleTimeString());
chart.data.datasets[0].data = data.map(d => d.cloud_cover);
chart.update();
}
fetchData();
setInterval(fetchData, 10000);
</script>
</body>
</html>
`
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}
func getEnv(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
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
}