Files
oko_public/runCam/main.go
T
2026-09-09 21:44:05 -05:00

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()
}
}
}