Use points for checks and add config loading
This commit is contained in:
130
main.go
130
main.go
@ -43,40 +43,45 @@ func getEnvDuration(key string, fallback time.Duration) time.Duration {
|
||||
}
|
||||
|
||||
type Light struct {
|
||||
Name string
|
||||
X, Y, W, H int
|
||||
Priority string // high, default, low, urgent
|
||||
Name string `json:"name"`
|
||||
X, Y int `json:"x","y"`
|
||||
Priority string `json:"priority"`
|
||||
MinOnDurationMS int64 `json:"min_on_duration_ms"`
|
||||
}
|
||||
|
||||
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"},
|
||||
}
|
||||
|
||||
var defaultLights = map[string]Light{
|
||||
"furnace_power": {"Furnace Power", 1135, 785, "urgent", 100},
|
||||
"living_room_stat": {"Living Room Thermostat", 885, 230, "low", 100},
|
||||
"living_room_heat": {"Living Room Heating", 1395, 224, "low", 100},
|
||||
"dining_room_stat": {"Dining Room Thermostat", 885, 263, "low", 100},
|
||||
"dining_room_heat": {"Dining Room Heating", 1395, 267, "low", 100},
|
||||
"hot_water_stat": {"Hot Water Thermostat", 880, 440, "low", 100},
|
||||
"hot_water_heat": {"Hot Water Heating", 1405, 445, "low", 100},
|
||||
"first_bed_stat": {"First Bedroom Thermostat", 880, 480, "low", 100},
|
||||
"first_bed_heat": {"First Bedroom Heating", 1407, 480, "low", 100},
|
||||
"master_bed_stat": {"Master Bedroom Thermostat", 880, 520, "low", 100},
|
||||
"master_bed_heat": {"Master Bedroom Heating", 1418, 540, "low", 100},
|
||||
"basement_stat": {"Basement Thermostat", 875, 560, "low", 100},
|
||||
"basement_heat": {"Basement Heating", 1410, 560, "low", 100},
|
||||
"second_bed_stat": {"Second Bedroom Thermostat", 880, 600, "low", 100},
|
||||
"second_bed_heat": {"Second Bedroom Heating", 1412, 600, "low", 100},
|
||||
"inducer": {"Inducer", 1415, 645, "low", 100},
|
||||
"burner": {"Burner", 1420, 690, "low", 100},
|
||||
"circulator": {"Circulator", 1422, 734, "low", 100},
|
||||
}
|
||||
func main() {
|
||||
fmt.Println("Starting multi-light monitor")
|
||||
|
||||
dbPath := getEnv("LIGHT_DB_PATH", "/var/furnacecheck/lightlog.db")
|
||||
dbPath := getEnv("LIGHT_DB_PATH", "lightlog.db")
|
||||
webcamURL := getEnv("WEBCAM_URL", "http://furnace.service:8082/stream")
|
||||
minBrightness := getEnvInt("MIN_BRIGHTNESS", 150)
|
||||
minBrightness := getEnvInt("MIN_BRIGHTNESS", 160)
|
||||
checkInterval := getEnvDuration("CHECK_INTERVAL", 2*time.Second)
|
||||
webPort := getEnv("FURNACE_WEB_PORT", "8090")
|
||||
lights, err := loadLightsFromEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("Using DB:", dbPath)
|
||||
fmt.Println("Using webcam:", webcamURL)
|
||||
@ -106,12 +111,12 @@ func main() {
|
||||
prevStates := make(map[string]bool)
|
||||
|
||||
for {
|
||||
img, err := grabFrame(webcamURL)
|
||||
if err != nil {
|
||||
log.Println("Error grabbing frame:", err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
img, err := grabFrame(webcamURL)
|
||||
if err != nil {
|
||||
log.Println("Error grabbing frame:", err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
for key, light := range lights {
|
||||
on := isLightOn(img, light, minBrightness)
|
||||
@ -120,13 +125,6 @@ func main() {
|
||||
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)
|
||||
@ -140,6 +138,25 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func loadLightsFromEnv() (map[string]Light, error) {
|
||||
raw := os.Getenv("LIGHTS_JSON")
|
||||
if raw == "" {
|
||||
return defaultLights, nil
|
||||
}
|
||||
|
||||
var lights map[string]Light
|
||||
if err := json.Unmarshal([]byte(raw), &lights); err != nil {
|
||||
return nil, fmt.Errorf("invalid LIGHTS_JSON: %w", err)
|
||||
}
|
||||
|
||||
if len(lights) == 0 {
|
||||
return nil, fmt.Errorf("no lights defined")
|
||||
}
|
||||
|
||||
return lights, nil
|
||||
}
|
||||
|
||||
|
||||
func grabFrame(url string) (image.Image, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
@ -196,30 +213,20 @@ func readJPEGFrame(r *bufio.Reader) ([]byte, error) {
|
||||
func isLightOn(img image.Image, light Light, minBrightness int) bool {
|
||||
bounds := img.Bounds()
|
||||
|
||||
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)
|
||||
x := clamp(light.X, bounds.Min.X, bounds.Max.X)
|
||||
y := clamp(light.Y, bounds.Min.Y, bounds.Max.Y)
|
||||
|
||||
var lumSum float64
|
||||
var count int
|
||||
r, g, bl, _ := img.At(x, y).RGBA()
|
||||
|
||||
for y := y0; y < y1; y++ {
|
||||
for x := x0; x < x1; x++ {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
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++
|
||||
}
|
||||
}
|
||||
// Convert to 8-bit
|
||||
rr := float64(r >> 8)
|
||||
gg := float64(g >> 8)
|
||||
bb := float64(bl >> 8)
|
||||
|
||||
if count == 0 {
|
||||
return false
|
||||
}
|
||||
// Perceived luminance (Rec. 709)
|
||||
brightness := 0.2126*rr + 0.7152*gg + 0.0722*bb
|
||||
|
||||
avgLum := lumSum / float64(count)
|
||||
return avgLum > float64(minBrightness)
|
||||
return brightness >= float64(minBrightness)
|
||||
}
|
||||
|
||||
func clamp(v, min, max int) int {
|
||||
@ -248,7 +255,7 @@ func sendNtfyNotification(light Light, on bool) {
|
||||
log.Println("Failed to create request:", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Title", "Light State Change")
|
||||
req.Header.Set("Title", "Furnace State Change")
|
||||
req.Header.Set("Priority", light.Priority)
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
@ -261,6 +268,11 @@ func sendNtfyNotification(light Light, on bool) {
|
||||
}
|
||||
|
||||
func startWebServer(db *sql.DB, port string) {
|
||||
lights, err := loadLightsFromEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
http.HandleFunc("/lights", func(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := db.Query(`
|
||||
SELECT name, state, MAX(timestamp)
|
||||
|
||||
Reference in New Issue
Block a user