Rewrite camera uptime monitor in Golang

This commit is contained in:
Ashish D'Souza 2024-08-19 16:36:01 -05:00
parent fd4c30f655
commit 90efd78669
13 changed files with 284 additions and 1 deletions

View File

@ -119,7 +119,7 @@
register: docker_build_dir register: docker_build_dir
- name: Copy Docker build directory - name: Copy Docker build directory
ansible.builtin.copy: ansible.builtin.copy:
src: uptime/ src: uptime-go/
dest: '{{docker_build_dir.path}}' dest: '{{docker_build_dir.path}}'
mode: preserve mode: preserve
- name: Build frigate-uptime Docker image - name: Build frigate-uptime Docker image

12
uptime-go/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
#FROM golang:1.23
FROM golang:1.23
WORKDIR /code
ENTRYPOINT ["./uptime"]
ENV GOEXPERIMENT=loopvar
RUN apt update --fix-missing
RUN apt install -y iputils-ping ffmpeg
COPY uptime .
RUN go build .

View File

@ -0,0 +1,26 @@
package config
import "os"
import "gopkg.in/yaml.v3"
type Config struct {
Cameras map[string]string `yaml:"cameras"`
PingIntervalSeconds int64 `yaml:"ping_interval_s"`
PingTimeoutSeconds int64 `yaml:"ping_timeout_s"`
ConsecutiveDownThreshold int `yaml:"consecutive_down_threshold"`
}
func ReadConfig(configFilename string) Config {
var data, readErr = os.ReadFile(configFilename)
if readErr != nil {
panic(readErr)
}
var config Config
if yamlErr := yaml.Unmarshal(data, &config); yamlErr != nil {
panic(yamlErr)
}
return config
}

View File

@ -0,0 +1,9 @@
cameras:
back_door: 'rtsp://frigate:8554/back_door'
doorbell: 'rtsp://frigate:8554/doorbell'
front_door: 'rtsp://frigate:8554/front_door'
garage: 'rtsp://frigate:8554/garage'
ping_interval_s: 60
ping_timeout_s: 30
consecutive_down_threshold: 3

5
uptime-go/uptime/go.mod Normal file
View File

@ -0,0 +1,5 @@
module frigate/uptime
go 1.21.12
require gopkg.in/yaml.v3 v3.0.1 // indirect

3
uptime-go/uptime/go.sum Normal file
View File

@ -0,0 +1,3 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

17
uptime-go/uptime/main.go Normal file
View File

@ -0,0 +1,17 @@
package main
import "time"
import (
"frigate/uptime/config"
"frigate/uptime/notify"
)
func main() {
var uptimeConfig = config.ReadConfig("config/config.yaml")
var pingInterval = time.Duration(uptimeConfig.PingIntervalSeconds * int64(time.Second))
var pingTimeout = time.Duration(uptimeConfig.PingTimeoutSeconds * int64(time.Second))
var cameraMonitor CameraMonitor = NewCameraMonitor(pingInterval, pingTimeout, uptimeConfig.ConsecutiveDownThreshold, notify.NewNtfyNotifier())
cameraMonitor.Run(uptimeConfig.Cameras)
}

104
uptime-go/uptime/monitor.go Normal file
View File

@ -0,0 +1,104 @@
package main
import (
"log/slog"
"time"
)
import (
"frigate/uptime/ping"
"frigate/uptime/notify"
)
type CameraMonitor struct {
pingInterval time.Duration
pingTimeout time.Duration
consecutiveDownThreshold int
downtime map[string]int
notify.Notifier
}
func NewCameraMonitor(pingInterval time.Duration, pingTimeout time.Duration, consecutiveDownThreshold int, notifier notify.Notifier) CameraMonitor {
return CameraMonitor{
pingInterval: pingInterval,
pingTimeout: pingTimeout,
consecutiveDownThreshold: consecutiveDownThreshold,
downtime: make(map[string]int),
Notifier: notifier,
}
}
func (c CameraMonitor) onCameraUp(camera string) {
c.SendNotification(camera, true)
slog.Info(camera, "camera is back online!")
}
func (c CameraMonitor) onCameraDown(camera string) {
c.SendNotification(camera, false)
slog.Info(camera, "camera is offline!")
}
func (c CameraMonitor) onCameraPingResult(camera string, online bool) {
if online {
if c.downtime[camera] >= c.consecutiveDownThreshold {
c.onCameraUp(camera)
}
c.downtime[camera] = 0
} else {
c.downtime[camera] += 1
if c.downtime[camera] == c.consecutiveDownThreshold {
c.onCameraDown(camera)
}
}
}
func (c CameraMonitor) Run(cameras map[string]string) {
type pingResult struct {
camera string
online bool
}
var pingResultChannel = make(chan pingResult, 4)
for {
var unknownPingResultCameras = make(map[string]bool, len(cameras))
var startTime = time.Now()
var timeoutChannel = time.After(c.pingTimeout)
// Start all pings
for camera, host := range cameras {
go func() {
pingResultChannel <- pingResult{
camera: camera,
online: ping.VideoPing(host),
}
}()
unknownPingResultCameras[camera] = true
}
timeout:
// Await ping results or timeout
for range cameras {
select {
case cameraPingResult := <-pingResultChannel:
slog.Debug(cameraPingResult.camera, "camera:", cameraPingResult.online)
c.onCameraPingResult(cameraPingResult.camera, cameraPingResult.online)
delete(unknownPingResultCameras, cameraPingResult.camera) // Maintain set of cameras with unknown ping status
case <-timeoutChannel:
slog.Info("Timed out waiting for cameras", unknownPingResultCameras)
break timeout
}
}
// Handle timed out camera pings
for camera := range unknownPingResultCameras {
c.onCameraPingResult(camera, false)
}
var sleepDuration = c.pingInterval - time.Since(startTime)
slog.Debug("Sleeping for", sleepDuration)
time.Sleep(sleepDuration)
}
}

View File

@ -0,0 +1,28 @@
package notify
import (
"fmt"
"strings"
)
type Notifier interface {
SendNotification(camera string, online bool)
}
func createMessage(camera string, online bool) string {
var msgTemplates = map[bool]string{
true: "%s camera is back online!",
false: "%s camera is offline!",
}
var cameraTitle string
for i, s := range strings.Split(camera, "_") {
if i > 0 {
cameraTitle += " "
}
cameraTitle += strings.ToUpper(string(s[0])) + s[1:]
}
return fmt.Sprintf(msgTemplates[online], cameraTitle)
}

View File

@ -0,0 +1,58 @@
package notify
import (
"fmt"
"bytes"
"encoding/json"
"log/slog"
"net/http"
)
type NtfyNotifier struct {
client *http.Client
}
func NewNtfyNotifier() NtfyNotifier {
return NtfyNotifier{
client: &http.Client{},
}
}
func (n NtfyNotifier) SendNotification(camera string, online bool) {
type ntfyJson struct {
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
Click string `json:"click"`
Icon string `json:"icon"`
}
var reqJson, jsonErr = json.Marshal(ntfyJson{
Topic: "frigate_camera_uptime",
Title: "Frigate",
Message: createMessage(camera, online),
Priority: 3,
Click: fmt.Sprintf("https://frigate.homelab.net/cameras/%s", camera),
Icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/frigate.png",
})
if jsonErr != nil {
slog.Error("Failed to construct JSON request body", jsonErr)
return
}
var req, reqErr = http.NewRequest("POST", "https://ntfy.homelab.net", bytes.NewBuffer([]byte(reqJson)))
if reqErr != nil {
slog.Error("Failed to create HTTP request", reqErr)
}
req.Header.Set("Content-Type", "application/json")
var resp, respErr = n.client.Do(req)
if respErr != nil {
slog.Error("Failed to send HTTP request", respErr)
}
if resp.StatusCode != 200 {
slog.Error("Ntfy notification returned HTTP", resp.Status)
}
}

View File

@ -0,0 +1,10 @@
package ping
import "os/exec"
func IPPing(host string) bool {
var cmd = exec.Command("ping", "-w", "3", "-c", "1", host)
var err = cmd.Run()
return err == nil
}

View File

@ -0,0 +1 @@
package ping

View File

@ -0,0 +1,10 @@
package ping
import "os/exec"
func VideoPing(url string) bool {
var cmd = exec.Command("ffprobe", "-rtsp_transport", "tcp", "-i", url, "-timeout", "10000000")
var err = cmd.Run()
return err == nil
}