Initial commit

This commit is contained in:
2025-12-18 21:10:41 -05:00
commit c0bd1f4e13
7 changed files with 253 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.db
.envrc
build

40
Makefile Normal file
View File

@ -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

14
PROJECT.org Normal file
View File

@ -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:

4
README.md Normal file
View File

@ -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.

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module furnacecheck
go 1.24.4

2
go.sum Normal file
View File

@ -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=

187
main.go Normal file
View File

@ -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()
}