package main import ( "bufio" "bytes" "database/sql" "encoding/json" "fmt" "image" "image/jpeg" "log" _ "modernc.org/sqlite" "net/http" "os" "strconv" "strings" "time" ) func getEnv(key, fallback string) string { if val, ok := os.LookupEnv(key); ok { return val } return fallback } func getEnvInt(key string, fallback int) int { if val, ok := os.LookupEnv(key); ok { if i, err := strconv.Atoi(val); err == nil { return i } } return fallback } func getEnvDuration(key string, fallback time.Duration) time.Duration { if val, ok := os.LookupEnv(key); ok { if d, err := time.ParseDuration(val); err == nil { return d } } return fallback } type Light struct { Name string `json:"name"` X, Y int `json:"x","y"` Priority string `json:"priority"` MinOnDurationMS int64 `json:"min_on_duration_ms"` } var defaultLights = map[string]Light{ "living_room_stat": {"Living Room Thermostat", 775, 251, "low", 100}, "living_room_heat": {"Living Room Heating", 1320, 263, "low", 100}, "dining_room_stat": {"Dining Room Thermostat", 779, 290, "low", 100}, "dining_room_heat": {"Dining Room Heating", 1317, 305, "low", 100}, "hot_water_stat": {"Hot Water Thermostat", 773, 488, "low", 100}, "hot_water_heat": {"Hot Water Heating", 1313, 501, "low", 100}, "first_bed_stat": {"First Bedroom Thermostat", 769, 526, "low", 100}, "first_bed_heat": {"First Bedroom Heating", 1309, 540, "low", 100}, "master_bed_stat": {"Master Bedroom Thermostat", 773, 570, "low", 100}, "master_bed_heat": {"Master Bedroom Heating", 1309, 588, "low", 100}, "basement_stat": {"Basement Thermostat", 769, 614, "low", 100}, "basement_heat": {"Basement Heating", 1309, 630, "low", 100}, "second_bed_stat": {"Second Bedroom Thermostat", 764, 657, "low", 100}, "second_bed_heat": {"Second Bedroom Heating", 1307, 672, "low", 100}, "inducer": {"Inducer", 1307, 711, "low", 100}, "burner": {"Burner", 1307, 760, "low", 100}, "circulator": {"Circulator", 1307, 799, "low", 100}, "furnace_power": {"Furnace Power", 1038, 837, "urgent", 100}, } func main() { fmt.Println("Starting multi-light monitor") dbPath := getEnv("LIGHT_DB_PATH", "lightlog.db") webcamURL := getEnv("WEBCAM_URL", "http://furnace.service:8081/stream") minBrightness := getEnvInt("MIN_BRIGHTNESS", 170) 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) fmt.Println("Min Brightness:", minBrightness) fmt.Println("Check Interval:", checkInterval) db, err := sql.Open("sqlite", dbPath) go startWebServer(db, getEnv("HTTP_PORT", webPort)) fmt.Println("Starting json data server on port ", webPort) if err != nil { log.Fatal(err) } defer db.Close() _, err = db.Exec(`CREATE TABLE IF NOT EXISTS light_checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, state TEXT, timestamp DATETIME )`) _, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_light_checks_name_ts ON light_checks(name, timestamp DESC );`) if err != nil { log.Fatal(err) } prevStates := make(map[string]bool) for { img, err := grabFrame(webcamURL) if err != nil { log.Println("Error grabbing frame:", err) time.Sleep(time.Second) continue } now := time.Now() for key, light := range lights { on := isLightOn(img, light, minBrightness) // Only act if state changed if on != prevStates[key] { stateStr := onOff(on) fmt.Printf("%s: %s\n", light.Name, stateStr) insertLightEvent(db, key, on, now) sendNtfyNotification(light, on) prevStates[key] = on } } time.Sleep(checkInterval) } } 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 { return nil, err } defer resp.Body.Close() reader := bufio.NewReader(resp.Body) frame, err := readJPEGFrame(reader) if err != nil { return nil, err } return jpeg.Decode(bytes.NewReader(frame)) } func readJPEGFrame(r *bufio.Reader) ([]byte, error) { var buf bytes.Buffer for { line, err := r.ReadString('\n') if err != nil { return nil, err } if strings.HasPrefix(line, "--") { break } } for { line, err := r.ReadString('\n') if err != nil { return nil, err } if line == "\r\n" { break } } for { b, err := r.ReadByte() if err != nil { return nil, err } buf.WriteByte(b) data := buf.Bytes() if len(data) > 2 && data[len(data)-2] == 0xFF && data[len(data)-1] == 0xD9 { return buf.Bytes(), nil } } } func isLightOn(img image.Image, light Light, minBrightness int) bool { bounds := img.Bounds() x := clamp(light.X, bounds.Min.X, bounds.Max.X) y := clamp(light.Y, bounds.Min.Y, bounds.Max.Y) r, g, bl, _ := img.At(x, y).RGBA() // Convert to 8-bit rr := float64(r >> 8) gg := float64(g >> 8) bb := float64(bl >> 8) // Perceived luminance (Rec. 709) brightness := 0.2126*rr + 0.7152*gg + 0.0722*bb return brightness >= float64(minBrightness) } func clamp(v, min, max int) int { if v < min { return min } if v > max { return max } return v } func onOff(on bool) string { if on { return "ON" } return "OFF" } func sendNtfyNotification(light Light, on bool) { state := onOff(on) url := "https://ntfy.unbl.ink/furnace" body := bytes.NewBufferString(fmt.Sprintf("%s: %s", light.Name, state)) req, err := http.NewRequest("POST", url, body) if err != nil { log.Println("Failed to create request:", err) return } req.Header.Set("Title", "Furnace State Change") req.Header.Set("Priority", light.Priority) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Do(req) if err != nil { log.Println("Failed to send notification:", err) return } resp.Body.Close() } 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) FROM light_checks GROUP BY name `) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() resp := make(map[string]string) for rows.Next() { var key, state string var ts string if err := rows.Scan(&key, &state, &ts); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } resp[lights[key].Name] = state } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) }) log.Println("HTTP server listening on", port) log.Fatal(http.ListenAndServe(":"+port, nil)) } func insertLightEvent(db *sql.DB, name string, on bool, ts time.Time) { state := "off" if on { state = "on" } _, err := db.Exec( `INSERT INTO light_checks (name, state, timestamp) VALUES (?, ?, ?)`, name, state, ts, ) if err != nil { log.Println("DB insert failed:", err) } }