Store things in sqlite

This commit is contained in:
2025-12-18 22:51:36 -05:00
parent 66ffc5f747
commit 76d55c572c
3 changed files with 199 additions and 68 deletions

201
main.go
View File

@ -3,79 +3,152 @@ package main
import (
"bufio"
"bytes"
"database/sql"
"fmt"
"image"
"image/jpeg"
"log"
_ "modernc.org/sqlite"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const (
streamURL = "http://furnace.service:8082/stream"
func getEnv(key, fallback string) string {
if val, ok := os.LookupEnv(key); ok {
return val
}
return fallback
}
// 🔧 TUNE THESE ONCE
roiX = 1050
roiY = 760
roiW = 60
roiH = 10
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
}
// Thresholds
minAvgRed = 90 // below this → light OFF
checkInterval = 2 * time.Second
)
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
X, Y, W, H int
Priority string // high, default, low, urgent
}
var lights = map[string]Light{
"furnace_power": {"Furnace Power", 1135, 785, 15, 5, "urgent"},
"living_room_stat": {"Living Room Thermostat", 885, 230, 10, 5, "default"},
"living_room_heat": {"Living Room Heating", 1395, 224, 10, 5, "default"},
"dining_room_stat": {"Dining Room Thermostat", 885, 263, 10, 10, "default"},
"dining_room_heat": {"Dining Room Heating", 1395, 267, 10, 5, "default"},
"hot_water_stat": {"Hot Water Thermostat", 880, 440, 10, 5, "default"},
"hot_water_heat": {"Hot Water Heating", 1405, 445, 10, 5, "default"},
"first_bed_stat": {"First Bedroom Thermostat", 880, 480, 10, 5, "default"},
"first_bed_heat": {"First Bedroom Heating", 1407, 480, 10, 5, "default"},
"master_bed_stat": {"Master Bedroom Thermostat", 880, 520, 10, 5, "default"},
"master_bed_heat": {"Master Bedroom Heating", 1418, 540, 10, 5, "default"},
"basement_stat": {"Basement Thermostat", 875, 560, 10, 5, "default"},
"basement_heat": {"Basement Heating", 1410, 560, 10, 5, "default"},
"second_bed_stat": {"Second Bedroom Thermostat", 880, 600, 10, 5, "default"},
"second_bed_heat": {"Second Bedroom Heating", 1412, 600, 10, 5, "default"},
"inducer": {"Inducer", 1415, 645, 10, 5, "default"},
"burner": {"Burner", 1420, 690, 10, 5, "default"},
"circulator": {"Circulator", 1422, 734, 10, 5, "default"},
}
func main() {
fmt.Println("Starting furnace light monitor")
fmt.Println("Starting multi-light monitor")
var prevState bool // false = Off, true = On
dbPath := getEnv("LIGHT_DB_PATH", "/var/furnacecheck/lightlog.db")
webcamURL := getEnv("WEBCAM_URL", "http://furnace.service:8082/stream")
minBrightness := getEnvInt("MIN_BRIGHTNESS", 150)
checkInterval := getEnvDuration("CHECK_INTERVAL", 2*time.Second)
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)
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
)`)
if err != nil {
log.Fatal(err)
}
prevStates := make(map[string]bool)
for {
on, err := checkOnce()
img, err := grabFrame(webcamURL)
if err != nil {
log.Println("Error:", err)
log.Println("Error grabbing frame:", 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")
for key, light := range lights {
on := isLightOn(img, light, minBrightness)
// Only act if state changed
if on != prevStates[key] {
stateStr := onOff(on)
// Log to SQLite
_, err := db.Exec(`INSERT INTO light_checks(name, state, timestamp) VALUES(?, ?, ?)`,
key, stateStr, time.Now().UTC())
if err != nil {
log.Println("Failed to insert into DB:", err)
}
// Notify
fmt.Printf("%s: %s\n", light.Name, stateStr)
sendNtfyNotification(light, on)
// Update previous state
prevStates[key] = on
}
// Send notification
sendNtfyNotification(on)
prevState = on
}
time.Sleep(checkInterval)
}
}
func checkOnce() (bool, error) {
resp, err := http.Get(streamURL)
func grabFrame(url string) (image.Image, error) {
resp, err := http.Get(url)
if err != nil {
return false, err
return nil, 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
frame, err := readJPEGFrame(reader)
if err != nil {
return nil, err
}
return jpeg.Decode(bytes.NewReader(frame))
}
func readJPEGFrame(r *bufio.Reader) ([]byte, error) {
@ -108,38 +181,31 @@ func readJPEGFrame(r *bufio.Reader) ([]byte, error) {
}
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
}
data := buf.Bytes()
if len(data) > 2 && data[len(data)-2] == 0xFF && data[len(data)-1] == 0xD9 {
return buf.Bytes(), nil
}
}
}
func isRedLightOn(img image.Image) bool {
func isLightOn(img image.Image, light Light, minBrightness int) 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)
x0 := clamp(light.X, bounds.Min.X, bounds.Max.X)
y0 := clamp(light.Y, bounds.Min.Y, bounds.Max.Y)
x1 := clamp(light.X+light.W, bounds.Min.X, bounds.Max.X)
y1 := clamp(light.Y+light.H, bounds.Min.Y, bounds.Max.Y)
var redSum int
var lumSum float64
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++
}
r8, g8, b8 := int(r>>8), int(g>>8), int(b>>8)
lum := 0.299*float64(r8) + 0.587*float64(g8) + 0.114*float64(b8)
lumSum += lum
count++
}
}
@ -147,8 +213,8 @@ func isRedLightOn(img image.Image) bool {
return false
}
avgRed := redSum / count
return avgRed > minAvgRed
avgLum := lumSum / float64(count)
return avgLum > float64(minBrightness)
}
func clamp(v, min, max int) int {
@ -161,21 +227,24 @@ func clamp(v, min, max int) int {
return v
}
func sendNtfyNotification(on bool) {
state := "OFF"
func onOff(on bool) string {
if on {
state = "ON"
return "ON"
}
return "OFF"
}
func sendNtfyNotification(light Light, on bool) {
state := onOff(on)
url := "https://ntfy.unbl.ink/furnace"
body := bytes.NewBufferString(state)
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 Power")
req.Header.Set("Priority", "high")
req.Header.Set("Title", "Light State Change")
req.Header.Set("Priority", light.Priority)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)