Initial commit
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
*.db
|
||||||
|
.envrc
|
||||||
|
build
|
||||||
40
Makefile
Normal file
40
Makefile
Normal 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
14
PROJECT.org
Normal 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
4
README.md
Normal 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.
|
||||||
2
go.sum
Normal file
2
go.sum
Normal 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
187
main.go
Normal 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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user