From 61d77c87f6462a43dc03123adee436a5c6c4291c Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Fri, 19 Dec 2025 11:26:35 -0500 Subject: [PATCH] Use points for checks and add config loading --- main.go | 130 +++++++++++++++++++++++++++++++------------------------- 1 file changed, 71 insertions(+), 59 deletions(-) diff --git a/main.go b/main.go index 68b33c3..1a929be 100644 --- a/main.go +++ b/main.go @@ -43,40 +43,45 @@ func getEnvDuration(key string, fallback time.Duration) time.Duration { } type Light struct { - Name string - X, Y, W, H int - Priority string // high, default, low, urgent + Name string `json:"name"` + X, Y int `json:"x","y"` + Priority string `json:"priority"` + MinOnDurationMS int64 `json:"min_on_duration_ms"` } -var lights = map[string]Light{ - "furnace_power": {"Furnace Power", 1135, 785, 15, 5, "urgent"}, - "living_room_stat": {"Living Room Thermostat", 885, 230, 10, 5, "default"}, - "living_room_heat": {"Living Room Heating", 1395, 224, 10, 5, "default"}, - "dining_room_stat": {"Dining Room Thermostat", 885, 263, 10, 10, "default"}, - "dining_room_heat": {"Dining Room Heating", 1395, 267, 10, 5, "default"}, - "hot_water_stat": {"Hot Water Thermostat", 880, 440, 10, 5, "default"}, - "hot_water_heat": {"Hot Water Heating", 1405, 445, 10, 5, "default"}, - "first_bed_stat": {"First Bedroom Thermostat", 880, 480, 10, 5, "default"}, - "first_bed_heat": {"First Bedroom Heating", 1407, 480, 10, 5, "default"}, - "master_bed_stat": {"Master Bedroom Thermostat", 880, 520, 10, 5, "default"}, - "master_bed_heat": {"Master Bedroom Heating", 1418, 540, 10, 5, "default"}, - "basement_stat": {"Basement Thermostat", 875, 560, 10, 5, "default"}, - "basement_heat": {"Basement Heating", 1410, 560, 10, 5, "default"}, - "second_bed_stat": {"Second Bedroom Thermostat", 880, 600, 10, 5, "default"}, - "second_bed_heat": {"Second Bedroom Heating", 1412, 600, 10, 5, "default"}, - "inducer": {"Inducer", 1415, 645, 10, 5, "default"}, - "burner": {"Burner", 1420, 690, 10, 5, "default"}, - "circulator": {"Circulator", 1422, 734, 10, 5, "default"}, -} +var defaultLights = map[string]Light{ + "furnace_power": {"Furnace Power", 1135, 785, "urgent", 100}, + "living_room_stat": {"Living Room Thermostat", 885, 230, "low", 100}, + "living_room_heat": {"Living Room Heating", 1395, 224, "low", 100}, + "dining_room_stat": {"Dining Room Thermostat", 885, 263, "low", 100}, + "dining_room_heat": {"Dining Room Heating", 1395, 267, "low", 100}, + "hot_water_stat": {"Hot Water Thermostat", 880, 440, "low", 100}, + "hot_water_heat": {"Hot Water Heating", 1405, 445, "low", 100}, + "first_bed_stat": {"First Bedroom Thermostat", 880, 480, "low", 100}, + "first_bed_heat": {"First Bedroom Heating", 1407, 480, "low", 100}, + "master_bed_stat": {"Master Bedroom Thermostat", 880, 520, "low", 100}, + "master_bed_heat": {"Master Bedroom Heating", 1418, 540, "low", 100}, + "basement_stat": {"Basement Thermostat", 875, 560, "low", 100}, + "basement_heat": {"Basement Heating", 1410, 560, "low", 100}, + "second_bed_stat": {"Second Bedroom Thermostat", 880, 600, "low", 100}, + "second_bed_heat": {"Second Bedroom Heating", 1412, 600, "low", 100}, + "inducer": {"Inducer", 1415, 645, "low", 100}, + "burner": {"Burner", 1420, 690, "low", 100}, + "circulator": {"Circulator", 1422, 734, "low", 100}, +} func main() { fmt.Println("Starting multi-light monitor") - dbPath := getEnv("LIGHT_DB_PATH", "/var/furnacecheck/lightlog.db") + dbPath := getEnv("LIGHT_DB_PATH", "lightlog.db") webcamURL := getEnv("WEBCAM_URL", "http://furnace.service:8082/stream") - minBrightness := getEnvInt("MIN_BRIGHTNESS", 150) + minBrightness := getEnvInt("MIN_BRIGHTNESS", 160) checkInterval := getEnvDuration("CHECK_INTERVAL", 2*time.Second) webPort := getEnv("FURNACE_WEB_PORT", "8090") + lights, err := loadLightsFromEnv() + if err != nil { + log.Fatal(err) + } fmt.Println("Using DB:", dbPath) fmt.Println("Using webcam:", webcamURL) @@ -106,12 +111,12 @@ func main() { prevStates := make(map[string]bool) for { - img, err := grabFrame(webcamURL) - if err != nil { - log.Println("Error grabbing frame:", err) - time.Sleep(time.Second) - continue - } + img, err := grabFrame(webcamURL) + if err != nil { + log.Println("Error grabbing frame:", err) + time.Sleep(time.Second) + continue + } for key, light := range lights { on := isLightOn(img, light, minBrightness) @@ -120,13 +125,6 @@ func main() { if on != prevStates[key] { stateStr := onOff(on) - // Log to SQLite - _, err := db.Exec(`INSERT INTO light_checks(name, state, timestamp) VALUES(?, ?, ?)`, - key, stateStr, time.Now().UTC()) - if err != nil { - log.Println("Failed to insert into DB:", err) - } - // Notify fmt.Printf("%s: %s\n", light.Name, stateStr) sendNtfyNotification(light, on) @@ -140,6 +138,25 @@ func main() { } } +func loadLightsFromEnv() (map[string]Light, error) { + raw := os.Getenv("LIGHTS_JSON") + if raw == "" { + return defaultLights, nil + } + + var lights map[string]Light + if err := json.Unmarshal([]byte(raw), &lights); err != nil { + return nil, fmt.Errorf("invalid LIGHTS_JSON: %w", err) + } + + if len(lights) == 0 { + return nil, fmt.Errorf("no lights defined") + } + + return lights, nil +} + + func grabFrame(url string) (image.Image, error) { resp, err := http.Get(url) if err != nil { @@ -196,30 +213,20 @@ func readJPEGFrame(r *bufio.Reader) ([]byte, error) { func isLightOn(img image.Image, light Light, minBrightness int) bool { bounds := img.Bounds() - x0 := clamp(light.X, bounds.Min.X, bounds.Max.X) - y0 := clamp(light.Y, bounds.Min.Y, bounds.Max.Y) - x1 := clamp(light.X+light.W, bounds.Min.X, bounds.Max.X) - y1 := clamp(light.Y+light.H, bounds.Min.Y, bounds.Max.Y) + x := clamp(light.X, bounds.Min.X, bounds.Max.X) + y := clamp(light.Y, bounds.Min.Y, bounds.Max.Y) - var lumSum float64 - var count int + r, g, bl, _ := img.At(x, y).RGBA() - for y := y0; y < y1; y++ { - for x := x0; x < x1; x++ { - r, g, b, _ := img.At(x, y).RGBA() - r8, g8, b8 := int(r>>8), int(g>>8), int(b>>8) - lum := 0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8) - lumSum += lum - count++ - } - } + // Convert to 8-bit + rr := float64(r >> 8) + gg := float64(g >> 8) + bb := float64(bl >> 8) - if count == 0 { - return false - } + // Perceived luminance (Rec. 709) + brightness := 0.2126*rr + 0.7152*gg + 0.0722*bb - avgLum := lumSum / float64(count) - return avgLum > float64(minBrightness) + return brightness >= float64(minBrightness) } func clamp(v, min, max int) int { @@ -248,7 +255,7 @@ func sendNtfyNotification(light Light, on bool) { log.Println("Failed to create request:", err) return } - req.Header.Set("Title", "Light State Change") + req.Header.Set("Title", "Furnace State Change") req.Header.Set("Priority", light.Priority) client := &http.Client{Timeout: 5 * time.Second} @@ -261,6 +268,11 @@ func sendNtfyNotification(light Light, on bool) { } func startWebServer(db *sql.DB, port string) { + lights, err := loadLightsFromEnv() + if err != nil { + log.Fatal(err) + } + http.HandleFunc("/lights", func(w http.ResponseWriter, r *http.Request) { rows, err := db.Query(` SELECT name, state, MAX(timestamp)