Add build and make scripts
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
*.db
|
||||||
|
.envrc
|
||||||
|
build
|
||||||
38
Makefile
Normal file
38
Makefile
Normal file
@ -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
|
||||||
@ -1,2 +1,3 @@
|
|||||||
export CLOUD_IMAGE_URL="http://clouds.local:8082/snapshot"
|
export CLOUD_IMAGE_URL="http://clouds.local:8082/snapshot"
|
||||||
example CLOUD_REFRESH_SECONDS=60
|
example CLOUD_REFRESH_SECONDS=60
|
||||||
|
export CLOUD_DB_PATH="./clouds.db"
|
||||||
|
|||||||
14
go.mod
14
go.mod
@ -3,3 +3,17 @@ module cloudcover
|
|||||||
go 1.24.4
|
go 1.24.4
|
||||||
|
|
||||||
require gocv.io/x/gocv v0.42.0
|
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
|
||||||
|
)
|
||||||
|
|||||||
23
go.sum
23
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 h1:AAsrFJH2aIsQHukkCovWqj0MCGZleQpVyf5gNVRXjQI=
|
||||||
gocv.io/x/gocv v0.42.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU=
|
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=
|
||||||
|
|||||||
151
main.go
151
main.go
@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -11,22 +12,39 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // lightweight pure-Go SQLite driver
|
||||||
"gocv.io/x/gocv"
|
"gocv.io/x/gocv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CloudResult struct {
|
type CloudResult struct {
|
||||||
|
ID int `json:"id"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
CloudCover float64 `json:"cloud_cover"`
|
CloudCover float64 `json:"cloud_cover"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
imageURL = getEnv("CLOUD_IMAGE_URL", "http://clouds.local:8082/snapshot")
|
imageURL = getEnv("CLOUD_IMAGE_URL", "http://clouds.local:8082/snapshot")
|
||||||
refreshSecs = getEnvInt("CLOUD_REFRESH_SECONDS", 30)
|
refreshSecs = getEnvInt("CLOUD_REFRESH_SECONDS", 30)
|
||||||
trendHistory []CloudResult
|
dbPath = getEnv("CLOUD_DB_PATH", "clouds.db")
|
||||||
trendLock sync.Mutex
|
|
||||||
|
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) {
|
func estimateCloudCover(url string) (float64, error) {
|
||||||
resp, err := http.Get(url)
|
resp, err := http.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -64,7 +82,6 @@ func estimateCloudCover(url string) (float64, error) {
|
|||||||
defer valMask.Close()
|
defer valMask.Close()
|
||||||
defer cloudMask.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(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.InRangeWithScalar(v, gocv.NewScalar(120, 0, 0, 0), gocv.NewScalar(255, 0, 0, 0), &valMask)
|
||||||
gocv.BitwiseAnd(satMask, valMask, &cloudMask)
|
gocv.BitwiseAnd(satMask, valMask, &cloudMask)
|
||||||
@ -88,64 +105,96 @@ func backgroundCollector() {
|
|||||||
result.Error = err.Error()
|
result.Error = err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
trendLock.Lock()
|
storeResult(result)
|
||||||
trendHistory = append(trendHistory, result)
|
|
||||||
if len(trendHistory) > 200 {
|
|
||||||
trendHistory = trendHistory[len(trendHistory)-200:]
|
|
||||||
}
|
|
||||||
trendLock.Unlock()
|
|
||||||
|
|
||||||
fmt.Printf("[%s] Cloud cover: %.2f%%\n", result.Timestamp.Format("15:04:05"), result.CloudCover)
|
fmt.Printf("[%s] Cloud cover: %.2f%%\n", result.Timestamp.Format("15:04:05"), result.CloudCover)
|
||||||
time.Sleep(time.Duration(refreshSecs) * time.Second)
|
time.Sleep(time.Duration(refreshSecs) * time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, def string) string {
|
func initDB() {
|
||||||
if v, ok := os.LookupEnv(key); ok {
|
var err error
|
||||||
return v
|
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 {
|
func storeResult(r CloudResult) {
|
||||||
if v, ok := os.LookupEnv(key); ok {
|
lock.Lock()
|
||||||
if i, err := strconv.Atoi(v); err == nil {
|
defer lock.Unlock()
|
||||||
return i
|
|
||||||
|
_, 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() {
|
// Reverse to chronological order
|
||||||
go backgroundCollector()
|
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
results[i], results[j] = results[j], results[i]
|
||||||
http.HandleFunc("/", dashboardHandler)
|
}
|
||||||
http.HandleFunc("/cloudcover", handleLatest)
|
return results, nil
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLatest(w http.ResponseWriter, r *http.Request) {
|
func handleLatest(w http.ResponseWriter, r *http.Request) {
|
||||||
trendLock.Lock()
|
result, err := getLatest()
|
||||||
defer trendLock.Unlock()
|
if err != nil {
|
||||||
|
http.Error(w, "no data yet", http.StatusServiceUnavailable)
|
||||||
if len(trendHistory) == 0 {
|
|
||||||
http.Error(w, "No data collected yet", http.StatusServiceUnavailable)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
latest := trendHistory[len(trendHistory)-1]
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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) {
|
func handleTrend(w http.ResponseWriter, r *http.Request) {
|
||||||
trendLock.Lock()
|
results, err := getAll(200)
|
||||||
defer trendLock.Unlock()
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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) {
|
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -163,7 +212,7 @@ func dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>☁️ Cloud Cover Trend</h1>
|
<h1>☁️ Cloud Cover Trend</h1>
|
||||||
<p>Live view of sky cloudiness from <code>` + imageURL + `</code></p>
|
<p>Source: <code>` + imageURL + `</code></p>
|
||||||
<canvas id="chart" width="800" height="400"></canvas>
|
<canvas id="chart" width="800" height="400"></canvas>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -209,3 +258,19 @@ func dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
w.Write([]byte(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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user