From c0bd1f4e1383cde482c4ec305908597530473b76 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Thu, 18 Dec 2025 21:10:41 -0500 Subject: [PATCH] Initial commit --- .gitignore | 3 + Makefile | 40 +++++++++++ PROJECT.org | 14 ++++ README.md | 4 ++ go.mod | 3 + go.sum | 2 + main.go | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 253 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 PROJECT.org create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go 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..15865b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +# Makefile + +# Project name / binary name +BINARY_NAME := furnacecheck + +# 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: + export CGO_CPPFLAGS="$(pkg-config --cflags opencv4)" + export CGO_LDFLAGS="$(pkg-config --libs opencv4 | sed 's/-lopencv_hdf//g' | sed 's/-lopencv_viz//g')" + go build -tags customenv -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/PROJECT.org b/PROJECT.org new file mode 100644 index 0000000..0661c7d --- /dev/null +++ b/PROJECT.org @@ -0,0 +1,14 @@ +#+title: FurnaceCheck + +* Expectations +* Usage +* Backlog +** DONE Ask ChatGPT to write the start of a furnacecheck app :project:personal:furnacecheck:chatgpt:feature: +:PROPERTIES: +:ID: 578e0c5b-7a6b-de9b-c9b6-b2edbe45ae82 +:END: + +** STRT Deploy basic check app to mundilfari :project:personal:furnacecheck:deploy:build: +:PROPERTIES: +:ID: 6fa2f545-470e-257d-50fd-97a6ee59a859 +:END: diff --git a/README.md b/README.md new file mode 100644 index 0000000..3049ca4 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +FurnaceCheck +============ + +A simple microservice which reads in a webcam snapshot (preferrably from ustreamer) and outputs whether the furnace is on and which zones are being heated. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b461a68 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module furnacecheck + +go 1.24.4 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f377ab5 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +gocv.io/x/gocv v0.42.0 h1:AAsrFJH2aIsQHukkCovWqj0MCGZleQpVyf5gNVRXjQI= +gocv.io/x/gocv v0.42.0/go.mod h1:zYdWMj29WAEznM3Y8NsU3A0TRq/wR/cy75jeUypThqU= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7aee03d --- /dev/null +++ b/main.go @@ -0,0 +1,187 @@ +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() +}