feat(sense): complete T-006 five-stream integration
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-07 16:33:35 +08:00
parent 08692e33e9
commit 9567838045
19 changed files with 1658 additions and 14 deletions
+81
View File
@@ -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
}
+11 -3
View File
@@ -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.
+143
View File
@@ -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
}