306 lines
7.0 KiB
Go
306 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
"log"
|
|
_ "modernc.org/sqlite"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func getEnv(key, fallback string) string {
|
|
if val, ok := os.LookupEnv(key); ok {
|
|
return val
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 `json:"name"`
|
|
X, Y int `json:"x","y"`
|
|
Priority string `json:"priority"`
|
|
MinOnDurationMS int64 `json:"min_on_duration_ms"`
|
|
}
|
|
|
|
|
|
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", "lightlog.db")
|
|
webcamURL := getEnv("WEBCAM_URL", "http://furnace.service:8082/stream")
|
|
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)
|
|
fmt.Println("Min Brightness:", minBrightness)
|
|
fmt.Println("Check Interval:", checkInterval)
|
|
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
|
|
go startWebServer(db, getEnv("HTTP_PORT", webPort))
|
|
fmt.Println("Starting json data server on port ", webPort)
|
|
|
|
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 {
|
|
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)
|
|
|
|
// Only act if state changed
|
|
if on != prevStates[key] {
|
|
stateStr := onOff(on)
|
|
|
|
// Notify
|
|
fmt.Printf("%s: %s\n", light.Name, stateStr)
|
|
sendNtfyNotification(light, on)
|
|
|
|
// Update previous state
|
|
prevStates[key] = on
|
|
}
|
|
}
|
|
|
|
time.Sleep(checkInterval)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
reader := bufio.NewReader(resp.Body)
|
|
frame, err := readJPEGFrame(reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return jpeg.Decode(bytes.NewReader(frame))
|
|
}
|
|
|
|
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)
|
|
|
|
data := buf.Bytes()
|
|
if len(data) > 2 && data[len(data)-2] == 0xFF && data[len(data)-1] == 0xD9 {
|
|
return buf.Bytes(), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func isLightOn(img image.Image, light Light, minBrightness int) bool {
|
|
bounds := img.Bounds()
|
|
|
|
x := clamp(light.X, bounds.Min.X, bounds.Max.X)
|
|
y := clamp(light.Y, bounds.Min.Y, bounds.Max.Y)
|
|
|
|
r, g, bl, _ := img.At(x, y).RGBA()
|
|
|
|
// Convert to 8-bit
|
|
rr := float64(r >> 8)
|
|
gg := float64(g >> 8)
|
|
bb := float64(bl >> 8)
|
|
|
|
// Perceived luminance (Rec. 709)
|
|
brightness := 0.2126*rr + 0.7152*gg + 0.0722*bb
|
|
|
|
return brightness >= float64(minBrightness)
|
|
}
|
|
|
|
func clamp(v, min, max int) int {
|
|
if v < min {
|
|
return min
|
|
}
|
|
if v > max {
|
|
return max
|
|
}
|
|
return v
|
|
}
|
|
|
|
func onOff(on bool) string {
|
|
if on {
|
|
return "ON"
|
|
}
|
|
return "OFF"
|
|
}
|
|
|
|
func sendNtfyNotification(light Light, on bool) {
|
|
state := onOff(on)
|
|
url := "https://ntfy.unbl.ink/furnace"
|
|
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 State Change")
|
|
req.Header.Set("Priority", light.Priority)
|
|
|
|
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()
|
|
}
|
|
|
|
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)
|
|
FROM light_checks
|
|
GROUP BY name
|
|
`)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
resp := make(map[string]string)
|
|
for rows.Next() {
|
|
var key, state string
|
|
var ts string
|
|
if err := rows.Scan(&key, &state, &ts); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
resp[lights[key].Name] = state
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
})
|
|
|
|
log.Println("HTTP server listening on", port)
|
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
|
}
|