380 lines
10 KiB
Go
380 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/signal"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"frame"
|
|
)
|
|
|
|
// camHandle captures the lifecycle state of one running camera so the
|
|
// rebalance loop and the camera's own goroutine can safely agree on removal
|
|
// (comparing handles by identity avoids deleting a replacement camera).
|
|
type camHandle struct {
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func main() {
|
|
var (
|
|
mofinAddr string
|
|
camMin int
|
|
camMax int
|
|
clipDur time.Duration
|
|
rescanInterval time.Duration
|
|
username string
|
|
cameraID string
|
|
passphrase string
|
|
authToken string
|
|
videoFPS int
|
|
videoWidth int
|
|
videoHeight int
|
|
pbkdf2Iter int
|
|
)
|
|
|
|
flag.StringVar(&mofinAddr, "mofin", "localhost:8083", "mofin motion detection address")
|
|
flag.IntVar(&camMin, "min-cam", 0, "minimum camera index to scan")
|
|
flag.IntVar(&camMax, "max-cam", 10, "maximum camera index to scan")
|
|
flag.DurationVar(&clipDur, "clip-duration", 10*time.Second, "duration of each clip")
|
|
flag.DurationVar(&rescanInterval, "rescan-interval", 10*time.Second, "interval between hotplug rescans")
|
|
flag.StringVar(&username, "user", "oko", "username for frame source data")
|
|
flag.StringVar(&cameraID, "camera-id", "", "camera identifier prefix (required)")
|
|
flag.StringVar(&passphrase, "passphrase", "", "encryption passphrase (empty = no encryption)")
|
|
flag.StringVar(&authToken, "auth-token", "", "shared pipeline auth token (or OKO_AUTH_TOKEN)")
|
|
flag.IntVar(&videoFPS, "video-fps", 5, "max frames per second read from the camera and stored in clips (0 = device native rate)")
|
|
flag.IntVar(&videoWidth, "video-width", 0, "capture width in pixels (0 = device default)")
|
|
flag.IntVar(&videoHeight, "video-height", 0, "capture height in pixels (0 = device default)")
|
|
flag.IntVar(&pbkdf2Iter, "pbkdf2-iters", frame.DefaultPBKDF2Iter, "PBKDF2 iterations for clip encryption (1..1000000)")
|
|
flag.Parse()
|
|
|
|
if cameraID == "" {
|
|
fmt.Fprintln(os.Stderr, "-camera-id is required")
|
|
flag.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
if pbkdf2Iter < 1 || pbkdf2Iter > frame.MaxPBKDF2Iter {
|
|
log.Fatalf("pbkdf2-iters must be in [1, %d], got %d", frame.MaxPBKDF2Iter, pbkdf2Iter)
|
|
}
|
|
if videoFPS < 0 || videoFPS > 60 {
|
|
log.Fatalf("video-fps must be in [0, 60], got %d", videoFPS)
|
|
}
|
|
if videoWidth < 0 || videoHeight < 0 {
|
|
log.Fatalf("video dimensions must be >= 0, got %dx%d", videoWidth, videoHeight)
|
|
}
|
|
frame.SetDefaultPBKDF2Iter(pbkdf2Iter)
|
|
|
|
tok := authToken
|
|
if tok == "" {
|
|
tok = os.Getenv("OKO_AUTH_TOKEN")
|
|
}
|
|
if tok == "" {
|
|
log.Fatal("authentication required: set -auth-token or OKO_AUTH_TOKEN")
|
|
}
|
|
frame.SetAuthToken(tok)
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
var (
|
|
cameras = make(map[int]*camHandle)
|
|
camerasMu sync.Mutex
|
|
wg sync.WaitGroup
|
|
)
|
|
|
|
// Hotplug loop: scan + rebalance continuously. It lives behind its own
|
|
// WaitGroup so shutdown can wait for it to stop scheduling new cameras
|
|
// BEFORE wg.Wait() (rebalance is the only caller of wg.Add; waiting on it
|
|
// first removes the Add/Wait misuse race).
|
|
var rebalanceWg sync.WaitGroup
|
|
rebalanceWg.Add(1)
|
|
go func() {
|
|
defer rebalanceWg.Done()
|
|
ticker := time.NewTicker(rescanInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
rebalance(ctx, &wg, &camerasMu, cameras, camMin, camMax, mofinAddr, clipDur, username, cameraID, passphrase, videoFPS, videoWidth, videoHeight)
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
log.Println("Shutting down...")
|
|
|
|
camerasMu.Lock()
|
|
for _, h := range cameras {
|
|
h.cancel()
|
|
}
|
|
camerasMu.Unlock()
|
|
|
|
rebalanceWg.Wait()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
log.Println("All cameras stopped")
|
|
case <-time.After(5 * time.Second): // TODO: Make configurable
|
|
log.Println("Forced shutdown after timeout")
|
|
}
|
|
}
|
|
|
|
func rebalance(ctx context.Context, wg *sync.WaitGroup, mu *sync.Mutex, cameras map[int]*camHandle, camMin, camMax int, mofinAddr string, clipDur time.Duration, username, cameraID, passphrase string, videoFPS, videoWidth, videoHeight int) {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
mu.Lock()
|
|
inUse := make(map[int]struct{}, len(cameras))
|
|
for idx := range cameras {
|
|
inUse[idx] = struct{}{}
|
|
}
|
|
mu.Unlock()
|
|
|
|
// Probe only devices not already streaming: opening a busy capture handle
|
|
// can transiently report a zero size and flap between teardown and
|
|
// restart. A camera that dies is detected by its own goroutine (read
|
|
// failure) and removed from the map, after which it is probed again.
|
|
indices, err := frame.ScanSkipExcluding(camMin, camMax, 3, inUse)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
// Stop tracked cameras that have disappeared. Cameras currently streaming
|
|
// are excluded from the scan, so treat them as still present; vanished
|
|
// ones leave the map themselves via the camera goroutine.
|
|
for idx, h := range cameras {
|
|
if _, busy := inUse[idx]; busy {
|
|
continue
|
|
}
|
|
if !contains(indices, idx) {
|
|
log.Printf("Camera %d: disconnected", idx)
|
|
h.cancel()
|
|
delete(cameras, idx)
|
|
}
|
|
}
|
|
|
|
// Start cameras that have appeared
|
|
for _, idx := range indices {
|
|
if _, exists := cameras[idx]; !exists {
|
|
camCtx, c := context.WithCancel(ctx)
|
|
h := &camHandle{ctx: camCtx, cancel: c}
|
|
cameras[idx] = h
|
|
wg.Add(1)
|
|
go func(i int, handle *camHandle) {
|
|
defer wg.Done()
|
|
log.Printf("Camera %d: connected", i)
|
|
runCamera(handle.ctx, i, mofinAddr, clipDur, username, cameraID, passphrase, videoFPS, videoWidth, videoHeight)
|
|
log.Printf("Camera %d: stopped", i)
|
|
mu.Lock()
|
|
if cameras[i] == handle {
|
|
delete(cameras, i)
|
|
}
|
|
mu.Unlock()
|
|
}(idx, h)
|
|
}
|
|
}
|
|
}
|
|
|
|
func contains(slice []int, val int) bool {
|
|
for _, v := range slice {
|
|
if v == val {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ThrottleFrames forwards the latest frame received from src to dst at a
|
|
// maximum rate of fps frames per second, so a slow reader only ever sees the
|
|
// newest frame. If the source closes, the function returns.
|
|
func ThrottleFrames(ctx context.Context, src <-chan frame.Frame, dst chan<- frame.Frame, fps int) {
|
|
interval := time.Second / time.Duration(fps)
|
|
if fps <= 0 {
|
|
interval = time.Second / 5
|
|
}
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
var (
|
|
lastFrame frame.Frame
|
|
haveFrame bool
|
|
frameMu sync.Mutex
|
|
)
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case f, ok := <-src:
|
|
if !ok {
|
|
return
|
|
}
|
|
frameMu.Lock()
|
|
lastFrame = f
|
|
haveFrame = true
|
|
frameMu.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
frameMu.Lock()
|
|
if !haveFrame {
|
|
frameMu.Unlock()
|
|
continue
|
|
}
|
|
f := lastFrame
|
|
frameMu.Unlock()
|
|
|
|
select {
|
|
case dst <- f:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func runCamera(ctx context.Context, camIndex int, mofinAddr string, clipDur time.Duration, username, cameraID, passphrase string, videoFPS, videoWidth, videoHeight int) {
|
|
frames := make(chan frame.Frame, 60)
|
|
|
|
if err := frame.Capture(ctx, camIndex, videoFPS, videoWidth, videoHeight, frames); err != nil {
|
|
log.Printf("Camera %d: %v", camIndex, err)
|
|
return
|
|
}
|
|
log.Printf("Camera %d: started", camIndex)
|
|
|
|
sourceData := fmt.Sprintf("%s--%s/%d", username, cameraID, camIndex)
|
|
|
|
// Throttle to the configured FPS, then build clips from the sampled frames.
|
|
// Clips are handed to a bounded outbound queue drained by a sender
|
|
// goroutine, so a slow or downed mofin never stalls the capture path —
|
|
// frames keep being sampled and the newest frames are preserved.
|
|
clipFrames := make(chan frame.Frame, 60)
|
|
var throttleWg sync.WaitGroup
|
|
throttleWg.Add(1)
|
|
go func() {
|
|
defer throttleWg.Done()
|
|
ThrottleFrames(ctx, frames, clipFrames, videoFPS)
|
|
}()
|
|
|
|
outQueue := make(chan frame.Clip, 4)
|
|
var senderWg sync.WaitGroup
|
|
senderWg.Add(1)
|
|
go func() {
|
|
defer senderWg.Done()
|
|
for clp := range outQueue {
|
|
if err := clp.SendClipRetry(mofinAddr, 6); err != nil {
|
|
log.Printf("Camera %d: send to mofin failed, clip dropped: %v", camIndex, err)
|
|
} else {
|
|
log.Printf("Camera %d: sent clip with %d frames to %s", camIndex, len(clp.PixelMats), mofinAddr)
|
|
}
|
|
}
|
|
}()
|
|
|
|
clipWg := sync.WaitGroup{}
|
|
clipWg.Add(1)
|
|
go func() {
|
|
defer clipWg.Done()
|
|
runClipBuilder(ctx, camIndex, clipDur, sourceData, passphrase, clipFrames, outQueue)
|
|
}()
|
|
|
|
clipWg.Wait()
|
|
close(outQueue)
|
|
senderWg.Wait()
|
|
throttleWg.Wait()
|
|
}
|
|
|
|
func runClipBuilder(ctx context.Context, camIndex int, clipDur time.Duration, sourceData, passphrase string, frames <-chan frame.Frame, outQueue chan<- frame.Clip) {
|
|
ticker := time.NewTicker(clipDur)
|
|
defer ticker.Stop()
|
|
|
|
var buf []frame.Frame
|
|
var pixelBuf [][]byte
|
|
var guidBuf [][]byte
|
|
var tsBuf []uint64
|
|
|
|
flush := func() {
|
|
if len(buf) == 0 {
|
|
return
|
|
}
|
|
|
|
clip := frame.Clip{
|
|
PixelMats: pixelBuf,
|
|
Guids: guidBuf,
|
|
Timestamps: tsBuf,
|
|
Width: buf[0].Width,
|
|
Height: buf[0].Height,
|
|
Types: buf[0].GocvImageType,
|
|
Channels: buf[0].Channels,
|
|
SourceData: sourceData,
|
|
}
|
|
|
|
if passphrase != "" {
|
|
if err := clip.Encrypt(passphrase); err != nil {
|
|
log.Printf("Camera %d: encrypt error: %v", camIndex, err)
|
|
}
|
|
}
|
|
|
|
buf = buf[:0]
|
|
pixelBuf = pixelBuf[:0]
|
|
guidBuf = guidBuf[:0]
|
|
tsBuf = tsBuf[:0]
|
|
|
|
if len(clip.PixelMats) == 0 {
|
|
return
|
|
}
|
|
|
|
// Non-blocking enqueue: the sender goroutine owns retries and network
|
|
// blocking. If the queue is full (mofin down for a while), drop this
|
|
// clip rather than stall capture and lose newest frames.
|
|
select {
|
|
case outQueue <- clip:
|
|
default:
|
|
log.Printf("Camera %d: outbound queue full, dropping clip (%d frames)", camIndex, len(clip.PixelMats))
|
|
// Flush in reverse on drop would hold newer frames back; the
|
|
// queue drains continuously and the next clip is a full window.
|
|
}
|
|
}
|
|
defer flush()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
flush()
|
|
return
|
|
case f := <-frames:
|
|
buf = append(buf, f)
|
|
pixelBuf = append(pixelBuf, f.PixelBytes)
|
|
guidBuf = append(guidBuf, f.Guid)
|
|
tsBuf = append(tsBuf, f.Timestamp)
|
|
case <-ticker.C:
|
|
flush()
|
|
}
|
|
}
|
|
} |