feat(sense): complete T-006 five-stream integration
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listenAddress := flag.String("listen", "127.0.0.1:10554", "local listen address")
|
||||
upstreamAddress := flag.String("upstream", "", "upstream host:port")
|
||||
flag.Parse()
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
if *upstreamAddress == "" {
|
||||
logger.Error("upstream is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(*upstreamAddress); err != nil {
|
||||
logger.Error("upstream must be host:port")
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := serve(ctx, *listenAddress, *upstreamAddress); err != nil {
|
||||
logger.Error("RTSP fault proxy stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func serve(ctx context.Context, listenAddress, upstreamAddress string) error {
|
||||
listener, err := net.Listen("tcp", listenAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = listener.Close()
|
||||
}()
|
||||
var connections sync.WaitGroup
|
||||
defer connections.Wait()
|
||||
for {
|
||||
client, acceptErr := listener.Accept()
|
||||
if acceptErr != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("accept: %w", acceptErr)
|
||||
}
|
||||
connections.Add(1)
|
||||
go func() {
|
||||
defer connections.Done()
|
||||
proxy(client, upstreamAddress)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func proxy(client net.Conn, upstreamAddress string) {
|
||||
defer client.Close()
|
||||
upstream, err := net.DialTimeout("tcp", upstreamAddress, 5*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
done := make(chan struct{}, 2)
|
||||
copyOneWay := func(destination, source net.Conn) {
|
||||
_, _ = io.Copy(destination, source)
|
||||
done <- struct{}{}
|
||||
}
|
||||
go copyOneWay(upstream, client)
|
||||
go copyOneWay(client, upstream)
|
||||
<-done
|
||||
}
|
||||
@@ -48,9 +48,17 @@ func run(logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// T-003 deliberately has no real camera adapter. T-006 replaces this port
|
||||
// only after the device whitelist and five-camera evidence are available.
|
||||
reconciler := reconcile.New(repository, onvif.UnavailableAdapter{}, mediaClient)
|
||||
credentials := onvif.EnvCredentials{}
|
||||
var cameraAdapter onvif.Adapter = onvif.UnavailableAdapter{}
|
||||
if cfg.ONVIFMode == "standard" {
|
||||
cameraAdapter = onvif.NewHTTPAdapter(credentials, nil, onvif.HTTPOptions{
|
||||
RTSPRewriteHost: cfg.RTSPRewriteHost,
|
||||
RTSPRewritePort: cfg.RTSPRewritePort,
|
||||
StripRTSPQuery: cfg.RTSPStripQuery,
|
||||
})
|
||||
}
|
||||
discovery := onvif.NewRouter(cameraAdapter, credentials)
|
||||
reconciler := reconcile.New(repository, discovery, mediaClient)
|
||||
checker := probe.New(repository, mediaClient)
|
||||
report := func(err error) {
|
||||
// Domain and MediaMTX errors intentionally omit stream URIs and credentials.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Site manifestSite `json:"site"`
|
||||
Devices []manifestDevice `json:"devices"`
|
||||
}
|
||||
|
||||
type manifestSite struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MaxVideoChannels int `json:"max_video_channels"`
|
||||
}
|
||||
|
||||
type manifestDevice struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
SiteID string `json:"site_id"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Name string `json:"name"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
EndpointRef string `json:"endpoint_ref"`
|
||||
CredentialRef string `json:"credential_ref"`
|
||||
PathName string `json:"path_name"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string, output io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: sense-lab <seed|status>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "seed":
|
||||
return seed(args[1:], output)
|
||||
case "status":
|
||||
return status(args[1:], output)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func seed(args []string, output io.Writer) error {
|
||||
flags := flag.NewFlagSet("seed", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
dsn := flags.String("db", "", "SQLite DSN")
|
||||
manifestPath := flags.String("manifest", "", "manifest JSON path")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *dsn == "" || *manifestPath == "" {
|
||||
return errors.New("seed requires -db and -manifest")
|
||||
}
|
||||
file, err := os.Open(*manifestPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open manifest: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
var value manifest
|
||||
decoder := json.NewDecoder(io.LimitReader(file, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
repository, err := store.OpenSQLite(context.Background(), *dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer repository.Close()
|
||||
ctx := context.Background()
|
||||
if err := repository.EnsureSite(ctx, device.Site{
|
||||
TenantID: value.Site.TenantID, ID: value.Site.ID, Name: value.Site.Name,
|
||||
MaxVideoChannels: value.Site.MaxVideoChannels,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, input := range value.Devices {
|
||||
capabilities := make([]device.Capability, 0, len(input.Capabilities))
|
||||
for _, capability := range input.Capabilities {
|
||||
capabilities = append(capabilities, device.Capability(capability))
|
||||
}
|
||||
if err := repository.CreateDevice(ctx, device.Device{
|
||||
ID: input.ID, TenantID: input.TenantID, SiteID: input.SiteID,
|
||||
SerialNumber: input.SerialNumber, Name: input.Name, Modality: device.ModalityVideo,
|
||||
Capabilities: capabilities, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
EndpointRef: input.EndpointRef, CredentialRef: input.CredentialRef, PathName: input.PathName,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("create device %s: %w", input.ID, err)
|
||||
}
|
||||
}
|
||||
return json.NewEncoder(output).Encode(map[string]int{"seeded": len(value.Devices)})
|
||||
}
|
||||
|
||||
func status(args []string, output io.Writer) error {
|
||||
flags := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
dsn := flags.String("db", "", "SQLite DSN")
|
||||
expect := flags.Int("expect", -1, "expected device count")
|
||||
requireConverged := flags.Bool("require-converged", false, "fail when unconverged is non-zero")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *dsn == "" {
|
||||
return errors.New("status requires -db")
|
||||
}
|
||||
repository, err := store.OpenSQLite(context.Background(), *dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer repository.Close()
|
||||
snapshot, err := repository.ConvergenceSnapshot(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.NewEncoder(output).Encode(snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if *expect >= 0 && snapshot.Total != *expect {
|
||||
return fmt.Errorf("expected %d devices, got %d", *expect, snapshot.Total)
|
||||
}
|
||||
if *requireConverged && snapshot.Unconverged != 0 {
|
||||
return fmt.Errorf("unconverged devices: %d", snapshot.Unconverged)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user