358 lines
9.0 KiB
Go
358 lines
9.0 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
"github.com/nathan-osman/go-sunrise"
|
|
|
|
_ "modernc.org/sqlite"
|
|
"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"`
|
|
}
|
|
|
|
type Summary struct {
|
|
From time.Time `json:"from"`
|
|
To time.Time `json:"to"`
|
|
Avg float64 `json:"avg"`
|
|
Min float64 `json:"min"`
|
|
Max float64 `json:"max"`
|
|
SampleCount int `json:"sample_count"`
|
|
}
|
|
|
|
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 isNight(lat, lon float64) bool {
|
|
now := time.Now()
|
|
sunriseTime, sunsetTime := sunrise.SunriseSunset(lat, lon, now.Year(), now.Month(), now.Day())
|
|
return now.Before(sunriseTime) || now.After(sunsetTime)
|
|
}
|
|
|
|
func main() {
|
|
initDB()
|
|
go backgroundCollector()
|
|
|
|
http.HandleFunc("/", dashboardHandler)
|
|
http.HandleFunc("/cloudcover", handleLatest)
|
|
http.HandleFunc("/cloudtrend", handleTrend)
|
|
http.HandleFunc("/cloudsummary", handleSummary)
|
|
|
|
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() {
|
|
lat, lon := 44.3897, -68.8040 // your location (New York example)
|
|
for {
|
|
cover, err := estimateCloudCover(imageURL)
|
|
result := CloudResult{Timestamp: time.Now(), CloudCover: cover}
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
}
|
|
|
|
|
|
//lat, lon := 40.7128, -74.0060 // your location (New York example)
|
|
if !isNight(lat, lon) {
|
|
storeResult(result)
|
|
fmt.Printf("[%s] Cloud cover: %.2f%%\n", result.Timestamp.Format("15:04:05"), result.CloudCover)
|
|
} else {
|
|
fmt.Println("🌙 It's night — skipping cloud capture")
|
|
}
|
|
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 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 getSummary() (*Summary, error) {
|
|
since := time.Now().Add(-24 * time.Hour)
|
|
rows := db.QueryRow(`
|
|
SELECT
|
|
AVG(cloud_cover),
|
|
MIN(cloud_cover),
|
|
MAX(cloud_cover),
|
|
COUNT(*)
|
|
FROM cloud_trend
|
|
WHERE timestamp > ?
|
|
`, since.Format(time.RFC3339))
|
|
|
|
var s Summary
|
|
var avg, min, max sql.NullFloat64
|
|
var count int
|
|
if err := rows.Scan(&avg, &min, &max, &count); err != nil {
|
|
return nil, err
|
|
}
|
|
s.From = since
|
|
s.To = time.Now()
|
|
s.SampleCount = count
|
|
if avg.Valid {
|
|
s.Avg = avg.Float64
|
|
s.Min = min.Float64
|
|
s.Max = max.Float64
|
|
}
|
|
return &s, 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 handleSummary(w http.ResponseWriter, r *http.Request) {
|
|
summary, err := getSummary()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(summary)
|
|
}
|
|
|
|
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; }
|
|
.summary { margin: 1em auto; width: 80%%; background: #222; border-radius: 10px; 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>
|
|
|
|
<div class="summary" id="summary">
|
|
<h3>Loading summary...</h3>
|
|
</div>
|
|
|
|
<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 fetchTrend() {
|
|
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();
|
|
}
|
|
|
|
async function fetchSummary() {
|
|
const res = await fetch("/cloudsummary");
|
|
const s = await res.json();
|
|
document.getElementById("summary").innerHTML =
|
|
"<h3>Last 24 hours</h3>" +
|
|
"<p>Average: " + s.avg.toFixed(1) + "% | " +
|
|
"Min: " + s.min.toFixed(1) + "% | " +
|
|
"Max: " + s.max.toFixed(1) + "% | " +
|
|
"Samples: " + s.sample_count + "</p>";
|
|
}
|
|
|
|
fetchTrend();
|
|
fetchSummary();
|
|
setInterval(fetchTrend, 10000);
|
|
setInterval(fetchSummary, 60000);
|
|
</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
|
|
}
|