package main import ( "bufio" "bytes" "fmt" "image" "image/jpeg" "log" "net/http" "strings" "time" ) const ( streamURL = "http://furnace.service:8082/stream" // 🔧 TUNE THESE ONCE roiX = 1050 roiY = 760 roiW = 60 roiH = 10 // Thresholds minAvgRed = 90 // below this → light OFF checkInterval = 2 * time.Second ) func main() { fmt.Println("Starting furnace light monitor") var prevState bool // false = Off, true = On for { on, err := checkOnce() if err != nil { log.Println("Error:", err) time.Sleep(time.Second) continue } // Check for state change if on != prevState { if on { fmt.Println("RED LIGHT: ON") } else { fmt.Println("RED LIGHT: OFF") } // Send notification sendNtfyNotification(on) prevState = on } time.Sleep(checkInterval) } } func checkOnce() (bool, error) { resp, err := http.Get(streamURL) if err != nil { return false, err } defer resp.Body.Close() reader := bufio.NewReader(resp.Body) for { frame, err := readJPEGFrame(reader) if err != nil { return false, err } img, err := jpeg.Decode(bytes.NewReader(frame)) return isRedLightOn(img), nil } } 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) if buf.Len() > 2 { data := buf.Bytes() if data[len(data)-2] == 0xFF && data[len(data)-1] == 0xD9 { return buf.Bytes(), nil } } } } func isRedLightOn(img image.Image) bool { bounds := img.Bounds() x0 := clamp(roiX, bounds.Min.X, bounds.Max.X) y0 := clamp(roiY, bounds.Min.Y, bounds.Max.Y) x1 := clamp(roiX+roiW, bounds.Min.X, bounds.Max.X) y1 := clamp(roiY+roiH, bounds.Min.Y, bounds.Max.Y) var redSum int var count int for y := y0; y < y1; y++ { for x := x0; x < x1; x++ { r, g, b, _ := img.At(x, y).RGBA() red := int(r >> 8) green := int(g >> 8) blue := int(b >> 8) // ensure it's "red", not white glare if red > 100 && red > green+30 && red > blue+30 { redSum += red count++ } } } if count == 0 { return false } avgRed := redSum / count return avgRed > minAvgRed } func clamp(v, min, max int) int { if v < min { return min } if v > max { return max } return v } func sendNtfyNotification(on bool) { state := "OFF" if on { state = "ON" } url := "https://ntfy.unbl.ink/furnace" body := bytes.NewBufferString(state) req, err := http.NewRequest("POST", url, body) if err != nil { log.Println("Failed to create request:", err) return } req.Header.Set("Title", "Furnace Power") req.Header.Set("Priority", "high") 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() }