From 109516c1853935f1ac060d3b1a5df748162331b2 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Fri, 24 Oct 2025 11:05:03 -0400 Subject: [PATCH] Add build and make scripts --- .gitignore | 3 + Makefile | 38 +++++++++++++ envrc.example | 1 + go.mod | 14 +++++ go.sum | 23 ++++++++ main.go | 151 ++++++++++++++++++++++++++++++++++++-------------- 6 files changed, 187 insertions(+), 43 deletions(-) create mode 100644 .gitignore create mode 100644 Makefile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20ea3b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.db +.envrc +build diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8d4c18b --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +# Makefile + +# Project name / binary name +BINARY_NAME := cloudcover + +# Default target +all: build + +# Run go app from CLI +run: + go run . + +install-linux: + sudo apt install libopencv-dev + +install-macos: + brew install opencv + +# Build for the host system +build: + go build -o build/$(BINARY_NAME) . + +# Cross-compile for macOS ARM64 +build-macos-arm64: + GOOS=darwin GOARCH=arm64 go build -o build/$(BINARY_NAME)-macos-arm64 . + +# Cross-compile for Linux AMD64 +build-linux-amd64: + GOOS=linux GOARCH=amd64 go build -o build/$(BINARY_NAME)-linux-amd64 . + +# Build all targets +build-all: build-macos-arm64 build-linux-amd64 + +# Clean build artifacts +clean: + rm -rf build + +.PHONY: all build build-macos-arm64 build-linux-amd64 build-all clean diff --git a/envrc.example b/envrc.example index f27287b..7dbdea1 100644 --- a/envrc.example +++ b/envrc.example @@ -1,2 +1,3 @@ export CLOUD_IMAGE_URL="http://clouds.local:8082/snapshot" example CLOUD_REFRESH_SECONDS=60 +export CLOUD_DB_PATH="./clouds.db" diff --git a/go.mod b/go.mod index 7562431..8a272a7 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,17 @@ module cloudcover go 1.24.4 require gocv.io/x/gocv v0.42.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.39.1 // indirect +) diff --git a/go.sum b/go.sum index f377ab5..729bf75 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,25 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= gocv.io/x/gocv v0.42.0 h1:AAsrFJH2aIsQHukkCovWqj0MCGZleQpVyf5gNVRXjQI= gocv.io/x/gocv v0.42.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= diff --git a/main.go b/main.go index e2c1b39..08da81b 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "database/sql" "encoding/json" "fmt" "io" @@ -11,22 +12,39 @@ import ( "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) - trendHistory []CloudResult - trendLock sync.Mutex + 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 { @@ -64,7 +82,6 @@ func estimateCloudCover(url string) (float64, error) { defer valMask.Close() defer cloudMask.Close() - // Low saturation, high brightness = likely clouds 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) @@ -88,64 +105,96 @@ func backgroundCollector() { result.Error = err.Error() } - trendLock.Lock() - trendHistory = append(trendHistory, result) - if len(trendHistory) > 200 { - trendHistory = trendHistory[len(trendHistory)-200:] - } - trendLock.Unlock() - + 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 getEnv(key, def string) string { - if v, ok := os.LookupEnv(key); ok { - return v +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) } - 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 +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) } - return def -} -func main() { - 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 interval: %ds | Source: %s\n", refreshSecs, imageURL) - log.Fatal(http.ListenAndServe(":8080", nil)) + // 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) { - trendLock.Lock() - defer trendLock.Unlock() - - if len(trendHistory) == 0 { - http.Error(w, "No data collected yet", http.StatusServiceUnavailable) + result, err := getLatest() + if err != nil { + http.Error(w, "no data yet", http.StatusServiceUnavailable) return } - latest := trendHistory[len(trendHistory)-1] w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(latest) + json.NewEncoder(w).Encode(result) } func handleTrend(w http.ResponseWriter, r *http.Request) { - trendLock.Lock() - defer trendLock.Unlock() + 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(trendHistory) + json.NewEncoder(w).Encode(results) } func dashboardHandler(w http.ResponseWriter, r *http.Request) { @@ -163,7 +212,7 @@ func dashboardHandler(w http.ResponseWriter, r *http.Request) {

☁️ Cloud Cover Trend

-

Live view of sky cloudiness from ` + imageURL + `

+

Source: ` + imageURL + `