Store things in sqlite
This commit is contained in:
201
main.go
201
main.go
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user