[T-003] 建立 Sense M1 无实机接入骨架 #20
@@ -0,0 +1,4 @@
|
||||
/bin/
|
||||
/data/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Sense M1 骨架
|
||||
|
||||
本目录是 YoVision Sense 的无实机接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;当前只能用确定性 fake、MediaMTX 假 HTTP 服务及可选合成 RTSP 源验证,不能据此宣称任何真实摄像头兼容性。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```powershell
|
||||
cd Sense
|
||||
go mod download
|
||||
go generate ./internal/mtx
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build -o bin/sense-api.exe ./cmd/sense-api
|
||||
go run ./cmd/sense-api
|
||||
```
|
||||
|
||||
Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。当前只有 `/healthz` 与 `/readyz`,设备管理公共 API 尚未冻结。
|
||||
|
||||
常用环境变量:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `SENSE_HTTP_ADDR` | `127.0.0.1:8080` | HTTP 监听地址 |
|
||||
| `SENSE_ALLOW_NON_LOOPBACK` | `false` | 显式允许监听非回环地址;只应在可信网络及外部认证/防火墙就绪后开启 |
|
||||
| `SENSE_DB_DSN` | `file:data/sense.db` | SQLite DSN;凭据不得放入该值 |
|
||||
| `SENSE_MEDIAMTX_URL` | `http://127.0.0.1:9997` | MediaMTX 控制 API;不得包含 userinfo |
|
||||
| `SENSE_RECONCILE_INTERVAL` | `5s` | 对账周期 |
|
||||
| `SENSE_PROBE_INTERVAL` | `10s` | path 探活周期 |
|
||||
|
||||
MediaMTX `v1.19.3` 应作为独立二进制启动并只在可信网络开放 API。获取与 SHA-256 校验值见 `docs/03-tech-stack.md`。生成客户端使用固定版本工具和 vendored 官方 OpenAPI;`internal/mtx/generated/client.gen.go` 不可手改。
|
||||
|
||||
Windows 本地准备 MediaMTX(从仓库根目录执行):
|
||||
|
||||
```powershell
|
||||
$asset = "mediamtx_v1.19.3_windows_amd64.zip"
|
||||
Invoke-WebRequest "https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/$asset" -OutFile "$env:TEMP\$asset"
|
||||
if ((Get-FileHash "$env:TEMP\$asset" -Algorithm SHA256).Hash.ToLowerInvariant() -ne "5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe") { throw "MediaMTX checksum mismatch" }
|
||||
Expand-Archive "$env:TEMP\$asset" -DestinationPath "$env:TEMP\yovision-mediamtx-v1.19.3" -Force
|
||||
& "$env:TEMP\yovision-mediamtx-v1.19.3\mediamtx.exe" "Sense\deploy\mediamtx.yml"
|
||||
```
|
||||
|
||||
Linux amd64 使用同版 `mediamtx_v1.19.3_linux_amd64.tar.gz`,SHA-256 为 `a7ba21268fccda3ebc43fdad76b87fddb85ce77e725b5cb637bca724b5394fbe`。不要把下载的二进制或摄像头凭据提交到仓库。
|
||||
+3766
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/config"
|
||||
"yovision/sense/internal/mtx"
|
||||
"yovision/sense/internal/onvif"
|
||||
"yovision/sense/internal/probe"
|
||||
"yovision/sense/internal/reconcile"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
if err := run(logger); err != nil {
|
||||
logger.Error("Sense stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
repository, err := store.OpenSQLite(ctx, cfg.DatabaseDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer repository.Close()
|
||||
mediaClient, err := mtx.NewClient(cfg.MediaMTXURL, nil)
|
||||
if err != nil {
|
||||
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)
|
||||
checker := probe.New(repository, mediaClient)
|
||||
report := func(err error) {
|
||||
// Domain and MediaMTX errors intentionally omit stream URIs and credentials.
|
||||
logger.Warn("background convergence error", "error", err)
|
||||
}
|
||||
var background sync.WaitGroup
|
||||
background.Add(2)
|
||||
go func() {
|
||||
defer background.Done()
|
||||
reconciler.Run(ctx, cfg.ReconcileInterval, report)
|
||||
}()
|
||||
go func() {
|
||||
defer background.Done()
|
||||
checker.Run(ctx, cfg.ProbeInterval, report)
|
||||
}()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = writer.Write([]byte(`{"status":"ready"}`))
|
||||
})
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddress, Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version)
|
||||
serverErrors <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case serverErr := <-serverErrors:
|
||||
if !errors.Is(serverErr, http.ErrServerClosed) {
|
||||
stop()
|
||||
background.Wait()
|
||||
return fmt.Errorf("serve HTTP: %w", serverErr)
|
||||
}
|
||||
}
|
||||
stop()
|
||||
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
shutdownErr := server.Shutdown(shutdownContext)
|
||||
background.Wait()
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("shutdown HTTP server: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# MediaMTX v1.19.3 minimal control-plane configuration for local T-003 work.
|
||||
# Keep the API on loopback. Production authentication/network policy is a
|
||||
# later deployment concern and must be in place before any non-loopback bind.
|
||||
logLevel: info
|
||||
api: true
|
||||
apiAddress: 127.0.0.1:9997
|
||||
metrics: true
|
||||
metricsAddress: 127.0.0.1:9998
|
||||
paths: {}
|
||||
@@ -0,0 +1,42 @@
|
||||
module yovision/sense
|
||||
|
||||
go 1.26.0
|
||||
|
||||
toolchain go1.26.5
|
||||
|
||||
require (
|
||||
github.com/oapi-codegen/runtime v1.6.0
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/getkin/kin-openapi v0.142.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect
|
||||
github.com/oasdiff/yaml v0.1.1 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.14 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
|
||||
github.com/speakeasy-api/openapi v1.24.0 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w=
|
||||
github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
|
||||
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
|
||||
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
|
||||
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
|
||||
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
|
||||
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=
|
||||
github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=
|
||||
github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
|
||||
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
|
||||
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
|
||||
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
|
||||
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
|
||||
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
|
||||
github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04=
|
||||
github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,113 @@
|
||||
// Package config loads and validates the Sense process configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPAddress = "127.0.0.1:8080"
|
||||
defaultDatabaseDSN = "file:data/sense.db"
|
||||
defaultMediaMTXURL = "http://127.0.0.1:9997"
|
||||
defaultReconcilePeriod = 5 * time.Second
|
||||
defaultProbePeriod = 10 * time.Second
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddress string
|
||||
AllowNonLoopback bool
|
||||
DatabaseDSN string
|
||||
MediaMTXURL string
|
||||
ReconcileInterval time.Duration
|
||||
ProbeInterval time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
allow, err := boolEnv("SENSE_ALLOW_NON_LOOPBACK", false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
reconcilePeriod, err := durationEnv("SENSE_RECONCILE_INTERVAL", defaultReconcilePeriod)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
probePeriod, err := durationEnv("SENSE_PROBE_INTERVAL", defaultProbePeriod)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress),
|
||||
AllowNonLoopback: allow,
|
||||
DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN),
|
||||
MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL),
|
||||
ReconcileInterval: reconcilePeriod,
|
||||
ProbeInterval: probePeriod,
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
host, _, err := net.SplitHostPort(c.HTTPAddress)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid SENSE_HTTP_ADDR: %w", err)
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
isLoopback := host == "localhost" || (ip != nil && ip.IsLoopback())
|
||||
if !isLoopback && !c.AllowNonLoopback {
|
||||
return fmt.Errorf("non-loopback HTTP bind requires SENSE_ALLOW_NON_LOOPBACK=true")
|
||||
}
|
||||
if c.DatabaseDSN == "" {
|
||||
return fmt.Errorf("SENSE_DB_DSN must not be empty")
|
||||
}
|
||||
mediaURL, err := url.Parse(c.MediaMTXURL)
|
||||
if err != nil || mediaURL.Scheme == "" || mediaURL.Host == "" {
|
||||
return fmt.Errorf("invalid SENSE_MEDIAMTX_URL")
|
||||
}
|
||||
if mediaURL.User != nil {
|
||||
return fmt.Errorf("SENSE_MEDIAMTX_URL must not contain credentials")
|
||||
}
|
||||
if c.ReconcileInterval <= 0 || c.ProbeInterval <= 0 {
|
||||
return fmt.Errorf("loop intervals must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringEnv(name, fallback string) string {
|
||||
if value, ok := os.LookupEnv(name); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolEnv(name string, fallback bool) (bool, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid %s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateRejectsNonLoopbackByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{
|
||||
HTTPAddress: "0.0.0.0:8080",
|
||||
DatabaseDSN: "file:test.db",
|
||||
MediaMTXURL: "http://127.0.0.1:9997",
|
||||
ReconcileInterval: 1,
|
||||
ProbeInterval: 1,
|
||||
}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("expected non-loopback bind to be rejected")
|
||||
}
|
||||
cfg.AllowNonLoopback = true
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("explicit non-loopback opt-in failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsCredentialsInMediaMTXURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{
|
||||
HTTPAddress: "127.0.0.1:8080",
|
||||
DatabaseDSN: "file:test.db",
|
||||
MediaMTXURL: "http://" + "user" + ":" + "redacted" + "@127.0.0.1:9997",
|
||||
ReconcileInterval: 1,
|
||||
ProbeInterval: 1,
|
||||
}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("expected credentials in MediaMTX URL to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package device contains the Sense device-ledger domain model.
|
||||
package device
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultVideoChannels = 16
|
||||
MaximumVideoChannels = 128
|
||||
)
|
||||
|
||||
type Modality string
|
||||
|
||||
const (
|
||||
ModalityVideo Modality = "video"
|
||||
ModalityRadar Modality = "radar"
|
||||
ModalityContact Modality = "contact"
|
||||
ModalityButton Modality = "button"
|
||||
ModalityWearable Modality = "wearable"
|
||||
ModalityOther Modality = "other"
|
||||
)
|
||||
|
||||
type Capability string
|
||||
|
||||
const (
|
||||
CapabilityVideoCapture Capability = "video_capture"
|
||||
CapabilityAudioCapture Capability = "audio_capture"
|
||||
CapabilitySpatialRule Capability = "spatial_rule"
|
||||
CapabilityTelemetry Capability = "telemetry"
|
||||
)
|
||||
|
||||
type DesiredState string
|
||||
|
||||
const (
|
||||
DesiredDisabled DesiredState = "disabled"
|
||||
DesiredEnabled DesiredState = "enabled"
|
||||
)
|
||||
|
||||
type ActualState string
|
||||
|
||||
const (
|
||||
ActualPending ActualState = "pending"
|
||||
ActualOnline ActualState = "online"
|
||||
ActualOffline ActualState = "offline"
|
||||
ActualFailed ActualState = "failed"
|
||||
)
|
||||
|
||||
type Site struct {
|
||||
TenantID string
|
||||
ID string
|
||||
Name string
|
||||
MaxVideoChannels int
|
||||
}
|
||||
|
||||
func (s *Site) ApplyDefaults() {
|
||||
if s.MaxVideoChannels == 0 {
|
||||
s.MaxVideoChannels = DefaultVideoChannels
|
||||
}
|
||||
}
|
||||
|
||||
func (s Site) Validate() error {
|
||||
if strings.TrimSpace(s.TenantID) == "" || strings.TrimSpace(s.ID) == "" {
|
||||
return errors.New("tenant ID and site ID are required")
|
||||
}
|
||||
if s.MaxVideoChannels < 1 || s.MaxVideoChannels > MaximumVideoChannels {
|
||||
return fmt.Errorf("max video channels must be between 1 and %d", MaximumVideoChannels)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string
|
||||
TenantID string
|
||||
SiteID string
|
||||
SerialNumber string
|
||||
Name string
|
||||
Modality Modality
|
||||
Capabilities []Capability
|
||||
DesiredState DesiredState
|
||||
ActualState ActualState
|
||||
EndpointRef string
|
||||
CredentialRef string
|
||||
PathName string
|
||||
Generation int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (d Device) Validate() error {
|
||||
if strings.TrimSpace(d.ID) == "" || strings.TrimSpace(d.TenantID) == "" || strings.TrimSpace(d.SiteID) == "" {
|
||||
return errors.New("device ID, tenant ID and site ID are required")
|
||||
}
|
||||
if strings.TrimSpace(d.SerialNumber) == "" || strings.TrimSpace(d.Name) == "" {
|
||||
return errors.New("serial number and device name are required")
|
||||
}
|
||||
if !validModality(d.Modality) {
|
||||
return fmt.Errorf("unsupported modality %q", d.Modality)
|
||||
}
|
||||
if d.DesiredState != DesiredEnabled && d.DesiredState != DesiredDisabled {
|
||||
return fmt.Errorf("unsupported desired state %q", d.DesiredState)
|
||||
}
|
||||
if d.ActualState != ActualPending && d.ActualState != ActualOnline && d.ActualState != ActualOffline && d.ActualState != ActualFailed {
|
||||
return fmt.Errorf("unsupported actual state %q", d.ActualState)
|
||||
}
|
||||
if d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture) {
|
||||
if strings.TrimSpace(d.EndpointRef) == "" || strings.TrimSpace(d.PathName) == "" {
|
||||
return errors.New("enabled video devices require endpoint ref and path name")
|
||||
}
|
||||
}
|
||||
if d.EndpointRef != "" {
|
||||
endpoint, err := url.Parse(d.EndpointRef)
|
||||
if err != nil || endpoint.Scheme == "" {
|
||||
return errors.New("endpoint ref must be an absolute URI")
|
||||
}
|
||||
if endpoint.User != nil {
|
||||
return errors.New("endpoint ref must not contain credentials")
|
||||
}
|
||||
}
|
||||
if d.PathName != "" && !validPathName(d.PathName) {
|
||||
return errors.New("path name must contain safe ASCII segments")
|
||||
}
|
||||
seen := make(map[Capability]struct{}, len(d.Capabilities))
|
||||
for _, capability := range d.Capabilities {
|
||||
if !validCapability(capability) {
|
||||
return fmt.Errorf("unsupported capability %q", capability)
|
||||
}
|
||||
if _, ok := seen[capability]; ok {
|
||||
return fmt.Errorf("duplicate capability %q", capability)
|
||||
}
|
||||
seen[capability] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d Device) HasCapability(capability Capability) bool {
|
||||
return slices.Contains(d.Capabilities, capability)
|
||||
}
|
||||
|
||||
func (d Device) ConsumesVideoChannel() bool {
|
||||
return d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture)
|
||||
}
|
||||
|
||||
func validModality(value Modality) bool {
|
||||
return slices.Contains([]Modality{ModalityVideo, ModalityRadar, ModalityContact, ModalityButton, ModalityWearable, ModalityOther}, value)
|
||||
}
|
||||
|
||||
func validCapability(value Capability) bool {
|
||||
return slices.Contains([]Capability{CapabilityVideoCapture, CapabilityAudioCapture, CapabilitySpatialRule, CapabilityTelemetry}, value)
|
||||
}
|
||||
|
||||
func validPathName(value string) bool {
|
||||
if strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || strings.Contains(value, "//") || strings.Contains(value, "..") {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
|
||||
(character >= '0' && character <= '9') || strings.ContainsRune("-_/.", character) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
type QuotaExceededError struct {
|
||||
TenantID string
|
||||
SiteID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (e *QuotaExceededError) Error() string {
|
||||
return fmt.Sprintf("video channel quota exceeded for site %s/%s (limit %d)", e.TenantID, e.SiteID, e.Limit)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package device
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeviceRejectsCredentialsInEndpointReference(t *testing.T) {
|
||||
t.Parallel()
|
||||
value := validVideoDevice()
|
||||
value.EndpointRef = "http://" + "user" + ":" + "redacted" + "@camera.invalid/onvif"
|
||||
if err := value.Validate(); err == nil {
|
||||
t.Fatal("expected endpoint credentials to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceRejectsUnsafeMediaPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
value := validVideoDevice()
|
||||
value.PathName = "tenant/../another-camera"
|
||||
if err := value.Validate(); err == nil {
|
||||
t.Fatal("expected unsafe path name to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func validVideoDevice() Device {
|
||||
return Device{
|
||||
ID: "camera", TenantID: "tenant", SiteID: "site", SerialNumber: "serial", Name: "Camera",
|
||||
Modality: ModalityVideo, Capabilities: []Capability{CapabilityVideoCapture},
|
||||
DesiredState: DesiredEnabled, ActualState: ActualPending,
|
||||
EndpointRef: "onvif://camera", PathName: "sense/tenant/site/camera",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package mtx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
mediamtxapi "yovision/sense/internal/mtx/generated"
|
||||
)
|
||||
|
||||
var ErrPathNotFound = errors.New("MediaMTX path not found")
|
||||
|
||||
type APIError struct {
|
||||
Operation string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("MediaMTX %s failed with HTTP status %d", e.Operation, e.StatusCode)
|
||||
}
|
||||
|
||||
type PathConfig struct {
|
||||
Name string
|
||||
Source string
|
||||
}
|
||||
|
||||
type pathAPI interface {
|
||||
ConfigPathsAddWithResponse(context.Context, string, mediamtxapi.ConfigPathsAddJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsAddResponse, error)
|
||||
ConfigPathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsGetResponse, error)
|
||||
ConfigPathsPatchWithResponse(context.Context, string, mediamtxapi.ConfigPathsPatchJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsPatchResponse, error)
|
||||
ConfigPathsDeleteWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsDeleteResponse, error)
|
||||
PathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.PathsGetResponse, error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
api pathAPI
|
||||
}
|
||||
|
||||
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
|
||||
options := make([]mediamtxapi.ClientOption, 0, 1)
|
||||
if httpClient != nil {
|
||||
options = append(options, mediamtxapi.WithHTTPClient(httpClient))
|
||||
}
|
||||
generated, err := mediamtxapi.NewClientWithResponses(strings.TrimRight(baseURL, "/"), options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create MediaMTX client: %w", err)
|
||||
}
|
||||
return &Client{api: generated}, nil
|
||||
}
|
||||
|
||||
func newClientWithAPI(api pathAPI) *Client {
|
||||
return &Client{api: api}
|
||||
}
|
||||
|
||||
func (c *Client) CreatePath(ctx context.Context, name, source string) error {
|
||||
response, err := c.api.ConfigPathsAddWithResponse(ctx, name, mediamtxapi.PathConf{Source: &source})
|
||||
if err != nil {
|
||||
return fmt.Errorf("MediaMTX create path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() != http.StatusOK {
|
||||
return &APIError{Operation: "create path", StatusCode: response.StatusCode()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetPath(ctx context.Context, name string) (PathConfig, error) {
|
||||
response, err := c.api.ConfigPathsGetWithResponse(ctx, name)
|
||||
if err != nil {
|
||||
return PathConfig{}, fmt.Errorf("MediaMTX read path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() == http.StatusNotFound {
|
||||
return PathConfig{}, ErrPathNotFound
|
||||
}
|
||||
if response.StatusCode() != http.StatusOK || response.JSON200 == nil {
|
||||
return PathConfig{}, &APIError{Operation: "read path", StatusCode: response.StatusCode()}
|
||||
}
|
||||
result := PathConfig{Name: name}
|
||||
if response.JSON200.Name != nil {
|
||||
result.Name = *response.JSON200.Name
|
||||
}
|
||||
if response.JSON200.Source != nil {
|
||||
result.Source = *response.JSON200.Source
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeletePath(ctx context.Context, name string) error {
|
||||
response, err := c.api.ConfigPathsDeleteWithResponse(ctx, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MediaMTX delete path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() == http.StatusNotFound {
|
||||
return ErrPathNotFound
|
||||
}
|
||||
if response.StatusCode() != http.StatusOK {
|
||||
return &APIError{Operation: "delete path", StatusCode: response.StatusCode()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsurePath converges one desired path. It never enumerates or deletes orphans.
|
||||
func (c *Client) EnsurePath(ctx context.Context, name, source string) (bool, error) {
|
||||
current, err := c.GetPath(ctx, name)
|
||||
if errors.Is(err, ErrPathNotFound) {
|
||||
if err := c.CreatePath(ctx, name, source); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if current.Source == source {
|
||||
return false, nil
|
||||
}
|
||||
response, err := c.api.ConfigPathsPatchWithResponse(ctx, name, mediamtxapi.PathConf{Source: &source})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("MediaMTX patch path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() != http.StatusOK {
|
||||
return false, &APIError{Operation: "patch path", StatusCode: response.StatusCode()}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Client) PathReady(ctx context.Context, name string) (bool, error) {
|
||||
response, err := c.api.PathsGetWithResponse(ctx, name)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("MediaMTX probe path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() == http.StatusNotFound {
|
||||
return false, ErrPathNotFound
|
||||
}
|
||||
if response.StatusCode() != http.StatusOK || response.JSON200 == nil {
|
||||
return false, &APIError{Operation: "probe path", StatusCode: response.StatusCode()}
|
||||
}
|
||||
if response.JSON200.Online == nil || response.JSON200.Available == nil {
|
||||
return false, &APIError{Operation: "probe path response", StatusCode: response.StatusCode()}
|
||||
}
|
||||
return *response.JSON200.Online && *response.JSON200.Available, nil
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package mtx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeMediaMTX struct {
|
||||
mu sync.Mutex
|
||||
paths map[string]string
|
||||
mutations int
|
||||
}
|
||||
|
||||
func (f *fakeMediaMTX) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
prefixes := map[string]string{
|
||||
"/v3/config/paths/get/": "get",
|
||||
"/v3/config/paths/add/": "add",
|
||||
"/v3/config/paths/patch/": "patch",
|
||||
"/v3/config/paths/delete/": "delete",
|
||||
"/v3/paths/get/": "runtime",
|
||||
}
|
||||
for prefix, operation := range prefixes {
|
||||
if !strings.HasPrefix(request.URL.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(request.URL.Path, prefix)
|
||||
source, exists := f.paths[name]
|
||||
switch operation {
|
||||
case "get":
|
||||
if !exists {
|
||||
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(writer).Encode(map[string]any{"name": name, "source": source})
|
||||
case "add", "patch":
|
||||
var body struct {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
http.Error(writer, `{}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
f.paths[name] = body.Source
|
||||
f.mutations++
|
||||
_, _ = writer.Write([]byte(`{}`))
|
||||
case "delete":
|
||||
if !exists {
|
||||
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
delete(f.paths, name)
|
||||
f.mutations++
|
||||
_, _ = writer.Write([]byte(`{}`))
|
||||
case "runtime":
|
||||
if !exists {
|
||||
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
_, _ = writer.Write([]byte(`{"online":true,"available":true}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
http.NotFound(writer, request)
|
||||
}
|
||||
|
||||
func TestGeneratedClientCreateReadDeleteMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeMediaMTX{paths: make(map[string]string)}
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := client.CreatePath(ctx, "camera-1", "rtsp://media.invalid/camera-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, err := client.GetPath(ctx, "camera-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path.Source != "rtsp://media.invalid/camera-1" {
|
||||
t.Fatalf("unexpected source mapping: %+v", path)
|
||||
}
|
||||
if err := client.DeletePath(ctx, "camera-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.GetPath(ctx, "camera-1"); err != ErrPathNotFound {
|
||||
t.Fatalf("expected not found after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsurePathIsIdempotentAndCanPatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeMediaMTX{paths: make(map[string]string)}
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
changed, err := client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/first")
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("first ensure: changed=%v err=%v", changed, err)
|
||||
}
|
||||
changed, err = client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/first")
|
||||
if err != nil || changed {
|
||||
t.Fatalf("second ensure must be idempotent: changed=%v err=%v", changed, err)
|
||||
}
|
||||
changed, err = client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/second")
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("changed source must patch: changed=%v err=%v", changed, err)
|
||||
}
|
||||
if fake.mutations != 2 {
|
||||
t.Fatalf("expected create + patch, got %d mutations", fake.mutations)
|
||||
}
|
||||
ready, err := client.PathReady(ctx, "camera-2")
|
||||
if err != nil || !ready {
|
||||
t.Fatalf("runtime probe: ready=%v err=%v", ready, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIErrorDoesNotLeakSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = fmt.Fprint(writer, `{"error":"upstream included a secret"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secretSource := "rtsp://" + "user" + ":" + "redacted" + "@camera.invalid/live"
|
||||
err = client.CreatePath(context.Background(), "camera", secretSource)
|
||||
if err == nil || strings.Contains(err.Error(), secretSource) || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatalf("error must be redacted, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package mtx wraps the generated MediaMTX control API client.
|
||||
package mtx
|
||||
|
||||
// The input is the official API document vendored from the frozen MediaMTX tag.
|
||||
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../api/vendor/mediamtx-v1.19.3.openapi.yaml
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
package: mediamtxapi
|
||||
output: generated/client.gen.go
|
||||
generate:
|
||||
models: true
|
||||
client: true
|
||||
output-options:
|
||||
skip-prune: false
|
||||
@@ -0,0 +1,126 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FakeScenario struct {
|
||||
Result ProbeResult `json:"result"`
|
||||
ProbeError ErrorCode `json:"probe_error,omitempty"`
|
||||
ClockError ErrorCode `json:"clock_error,omitempty"`
|
||||
DelayMillis int `json:"delay_millis,omitempty"`
|
||||
}
|
||||
|
||||
type fakeFixture struct {
|
||||
Scenarios map[string]FakeScenario `json:"scenarios"`
|
||||
}
|
||||
|
||||
// Fake is deterministic and intended only for tests and offline development.
|
||||
type Fake struct {
|
||||
mu sync.Mutex
|
||||
scenarios map[string]FakeScenario
|
||||
probeCalls map[string]int
|
||||
clockSyncCalls map[string]int
|
||||
}
|
||||
|
||||
func NewFake(scenarios map[string]FakeScenario) *Fake {
|
||||
copyOfScenarios := make(map[string]FakeScenario, len(scenarios))
|
||||
for key, value := range scenarios {
|
||||
copyOfScenarios[key] = value
|
||||
}
|
||||
return &Fake{
|
||||
scenarios: copyOfScenarios, probeCalls: make(map[string]int), clockSyncCalls: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func LoadFakeFixture(path string) (*Fake, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ONVIF fixture: %w", err)
|
||||
}
|
||||
var fixture fakeFixture
|
||||
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||
return nil, fmt.Errorf("decode ONVIF fixture: %w", err)
|
||||
}
|
||||
return NewFake(fixture.Scenarios), nil
|
||||
}
|
||||
|
||||
func (f *Fake) Probe(ctx context.Context, target Target) (ProbeResult, error) {
|
||||
scenario, ok := f.scenario(target.EndpointRef)
|
||||
if !ok {
|
||||
return ProbeResult{}, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("fixture endpoint is not configured")}
|
||||
}
|
||||
if err := waitForFakeDelay(ctx, scenario.DelayMillis); err != nil {
|
||||
return ProbeResult{}, &Error{Code: ErrorTimeout, Err: err}
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.probeCalls[target.EndpointRef]++
|
||||
f.mu.Unlock()
|
||||
if scenario.ProbeError != "" {
|
||||
return ProbeResult{}, &Error{Code: scenario.ProbeError, Err: fmt.Errorf("fixture probe failure")}
|
||||
}
|
||||
if scenario.Result.StreamURI == "" || len(scenario.Result.Profiles) == 0 {
|
||||
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("fixture lacks profile or stream URI")}
|
||||
}
|
||||
return scenario.Result, nil
|
||||
}
|
||||
|
||||
func (f *Fake) SetSystemDateAndTime(ctx context.Context, target Target, _ time.Time) error {
|
||||
scenario, ok := f.scenario(target.EndpointRef)
|
||||
if !ok {
|
||||
return &Error{Code: ErrorUnavailable, Err: fmt.Errorf("fixture endpoint is not configured")}
|
||||
}
|
||||
if err := waitForFakeDelay(ctx, scenario.DelayMillis); err != nil {
|
||||
return &Error{Code: ErrorTimeout, Err: err}
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.clockSyncCalls[target.EndpointRef]++
|
||||
f.mu.Unlock()
|
||||
if scenario.ClockError != "" {
|
||||
return &Error{Code: scenario.ClockError, Err: fmt.Errorf("fixture clock failure")}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fake) ProbeCalls(endpointRef string) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.probeCalls[endpointRef]
|
||||
}
|
||||
|
||||
func (f *Fake) ClockSyncCalls(endpointRef string) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.clockSyncCalls[endpointRef]
|
||||
}
|
||||
|
||||
func (f *Fake) scenario(endpointRef string) (FakeScenario, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
scenario, ok := f.scenarios[endpointRef]
|
||||
return scenario, ok
|
||||
}
|
||||
|
||||
func waitForFakeDelay(ctx context.Context, milliseconds int) error {
|
||||
if milliseconds <= 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(milliseconds) * time.Millisecond)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFakeMapsProfilesStreamAndClockSync(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := Target{EndpointRef: "onvif://camera-ok", CredentialRef: "secret://camera-ok"}
|
||||
result, err := fake.Probe(context.Background(), target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Profiles) != 2 || result.StreamURI != "rtsp://media.invalid/camera-ok" {
|
||||
t.Fatalf("unexpected fixture mapping: %+v", result)
|
||||
}
|
||||
if err := fake.SetSystemDateAndTime(context.Background(), target, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.ProbeCalls(target.EndpointRef) != 1 || fake.ClockSyncCalls(target.EndpointRef) != 1 {
|
||||
t.Fatal("expected one probe and one clock-sync call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeMapsAuthenticationFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = fake.Probe(context.Background(), Target{EndpointRef: "onvif://camera-auth"})
|
||||
if CodeOf(err) != ErrorAuthentication {
|
||||
t.Fatalf("expected authentication error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeHonorsCancellationAsTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err = fake.Probe(ctx, Target{EndpointRef: "onvif://camera-slow"})
|
||||
if CodeOf(err) != ErrorTimeout {
|
||||
t.Fatalf("expected timeout error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Package onvif defines the ONVIF boundary used by Sense.
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Target struct {
|
||||
// EndpointRef identifies a device endpoint but must not contain credentials.
|
||||
EndpointRef string
|
||||
// CredentialRef is an opaque secret-store reference, never a password.
|
||||
CredentialRef string
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
VideoEncoder bool `json:"video_encoder"`
|
||||
}
|
||||
|
||||
type ProbeResult struct {
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Profiles []Profile `json:"profiles"`
|
||||
StreamURI string `json:"stream_uri"`
|
||||
}
|
||||
|
||||
type Adapter interface {
|
||||
Probe(ctx context.Context, target Target) (ProbeResult, error)
|
||||
SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error
|
||||
}
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrorAuthentication ErrorCode = "authentication_failed"
|
||||
ErrorTimeout ErrorCode = "timeout"
|
||||
ErrorUnavailable ErrorCode = "unavailable"
|
||||
ErrorInvalidReply ErrorCode = "invalid_response"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
Code ErrorCode
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Err == nil {
|
||||
return string(e.Code)
|
||||
}
|
||||
return fmt.Sprintf("%s: %v", e.Code, e.Err)
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error { return e.Err }
|
||||
|
||||
func CodeOf(err error) ErrorCode {
|
||||
var onvifError *Error
|
||||
if errors.As(err, &onvifError) {
|
||||
return onvifError.Code
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return ErrorTimeout
|
||||
}
|
||||
return ErrorUnavailable
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"scenarios": {
|
||||
"onvif://camera-ok": {
|
||||
"result": {
|
||||
"manufacturer": "YoVision Fixture",
|
||||
"model": "Offline Camera",
|
||||
"firmware_version": "0.0-fixture",
|
||||
"serial_number": "REDACTED-001",
|
||||
"profiles": [
|
||||
{"token": "main", "name": "Main stream", "video_encoder": true},
|
||||
{"token": "sub", "name": "Sub stream", "video_encoder": true}
|
||||
],
|
||||
"stream_uri": "rtsp://media.invalid/camera-ok"
|
||||
}
|
||||
},
|
||||
"onvif://camera-auth": {
|
||||
"probe_error": "authentication_failed",
|
||||
"result": {}
|
||||
},
|
||||
"onvif://camera-slow": {
|
||||
"delay_millis": 100,
|
||||
"result": {
|
||||
"profiles": [{"token": "main", "name": "Main stream", "video_encoder": true}],
|
||||
"stream_uri": "rtsp://media.invalid/camera-slow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UnavailableAdapter keeps the process boundary explicit until T-006 supplies
|
||||
// a real, whitelist-validated ONVIF adapter. It must not be mistaken for a
|
||||
// compatibility implementation.
|
||||
type UnavailableAdapter struct{}
|
||||
|
||||
func (UnavailableAdapter) Probe(context.Context, Target) (ProbeResult, error) {
|
||||
return ProbeResult{}, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("real ONVIF adapter is not configured")}
|
||||
}
|
||||
|
||||
func (UnavailableAdapter) SetSystemDateAndTime(context.Context, Target, time.Time) error {
|
||||
return &Error{Code: ErrorUnavailable, Err: fmt.Errorf("real ONVIF adapter is not configured")}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package probe maps MediaMTX runtime path health into the Sense actual state.
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
const defaultBatchSize = 128
|
||||
|
||||
type Repository interface {
|
||||
ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error)
|
||||
UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error
|
||||
}
|
||||
|
||||
type RuntimePaths interface {
|
||||
PathReady(ctx context.Context, name string) (bool, error)
|
||||
}
|
||||
|
||||
type Checker struct {
|
||||
repository Repository
|
||||
media RuntimePaths
|
||||
now func() time.Time
|
||||
batchSize int
|
||||
}
|
||||
|
||||
func New(repository Repository, media RuntimePaths) *Checker {
|
||||
return &Checker{repository: repository, media: media, now: time.Now, batchSize: defaultBatchSize}
|
||||
}
|
||||
|
||||
func (c *Checker) RunOnce(ctx context.Context) error {
|
||||
devices, err := c.repository.ListEnabledVideoDevices(ctx, c.batchSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list probe candidates: %w", err)
|
||||
}
|
||||
var runErrors []error
|
||||
for _, value := range devices {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
ready, probeErr := c.media.PathReady(ctx, value.PathName)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
state := device.ActualOffline
|
||||
if probeErr == nil && ready {
|
||||
state = device.ActualOnline
|
||||
}
|
||||
if updateErr := c.repository.UpdateActualState(ctx, value.ID, state, c.now().UTC()); updateErr != nil {
|
||||
runErrors = append(runErrors, fmt.Errorf("update device %s health: %w", value.ID, updateErr))
|
||||
}
|
||||
if probeErr != nil {
|
||||
runErrors = append(runErrors, fmt.Errorf("probe device %s: %w", value.ID, probeErr))
|
||||
}
|
||||
}
|
||||
return errors.Join(runErrors...)
|
||||
}
|
||||
|
||||
func (c *Checker) Run(ctx context.Context, interval time.Duration, report func(error)) {
|
||||
if err := c.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||
report(err)
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := c.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||
report(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
devices []device.Device
|
||||
states map[string]device.ActualState
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ListEnabledVideoDevices(context.Context, int) ([]device.Device, error) {
|
||||
return f.devices, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) UpdateActualState(_ context.Context, id string, state device.ActualState, _ time.Time) error {
|
||||
if f.states == nil {
|
||||
f.states = make(map[string]device.ActualState)
|
||||
}
|
||||
f.states[id] = state
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeRuntime struct {
|
||||
ready map[string]bool
|
||||
errors map[string]error
|
||||
}
|
||||
|
||||
func (f fakeRuntime) PathReady(_ context.Context, name string) (bool, error) {
|
||||
return f.ready[name], f.errors[name]
|
||||
}
|
||||
|
||||
func TestCheckerMapsReadyAndUnavailablePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
repository := &fakeRepository{devices: []device.Device{
|
||||
{ID: "online", PathName: "online"}, {ID: "offline", PathName: "offline"},
|
||||
}}
|
||||
checker := New(repository, fakeRuntime{
|
||||
ready: map[string]bool{"online": true}, errors: map[string]error{"offline": errors.New("unavailable")},
|
||||
})
|
||||
err := checker.RunOnce(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("probe transport error must remain observable")
|
||||
}
|
||||
if repository.states["online"] != device.ActualOnline || repository.states["offline"] != device.ActualOffline {
|
||||
t.Fatalf("unexpected actual states: %+v", repository.states)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Package reconcile converges MediaMTX paths from the database desired state.
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/onvif"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
const defaultBatchSize = 128
|
||||
|
||||
type Repository interface {
|
||||
ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]store.ReconcileCandidate, error)
|
||||
MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error
|
||||
MarkReconcileFailure(ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode string, now time.Time) error
|
||||
}
|
||||
|
||||
type MediaPaths interface {
|
||||
EnsurePath(ctx context.Context, name, source string) (bool, error)
|
||||
}
|
||||
|
||||
type Reconciler struct {
|
||||
repository Repository
|
||||
discovery onvif.Adapter
|
||||
media MediaPaths
|
||||
now func() time.Time
|
||||
baseBackoff time.Duration
|
||||
maxBackoff time.Duration
|
||||
batchSize int
|
||||
}
|
||||
|
||||
func New(repository Repository, discovery onvif.Adapter, media MediaPaths) *Reconciler {
|
||||
return &Reconciler{
|
||||
repository: repository, discovery: discovery, media: media,
|
||||
now: time.Now, baseBackoff: time.Second, maxBackoff: time.Minute, batchSize: defaultBatchSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reconciler) RunOnce(ctx context.Context) error {
|
||||
now := r.now().UTC()
|
||||
candidates, err := r.repository.ListDueReconcile(ctx, now, r.batchSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list reconciliation candidates: %w", err)
|
||||
}
|
||||
var runErrors []error
|
||||
for _, candidate := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.reconcileOne(ctx, candidate, now); err != nil {
|
||||
runErrors = append(runErrors, fmt.Errorf("reconcile device %s: %w", candidate.Device.ID, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(runErrors...)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.ReconcileCandidate, now time.Time) error {
|
||||
result, err := r.discovery.Probe(ctx, onvif.Target{
|
||||
EndpointRef: candidate.Device.EndpointRef, CredentialRef: candidate.Device.CredentialRef,
|
||||
})
|
||||
if err == nil {
|
||||
err = validateStreamURI(result.StreamURI)
|
||||
}
|
||||
if err == nil {
|
||||
_, err = r.media.EnsurePath(ctx, candidate.Device.PathName, result.StreamURI)
|
||||
}
|
||||
if err == nil {
|
||||
return r.repository.MarkReconciled(ctx, candidate.Device.ID, candidate.Device.Generation, now)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
failureCount := candidate.FailureCount + 1
|
||||
nextAttempt := now.Add(r.backoff(failureCount))
|
||||
errorCode := string(onvif.CodeOf(err))
|
||||
var onvifError *onvif.Error
|
||||
if !errors.As(err, &onvifError) {
|
||||
errorCode = "media_error"
|
||||
}
|
||||
if markErr := r.repository.MarkReconcileFailure(
|
||||
ctx, candidate.Device.ID, failureCount, nextAttempt, errorCode, now,
|
||||
); markErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("persist reconcile failure: %w", markErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func validateStreamURI(value string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "rtsp" && parsed.Scheme != "rtsps") {
|
||||
return &onvif.Error{Code: onvif.ErrorInvalidReply, Err: fmt.Errorf("stream URI has invalid scheme or host")}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) backoff(failureCount int) time.Duration {
|
||||
if failureCount <= 1 {
|
||||
return r.baseBackoff
|
||||
}
|
||||
value := r.baseBackoff
|
||||
for step := 1; step < failureCount; step++ {
|
||||
if value >= r.maxBackoff/2 {
|
||||
return r.maxBackoff
|
||||
}
|
||||
value *= 2
|
||||
}
|
||||
if value > r.maxBackoff {
|
||||
return r.maxBackoff
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (r *Reconciler) Run(ctx context.Context, interval time.Duration, report func(error)) {
|
||||
if err := r.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||
report(err)
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := r.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
|
||||
report(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
"yovision/sense/internal/onvif"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
type recordingMedia struct {
|
||||
calls int
|
||||
changed int
|
||||
paths map[string]string
|
||||
}
|
||||
|
||||
func (m *recordingMedia) EnsurePath(_ context.Context, name, source string) (bool, error) {
|
||||
m.calls++
|
||||
if m.paths == nil {
|
||||
m.paths = make(map[string]string)
|
||||
}
|
||||
if m.paths[name] == source {
|
||||
return false, nil
|
||||
}
|
||||
m.paths[name] = source
|
||||
m.changed++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestReconcileConvergesOnceAndPersistsGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||
createReconcileDevice(t, repository)
|
||||
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||
"onvif://camera-1": {Result: onvif.ProbeResult{
|
||||
Profiles: []onvif.Profile{{Token: "main", Name: "Main", VideoEncoder: true}},
|
||||
StreamURI: "rtsp://media.invalid/camera-1",
|
||||
}},
|
||||
})
|
||||
media := &recordingMedia{}
|
||||
reconciler := New(repository, discovery, media)
|
||||
reconciler.now = func() time.Time { return time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) }
|
||||
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.calls != 1 || media.changed != 1 {
|
||||
t.Fatalf("converged generation should not repeat: calls=%d changed=%d", media.calls, media.changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoffSurvivesStoreRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
databasePath := filepath.Join(t.TempDir(), "sense.db")
|
||||
repository := openRepository(t, databasePath)
|
||||
createReconcileDevice(t, repository)
|
||||
failingDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||
"onvif://camera-1": {ProbeError: onvif.ErrorAuthentication},
|
||||
})
|
||||
media := &recordingMedia{}
|
||||
initialTime := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
||||
first := New(repository, failingDiscovery, media)
|
||||
first.now = func() time.Time { return initialTime }
|
||||
err := first.RunOnce(context.Background())
|
||||
if err == nil || onvif.CodeOf(err) != onvif.ErrorAuthentication {
|
||||
t.Fatalf("expected authentication failure, got %v", err)
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reopened, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(databasePath))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reopened.Close() })
|
||||
successDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||
"onvif://camera-1": {Result: onvif.ProbeResult{
|
||||
Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}},
|
||||
StreamURI: "rtsp://media.invalid/camera-1",
|
||||
}},
|
||||
})
|
||||
afterRestart := New(reopened, successDiscovery, media)
|
||||
afterRestart.now = func() time.Time { return initialTime.Add(500 * time.Millisecond) }
|
||||
if err := afterRestart.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.calls != 0 {
|
||||
t.Fatal("backoff window must survive restart")
|
||||
}
|
||||
afterRestart.now = func() time.Time { return initialTime.Add(time.Second) }
|
||||
if err := afterRestart.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.calls != 1 {
|
||||
t.Fatal("device must retry when persisted backoff expires")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellationDoesNotPersistFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||
createReconcileDevice(t, repository)
|
||||
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||
"onvif://camera-1": {
|
||||
DelayMillis: 100,
|
||||
Result: onvif.ProbeResult{
|
||||
Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}},
|
||||
StreamURI: "rtsp://media.invalid/camera-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reconciler := New(repository, discovery, &recordingMedia{})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
||||
defer cancel()
|
||||
err := reconciler.RunOnce(ctx)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected cancellation, got %v", err)
|
||||
}
|
||||
candidates, err := repository.ListDueReconcile(context.Background(), time.Now().Add(time.Hour), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(candidates) != 1 || candidates[0].FailureCount != 0 {
|
||||
t.Fatalf("cancellation must not consume retry budget: %+v", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func openRepository(t *testing.T, path string) *store.SQLite {
|
||||
t.Helper()
|
||||
repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repository.Close() })
|
||||
return repository
|
||||
}
|
||||
|
||||
func createReconcileDevice(t *testing.T, repository *store.SQLite) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := repository.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.CreateDevice(ctx, device.Device{
|
||||
ID: "camera-1", TenantID: "tenant", SiteID: "site", SerialNumber: "camera-1", Name: "Camera 1",
|
||||
Modality: device.ModalityVideo, Capabilities: []device.Capability{device.CapabilityVideoCapture},
|
||||
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
EndpointRef: "onvif://camera-1", CredentialRef: "secret://camera-1", PathName: "camera-1",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
// Package store persists the Sense desired state and reconciliation progress.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("store record not found")
|
||||
|
||||
type ReconcileCandidate struct {
|
||||
Device device.Device
|
||||
FailureCount int
|
||||
NextAttempt *time.Time
|
||||
}
|
||||
|
||||
type SQLite struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func OpenSQLite(ctx context.Context, dsn string) (*SQLite, error) {
|
||||
if err := ensureSQLiteDirectory(dsn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
// M1 runs one writer per edge instance. A single connection also gives
|
||||
// deterministic quota transactions and avoids :memory: connection splits.
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
store := &SQLite{db: db}
|
||||
if err := store.Migrate(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func ensureSQLiteDirectory(dsn string) error {
|
||||
if !strings.HasPrefix(dsn, "file:") {
|
||||
return nil
|
||||
}
|
||||
path := strings.TrimPrefix(dsn, "file:")
|
||||
path = strings.SplitN(path, "?", 2)[0]
|
||||
if path == "" || path == ":memory:" || strings.HasPrefix(path, ":memory:") {
|
||||
return nil
|
||||
}
|
||||
directory := filepath.Dir(filepath.FromSlash(path))
|
||||
if directory == "." {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(directory, 0o750); err != nil {
|
||||
return fmt.Errorf("create sqlite directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) Migrate(ctx context.Context) error {
|
||||
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
|
||||
return fmt.Errorf("enable sqlite foreign keys: %w", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `PRAGMA busy_timeout = 5000`); err != nil {
|
||||
return fmt.Errorf("configure sqlite busy timeout: %w", err)
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, statement := range migrationStatements {
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("apply sqlite migration: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO sense_schema_migrations(version, applied_at)
|
||||
VALUES (1, ?)
|
||||
ON CONFLICT(version) DO NOTHING`, formatTime(time.Now())); err != nil {
|
||||
return fmt.Errorf("record sqlite migration: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit sqlite migration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var migrationStatements = []string{
|
||||
`CREATE TABLE IF NOT EXISTS sense_schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sense_sites (
|
||||
tenant_id TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
max_video_channels INTEGER NOT NULL DEFAULT 16 CHECK (max_video_channels BETWEEN 1 AND 128),
|
||||
PRIMARY KEY (tenant_id, id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sense_devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
site_id TEXT NOT NULL,
|
||||
serial_number TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
modality TEXT NOT NULL,
|
||||
desired_state TEXT NOT NULL CHECK (desired_state IN ('disabled', 'enabled')),
|
||||
actual_state TEXT NOT NULL CHECK (actual_state IN ('pending', 'online', 'offline', 'failed')),
|
||||
endpoint_ref TEXT NOT NULL DEFAULT '',
|
||||
credential_ref TEXT NOT NULL DEFAULT '',
|
||||
path_name TEXT NOT NULL DEFAULT '',
|
||||
generation INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (tenant_id, site_id, serial_number),
|
||||
FOREIGN KEY (tenant_id, site_id) REFERENCES sense_sites(tenant_id, id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sense_device_capabilities (
|
||||
device_id TEXT NOT NULL,
|
||||
capability TEXT NOT NULL,
|
||||
PRIMARY KEY (device_id, capability),
|
||||
FOREIGN KEY (device_id) REFERENCES sense_devices(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS sense_reconcile_state (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT,
|
||||
last_error_code TEXT,
|
||||
observed_generation INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (device_id) REFERENCES sense_devices(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS sense_devices_site_state_idx
|
||||
ON sense_devices(tenant_id, site_id, desired_state)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS sense_devices_path_name_idx
|
||||
ON sense_devices(path_name) WHERE path_name <> ''`,
|
||||
`CREATE INDEX IF NOT EXISTS sense_reconcile_due_idx
|
||||
ON sense_reconcile_state(next_attempt_at)`,
|
||||
}
|
||||
|
||||
func (s *SQLite) EnsureSite(ctx context.Context, site device.Site) error {
|
||||
site.ApplyDefaults()
|
||||
if err := site.Validate(); err != nil {
|
||||
return fmt.Errorf("validate site: %w", err)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO sense_sites(tenant_id, id, name, max_video_channels)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(tenant_id, id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
max_video_channels = excluded.max_video_channels`,
|
||||
site.TenantID, site.ID, site.Name, site.MaxVideoChannels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure site: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) CreateDevice(ctx context.Context, value device.Device) error {
|
||||
if value.Generation == 0 {
|
||||
value.Generation = 1
|
||||
}
|
||||
if value.ActualState == "" {
|
||||
value.ActualState = device.ActualPending
|
||||
}
|
||||
if err := value.Validate(); err != nil {
|
||||
return fmt.Errorf("validate device: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if value.CreatedAt.IsZero() {
|
||||
value.CreatedAt = now
|
||||
}
|
||||
value.UpdatedAt = now
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin create device: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if value.ConsumesVideoChannel() {
|
||||
if err := checkVideoQuota(ctx, tx, value.TenantID, value.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO sense_devices(
|
||||
id, tenant_id, site_id, serial_number, name, modality,
|
||||
desired_state, actual_state, endpoint_ref, credential_ref,
|
||||
path_name, generation, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
value.ID, value.TenantID, value.SiteID, value.SerialNumber, value.Name,
|
||||
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
|
||||
value.CredentialRef, value.PathName, value.Generation,
|
||||
formatTime(value.CreatedAt), formatTime(value.UpdatedAt))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert device: %w", err)
|
||||
}
|
||||
for _, capability := range sortedCapabilities(value.Capabilities) {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO sense_device_capabilities(device_id, capability) VALUES (?, ?)`,
|
||||
value.ID, capability); err != nil {
|
||||
return fmt.Errorf("insert device capability: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO sense_reconcile_state(device_id, updated_at)
|
||||
VALUES (?, ?)`, value.ID, formatTime(now)); err != nil {
|
||||
return fmt.Errorf("insert reconcile state: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit create device: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkVideoQuota(ctx context.Context, tx *sql.Tx, tenantID, siteID string) error {
|
||||
var limit int
|
||||
err := tx.QueryRowContext(ctx,
|
||||
`SELECT max_video_channels FROM sense_sites WHERE tenant_id = ? AND id = ?`,
|
||||
tenantID, siteID).Scan(&limit)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("site %s/%s: %w", tenantID, siteID, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read site quota: %w", err)
|
||||
}
|
||||
var current int
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM sense_devices d
|
||||
JOIN sense_device_capabilities c ON c.device_id = d.id
|
||||
WHERE d.tenant_id = ? AND d.site_id = ?
|
||||
AND d.desired_state = 'enabled'
|
||||
AND c.capability = 'video_capture'`, tenantID, siteID).Scan(¤t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count site video channels: %w", err)
|
||||
}
|
||||
if current >= limit {
|
||||
return &device.QuotaExceededError{TenantID: tenantID, SiteID: siteID, Limit: limit}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) SetDesiredState(ctx context.Context, id string, desired device.DesiredState) error {
|
||||
if desired != device.DesiredEnabled && desired != device.DesiredDisabled {
|
||||
return fmt.Errorf("invalid desired state %q", desired)
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin desired-state update: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var tenantID, siteID, endpointRef, pathName string
|
||||
var current device.DesiredState
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT tenant_id, site_id, desired_state, endpoint_ref, path_name FROM sense_devices WHERE id = ?`, id).
|
||||
Scan(&tenantID, &siteID, ¤t, &endpointRef, &pathName)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read device desired state: %w", err)
|
||||
}
|
||||
if current == desired {
|
||||
return tx.Commit()
|
||||
}
|
||||
if desired == device.DesiredEnabled {
|
||||
var videoCapability int
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM sense_device_capabilities
|
||||
WHERE device_id = ? AND capability = 'video_capture'`, id).Scan(&videoCapability); err != nil {
|
||||
return fmt.Errorf("read video capability: %w", err)
|
||||
}
|
||||
if videoCapability > 0 {
|
||||
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
|
||||
return fmt.Errorf("enabled video devices require endpoint ref and path name")
|
||||
}
|
||||
if err := checkVideoQuota(ctx, tx, tenantID, siteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_devices
|
||||
SET desired_state = ?, actual_state = 'pending', generation = generation + 1, updated_at = ?
|
||||
WHERE id = ?`, desired, formatTime(time.Now()), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update desired state: %w", err)
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_reconcile_state
|
||||
SET failure_count = 0, next_attempt_at = NULL, last_error_code = NULL, updated_at = ?
|
||||
WHERE device_id = ?`, formatTime(time.Now()), id); err != nil {
|
||||
return fmt.Errorf("reset reconcile state: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit desired-state update: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) GetDevice(ctx context.Context, id string) (device.Device, error) {
|
||||
row := s.db.QueryRowContext(ctx, deviceSelect+` WHERE d.id = ?`, id)
|
||||
value, err := scanDevice(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return device.Device{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return device.Device{}, fmt.Errorf("get device: %w", err)
|
||||
}
|
||||
capabilities, err := s.capabilities(ctx, value.ID)
|
||||
if err != nil {
|
||||
return device.Device{}, err
|
||||
}
|
||||
value.Capabilities = capabilities
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]ReconcileCandidate, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+deviceColumns+`, r.failure_count, r.next_attempt_at
|
||||
FROM sense_devices d
|
||||
JOIN sense_reconcile_state r ON r.device_id = d.id
|
||||
WHERE d.desired_state = 'enabled'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sense_device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||
)
|
||||
AND (r.observed_generation < d.generation OR r.failure_count > 0)
|
||||
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= ?)
|
||||
ORDER BY d.updated_at, d.id
|
||||
LIMIT ?`, formatTime(now), limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list due reconcile devices: %w", err)
|
||||
}
|
||||
candidates := make([]ReconcileCandidate, 0)
|
||||
for rows.Next() {
|
||||
var candidate ReconcileCandidate
|
||||
var createdAt, updatedAt string
|
||||
var nextAttempt sql.NullString
|
||||
if err := rows.Scan(
|
||||
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
|
||||
&candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
||||
&candidate.Device.DesiredState, &candidate.Device.ActualState,
|
||||
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
|
||||
&candidate.Device.PathName, &candidate.Device.Generation,
|
||||
&createdAt, &updatedAt, &candidate.FailureCount, &nextAttempt,
|
||||
); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("scan due reconcile device: %w", err)
|
||||
}
|
||||
candidate.Device.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
candidate.Device.UpdatedAt, err = parseTime(updatedAt)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if nextAttempt.Valid {
|
||||
value, parseErr := parseTime(nextAttempt.String)
|
||||
if parseErr != nil {
|
||||
rows.Close()
|
||||
return nil, parseErr
|
||||
}
|
||||
candidate.NextAttempt = &value
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close due reconcile rows: %w", err)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate due reconcile devices: %w", err)
|
||||
}
|
||||
for index := range candidates {
|
||||
capabilities, err := s.capabilities(ctx, candidates[index].Device.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates[index].Device.Capabilities = capabilities
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, deviceSelect+`
|
||||
WHERE d.desired_state = 'enabled'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sense_device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||
)
|
||||
ORDER BY d.id LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled video devices: %w", err)
|
||||
}
|
||||
values := make([]device.Device, 0)
|
||||
for rows.Next() {
|
||||
value, err := scanDevice(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("scan enabled video device: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close enabled video rows: %w", err)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate enabled video devices: %w", err)
|
||||
}
|
||||
for index := range values {
|
||||
capabilities, err := s.capabilities(ctx, values[index].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[index].Capabilities = capabilities
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin reconciled update: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_reconcile_state
|
||||
SET failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||
observed_generation = ?, updated_at = ?
|
||||
WHERE device_id = ?`, generation, formatTime(now), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark device reconciled: %w", err)
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_devices SET actual_state = 'pending', updated_at = ? WHERE id = ?`,
|
||||
formatTime(now), id); err != nil {
|
||||
return fmt.Errorf("mark reconciled device pending: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit reconciled update: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) MarkReconcileFailure(ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode string, now time.Time) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin reconcile failure update: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_reconcile_state
|
||||
SET failure_count = ?, next_attempt_at = ?, last_error_code = ?, updated_at = ?
|
||||
WHERE device_id = ?`, failureCount, formatTime(nextAttempt), errorCode, formatTime(now), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark reconcile failure: %w", err)
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE sense_devices SET actual_state = 'failed', updated_at = ? WHERE id = ?`,
|
||||
formatTime(now), id); err != nil {
|
||||
return fmt.Errorf("mark failed device state: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit reconcile failure update: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error {
|
||||
if state != device.ActualPending && state != device.ActualOnline && state != device.ActualOffline && state != device.ActualFailed {
|
||||
return fmt.Errorf("invalid actual state %q", state)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`UPDATE sense_devices SET actual_state = ?, updated_at = ? WHERE id = ?`,
|
||||
state, formatTime(now), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update actual state: %w", err)
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const deviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality,
|
||||
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref,
|
||||
d.path_name, d.generation, d.created_at, d.updated_at`
|
||||
|
||||
const deviceSelect = `SELECT ` + deviceColumns + ` FROM sense_devices d`
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanDevice(row scanner) (device.Device, error) {
|
||||
var value device.Device
|
||||
var createdAt, updatedAt string
|
||||
err := row.Scan(
|
||||
&value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber,
|
||||
&value.Name, &value.Modality, &value.DesiredState, &value.ActualState,
|
||||
&value.EndpointRef, &value.CredentialRef, &value.PathName,
|
||||
&value.Generation, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return device.Device{}, err
|
||||
}
|
||||
value.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
return device.Device{}, err
|
||||
}
|
||||
value.UpdatedAt, err = parseTime(updatedAt)
|
||||
if err != nil {
|
||||
return device.Device{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) capabilities(ctx context.Context, id string) ([]device.Capability, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT capability FROM sense_device_capabilities WHERE device_id = ? ORDER BY capability`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list device capabilities: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]device.Capability, 0)
|
||||
for rows.Next() {
|
||||
var value device.Capability
|
||||
if err := rows.Scan(&value); err != nil {
|
||||
return nil, fmt.Errorf("scan device capability: %w", err)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate device capabilities: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func sortedCapabilities(values []device.Capability) []device.Capability {
|
||||
result := append([]device.Capability(nil), values...)
|
||||
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
||||
return result
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
return value.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func parseTime(value string) (time.Time, error) {
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse stored timestamp: %w", err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
func TestDefaultVideoQuotaRejectsSeventeenthChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant-a", ID: "site-a", Name: "Site A"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 1; index <= device.DefaultVideoChannels; index++ {
|
||||
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-a", "site-a")); err != nil {
|
||||
t.Fatalf("create channel %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
err := store.CreateDevice(ctx, videoDevice(17, "tenant-a", "site-a"))
|
||||
var quotaError *device.QuotaExceededError
|
||||
if !errors.As(err, "aError) || quotaError.Limit != 16 {
|
||||
t.Fatalf("expected 16-channel quota error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredMaximumAccepts128AndRejects129(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{
|
||||
TenantID: "tenant-b", ID: "site-b", Name: "Site B", MaxVideoChannels: 128,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 1; index <= 128; index++ {
|
||||
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-b", "site-b")); err != nil {
|
||||
t.Fatalf("create channel %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
err := store.CreateDevice(ctx, videoDevice(129, "tenant-b", "site-b"))
|
||||
var quotaError *device.QuotaExceededError
|
||||
if !errors.As(err, "aError) || quotaError.Limit != 128 {
|
||||
t.Fatalf("expected 128-channel quota error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteRejectsCapacityAbove128(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
err := store.EnsureSite(context.Background(), device.Site{
|
||||
TenantID: "tenant", ID: "site", Name: "Site", MaxVideoChannels: 129,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected capacity 129 to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonVideoDeviceDoesNotConsumeVideoQuota(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{
|
||||
TenantID: "tenant", ID: "site", Name: "Site", MaxVideoChannels: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
radar := device.Device{
|
||||
ID: "radar-1", TenantID: "tenant", SiteID: "site", SerialNumber: "radar-1",
|
||||
Name: "Radar", Modality: device.ModalityRadar,
|
||||
Capabilities: []device.Capability{device.CapabilityTelemetry},
|
||||
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
}
|
||||
if err := store.CreateDevice(ctx, radar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err != nil {
|
||||
t.Fatalf("video channel should remain available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnablingSeventeenthVideoDeviceIsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant-c", ID: "site-c", Name: "Site C"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 1; index <= 17; index++ {
|
||||
value := videoDevice(index, "tenant-c", "site-c")
|
||||
if index == 17 {
|
||||
value.DesiredState = device.DesiredDisabled
|
||||
}
|
||||
if err := store.CreateDevice(ctx, value); err != nil {
|
||||
t.Fatalf("create device %d: %v", index, err)
|
||||
}
|
||||
}
|
||||
err := store.SetDesiredState(ctx, "camera-017", device.DesiredEnabled)
|
||||
var quotaError *device.QuotaExceededError
|
||||
if !errors.As(err, "aError) || quotaError.Limit != 16 {
|
||||
t.Fatalf("expected enable to enforce quota, got %v", err)
|
||||
}
|
||||
value, err := store.GetDevice(ctx, "camera-017")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.DesiredState != device.DesiredDisabled {
|
||||
t.Fatal("failed enable must leave the existing desired state unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerQuotaDoesNotDisableExistingStreams(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := store.EnsureSite(ctx, device.Site{
|
||||
TenantID: "tenant-d", ID: "site-d", Name: "Site D", MaxVideoChannels: 2,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 1; index <= 2; index++ {
|
||||
if err := store.CreateDevice(ctx, videoDevice(index, "tenant-d", "site-d")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := store.EnsureSite(ctx, device.Site{
|
||||
TenantID: "tenant-d", ID: "site-d", Name: "Site D", MaxVideoChannels: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 1; index <= 2; index++ {
|
||||
value, err := store.GetDevice(ctx, fmt.Sprintf("camera-%03d", index))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.DesiredState != device.DesiredEnabled {
|
||||
t.Fatalf("existing channel %d was disabled", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openTestStore(t *testing.T) *SQLite {
|
||||
t.Helper()
|
||||
dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "sense.db"))
|
||||
store, err := OpenSQLite(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
func videoDevice(index int, tenantID, siteID string) device.Device {
|
||||
id := fmt.Sprintf("camera-%03d", index)
|
||||
return device.Device{
|
||||
ID: id, TenantID: tenantID, SiteID: siteID, SerialNumber: id, Name: id,
|
||||
Modality: device.ModalityVideo,
|
||||
Capabilities: []device.Capability{device.CapabilityVideoCapture, device.CapabilitySpatialRule},
|
||||
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
|
||||
EndpointRef: "onvif://" + id, CredentialRef: "secret://" + id,
|
||||
PathName: "sense/" + tenantID + "/" + siteID + "/" + id,
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前为 **M0:兼容性验证 + 需求定稿**。
|
||||
当前为 **M0 实机验证暂缓、M1 Sense 无实机骨架并行推进**。无实机测试不能替代 M0 白名单或 M1 五路实机出口。
|
||||
|
||||
优先路径:
|
||||
|
||||
@@ -85,6 +85,10 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
|
||||
python scripts/validate_agent_context.py
|
||||
python -m unittest discover -s tests -p "test_*.py"
|
||||
python scripts/validate_harness_governance.py
|
||||
go -C Sense generate ./internal/mtx
|
||||
go -C Sense test ./...
|
||||
go -C Sense vet ./...
|
||||
go -C Sense build ./...
|
||||
```
|
||||
|
||||
当前没有生产代码构建命令。代码出现后,以 [`03-tech-stack.md`](03-tech-stack.md) 的验证矩阵和当前任务门禁为准。
|
||||
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;默认只监听回环地址,具体配置、MediaMTX 版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md) 和 [`../Sense/README.md`](../Sense/README.md)。
|
||||
|
||||
+26
-3
@@ -28,6 +28,18 @@
|
||||
| 指标 | Prometheus + Grafana | 三系统统一可观测入口 |
|
||||
| 追踪 | OpenTelemetry + Jaeger | 端到端事件链路 |
|
||||
|
||||
### 1.1 Sense M1 冻结版本(T-003)
|
||||
|
||||
| 组件 | 冻结版本 | 许可证 / 校验 | 升级与退出路线 |
|
||||
| --- | --- | --- | --- |
|
||||
| Go | `1.26.5`(`go 1.26.0` + `toolchain go1.26.5`) | BSD-3-Clause;从 `go.dev/dl` 校验,Windows amd64 ZIP SHA-256 `97e6b2a833b6d89f9ff17d25419ac0a7e3b482a044e9ab18cdef834bd834fd38` | 跟随仍受支持的 Go 小版本,先在 CI/目标平台跑全量测试再升级;标准 Go module,无私有运行时绑定 |
|
||||
| SQLite driver | `modernc.org/sqlite v1.54.0` | BSD-3-Clause;module sum `h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=`,go.mod sum `h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=` | 选择无 CGO driver 以简化 Windows/边缘部署;只经 `database/sql` 与 repository 使用,可替换 driver;生产仍迁移到 PostgreSQL `sense` schema |
|
||||
| MediaMTX | `v1.19.3` | MIT;官方 release `checksums.sha256`:Windows amd64 `5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe`、Linux amd64 `a7ba21268fccda3ebc43fdad76b87fddb85ce77e725b5cb637bca724b5394fbe`、Linux arm64 `9e5b38a5b5fcab1916341b024031b2fc5dc6a2059baed9ba3f3b0d3768d231a8` | 独立进程,不链接到 Sense;升级时先更新 vendored OpenAPI、重新生成并跑假服务契约测试;可通过 `mtx` port 更换媒体数据面 |
|
||||
| oapi-codegen | `v2.8.0` | Apache-2.0;module sum `h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=`,go.mod sum `h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=` | 仅为构建工具;版本锁在 module tool dependency,生成文件与薄封装分离;升级后必须重新生成并检查 diff |
|
||||
| oapi-codegen runtime | `v1.6.0` | Apache-2.0;module sum `h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=`,go.mod sum `h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=` | 生成客户端的最小运行时;与生成器一起升级并跑假 HTTP 契约测试,退出时随生成客户端一并替换 |
|
||||
|
||||
MediaMTX 官方 `v1.19.3` OpenAPI 固定保存于 `Sense/api/vendor/mediamtx-v1.19.3.openapi.yaml`,SHA-256 为 `a2b58195f1ec76541e124b5de4ee54645e5a3e25f70c4a73acc4a44d6f2b9c52`。下载二进制后必须对照上表或官方同版 `checksums.sha256`,不得使用浮动 `latest` URL。SQLite `v1.56.0` 在本决策日刚发布,T-003 不追新;后续依赖升级单独评审。
|
||||
|
||||
## 2. 外部项目边界
|
||||
|
||||
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
|
||||
@@ -37,7 +49,7 @@
|
||||
|
||||
## 3. 待冻结项
|
||||
|
||||
- Go、Python、PostgreSQL、MediaMTX、Savant/DeepStream 的精确版本。
|
||||
- Python、PostgreSQL、Savant/DeepStream 的精确版本;Go 与 MediaMTX 已为 Sense M1 冻结,后续阶段可按升级流程调整。
|
||||
- Bell 前端框架和组件库。
|
||||
- 事件投递 transport 从 HTTP 起步还是直接采用消息总线。
|
||||
- 目标 GPU/边缘硬件、解码能力和每 worker 的 `max_sources`。
|
||||
@@ -48,7 +60,7 @@
|
||||
|
||||
## 4. 当前标准入口
|
||||
|
||||
仓库当前只有文档和契约,未产生可构建生产代码。根目录脚本执行文档治理验证:
|
||||
Sense M1 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
|
||||
|
||||
```powershell
|
||||
./init.ps1
|
||||
@@ -60,6 +72,17 @@ WSL/Linux/macOS/Git Bash:
|
||||
./init.sh
|
||||
```
|
||||
|
||||
Sense 单独执行:
|
||||
|
||||
```powershell
|
||||
go -C Sense mod download
|
||||
go -C Sense generate ./internal/mtx
|
||||
go -C Sense test ./...
|
||||
go -C Sense vet ./...
|
||||
go -C Sense build ./...
|
||||
go -C Sense run ./cmd/sense-api
|
||||
```
|
||||
|
||||
直接验证:
|
||||
|
||||
```powershell
|
||||
@@ -74,7 +97,7 @@ python scripts/validate_harness_governance.py
|
||||
| --- | --- | --- | --- |
|
||||
| Harness 文档/任务/Gitea 模板 | 上述三条 Python 命令 | 任一治理协议、清单或任务 schema 变化 | 不适用 |
|
||||
| `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 |
|
||||
| Sense Go | `go test ./...`、`go vet ./...`(代码出现后) | ONVIF、存储、MediaMTX、对账或公共 API 变化 | 命中设备任务时使用指定摄像头矩阵 |
|
||||
| Sense Go | `go -C Sense generate ./internal/mtx`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-003 为无实机;命中 T-006 等设备任务时使用指定摄像头矩阵 |
|
||||
| Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 |
|
||||
| Bell Go/Web | 后端测试 + 前端 lint/test/build(命令待脚手架冻结) | schema、RBAC、预警状态机或公共 UI 变化 | P0 流程由产品/值班角色验收 |
|
||||
| 容量/分片 | 任务内基准脚本 | 16/64/128 路里程碑 | 目标网络、媒体和 GPU 硬件必需 |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
出口:5 路自动建 path、探活、断线重建;使用 MediaMTX 生产数据面,完整 MiBeeNvr 不替代生产基线。
|
||||
|
||||
- T-003:建立无需真实摄像头即可验证的 Sense Go 脚手架、设备台账、ONVIF port/fake、MediaMTX 生成客户端与薄封装;完成不代表 M0/M1 出口。
|
||||
- T-003 只冻结 `/healthz`、`/readyz` 运维探针,设备管理公共 API 留给后续契约任务;真实 ONVIF adapter 不用 fake 冒充。
|
||||
- T-006:在 T-001 白名单与 T-003 骨架之上完成真实摄像头 5 路自动建 path、探活、断线恢复和 MediaMTX 集成验收。
|
||||
- T-001 暂缓期间可推进 T-003,但 T-006 和 M1 出口继续受真实设备证据阻塞。
|
||||
|
||||
|
||||
+23
@@ -42,6 +42,29 @@
|
||||
|
||||
Sense 使用 MediaMTX 官方 OpenAPI 生成客户端并加薄封装。业务代码不得散落硬编码 path API;生成代码不可手改。MediaMTX path 不是租户/站点/设备的业务真相源。
|
||||
|
||||
### 4.1 T-003 已实现的内部适配契约
|
||||
|
||||
以下是 Sense 内部 Go port,不是 Bell 或第三方可依赖的公共 HTTP API:
|
||||
|
||||
| Port | 操作 | 数据所有者 / 失败语义 |
|
||||
| --- | --- | --- |
|
||||
| ONVIF adapter | `Probe(target)`、`SetSystemDateAndTime(target, time)` | 设备是外部来源;`target` 只含 endpoint ref 与不透明 credential ref。错误稳定映射为认证失败、超时、不可用、响应无效,不记录凭据或完整流地址 |
|
||||
| MediaMTX paths | `CreatePath`、`GetPath`、`EnsurePath`、`DeletePath`、`PathReady` | SQLite 设备台账持有期望态,MediaMTX 只持有运行配置;`EnsurePath` 相同 source 不写、不同 source patch、缺失时 add;当前调和器绝不枚举或删除孤儿 |
|
||||
| Device repository | 站点、设备、期望态、实际态、调和进度 | SQLite 是 M1 期望态真相源;调和失败次数与下次时间持久化,进程重启不清空退避;配额读取/写入失败时拒绝新增或启用,不关闭已有流 |
|
||||
|
||||
MediaMTX 薄封装调用同版官方 OpenAPI 的 `/v3/config/paths/get|add|patch|delete/{name}` 与 `/v3/paths/get/{name}`。生成源、版本和 SHA-256 见 `docs/03-tech-stack.md`;业务包不得直接 import 生成包。
|
||||
|
||||
### 4.2 设备台账语义
|
||||
|
||||
- 设备类型由 `modality` 表达物理类别,由多值 `capabilities` 表达视频采集、音频、空间规则或遥测能力,避免把“摄像头”固化为唯一设备模型。
|
||||
- 视频配额只统计 `desired_state=enabled` 且具有 `video_capture` capability 的设备;站点默认 16、可配置 1~128。禁用设备和非视频传感器不占视频路数。
|
||||
- SQLite 表使用 `sense_` 前缀对应未来 PostgreSQL `sense` schema:`sense_sites`、`sense_devices`、`sense_device_capabilities`、`sense_reconcile_state`。标识、唯一性、状态与时间字段语义保持一致;本地表名前缀不是跨系统公共契约。
|
||||
- 摄像头密码不进入设备普通字段。`credential_ref` 只保存外部密钥引用;ONVIF 返回的 stream URI 只在内存中传给 MediaMTX,不写入设备台账或日志。
|
||||
|
||||
### 4.3 Sense 进程 HTTP 面
|
||||
|
||||
T-003 只提供运维探针:`GET /healthz` 表示进程存活,`GET /readyz` 表示配置、SQLite 打开及 migration 已完成。两者返回 JSON,均不等价于摄像头、MediaMTX path 或 M1 里程碑健康。设备管理、认证、分页、幂等键与并发控制尚未冻结,因此本任务不暴露 `/api/v1/devices` 等临时接口。
|
||||
|
||||
## 5. 变更流程
|
||||
|
||||
1. 在对应任务文件写清调用方、提供方、数据所有者、失败语义、幂等与兼容策略。
|
||||
|
||||
+14
-5
@@ -5,12 +5,15 @@
|
||||
## 当前阶段
|
||||
|
||||
- 阶段:M0 摄像头兼容性验证尚未完成;已批准并行推进不依赖真实摄像头的 M1 Sense 软件骨架,但不得提前宣称 M0/M1 出口完成。
|
||||
- 生产代码:尚未开始。
|
||||
- 生产代码:Sense M1 无实机骨架已建立,包含可构建进程、SQLite 台账、ONVIF port/fake、MediaMTX 生成客户端、最小对账与探活;真实 ONVIF adapter 和五路设备验收仍未开始。
|
||||
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
|
||||
|
||||
## 仓库现实
|
||||
|
||||
- `Sense/`、`Brain/`、`Bell/` 只有目录占位。
|
||||
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Brain/`、`Bell/` 仍只有目录占位。
|
||||
- Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。
|
||||
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure 与探活假 HTTP 测试。
|
||||
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;当前不枚举/删除孤儿,也不包含真实摄像头 adapter。
|
||||
- `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。
|
||||
- `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。
|
||||
- harness coding 文档、上下文清单、Gitea Issue/PR 模板和治理脚本已接入。
|
||||
@@ -27,6 +30,12 @@ Windows:
|
||||
./init.ps1
|
||||
```
|
||||
|
||||
该入口会下载锁定 Go module,运行三条治理验证、MediaMTX 客户端生成漂移检查、`go test`、`go vet` 和 `go build`。只启动 Sense:
|
||||
|
||||
```powershell
|
||||
go -C Sense run ./cmd/sense-api
|
||||
```
|
||||
|
||||
跨平台直接验证:
|
||||
|
||||
```powershell
|
||||
@@ -35,7 +44,7 @@ python -m unittest discover -s tests -p "test_*.py"
|
||||
python scripts/validate_harness_governance.py
|
||||
```
|
||||
|
||||
当前没有 Go/Python 业务依赖安装、生产服务启动或端到端命令。M1 脚手架创建时必须同步更新标准入口。
|
||||
Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz` 与 `/readyz` 运维探针;它们不代表摄像头或 M1 里程碑健康。MediaMTX 获取、校验和独立启动方法见 `Sense/README.md`。
|
||||
|
||||
## 当前 blocker / 待确认
|
||||
|
||||
@@ -43,12 +52,12 @@ python scripts/validate_harness_governance.py
|
||||
- S2 真实生产试点的未成年人影像、公共安全视频法规适用性和最终留存政策仍需客户/法务确认,阻塞 M3 上线但不阻塞 M1 实验室骨架。
|
||||
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
|
||||
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
|
||||
- Go/Python/PostgreSQL/MediaMTX/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结。
|
||||
- Python/PostgreSQL/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结。
|
||||
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
|
||||
|
||||
## 下一步
|
||||
|
||||
从 Gitea 的 `status/todo` 工单中由 dispatcher 分配依赖已满足、编号最靠前且写路径不冲突的任务。当前建议领取重构后的 T-003,先完成无实机 Sense 骨架;T-001 恢复后完成兼容性白名单,T-006 再执行真实摄像头 5 路集成验收。不要仅凭本文宣称领取成功。
|
||||
T-003 合入后仍不能宣称 M0/M1 出口完成。恢复 T-001 后先形成摄像头兼容性白名单,再领取依赖 T-001 与 T-003 的 T-006,完成真实摄像头 5 路自动建 path、探活、断线恢复和 MediaMTX 证据。实时领取状态仍以 Gitea 为准。
|
||||
|
||||
## 已知风险
|
||||
|
||||
|
||||
+31
-4
@@ -3,15 +3,16 @@ id: T-003
|
||||
title: 建立 Sense M1 无实机接入骨架
|
||||
phase: 1
|
||||
deps: [T-002]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
issue: 3
|
||||
context_ref: null
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
context_ref: e28070dd035cef3ff3e4a2dad879a482c41dac3a
|
||||
claim_branch: claims/T-003
|
||||
work_branch: agent/codex/T-003
|
||||
write_paths:
|
||||
- docs/tasks/T-003.md
|
||||
- Sense/
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/api.md
|
||||
- docs/06-tasks.md
|
||||
@@ -69,9 +70,35 @@ write_paths:
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04 领取与基线
|
||||
|
||||
- dispatcher `ila` 已将 Issue #3 分配给 `codex`;claim 与工作分支均从 `e28070dd035cef3ff3e4a2dad879a482c41dac3a` 创建并读回一致。
|
||||
- 在独立工作树 `D:\OPC\yovision-T-003` 开工,唯一写入范围为本任务声明的 `Sense/`、任务/技术/API/路线图/当前状态文档和根初始化脚本。
|
||||
- 开工基线 `./init.ps1` 通过,18 项治理测试成功;仓库尚无 Sense 生产代码,代码知识图谱工具未提供,本轮按仓库规则降级到文件检查。
|
||||
|
||||
### 2026-08-04 无实机拆分
|
||||
|
||||
- 项目负责人决定暂缓 T-001,并批准继续推进 Sense 离线软件骨架。T-003 的开工依赖调整为仅依赖已完成的 T-002,但真实摄像头验证仍是后续集成门禁。
|
||||
- 本任务收敛为 fake ONVIF、MediaMTX 假服务、SQLite 和可选合成 RTSP 可验证的生产骨架;不得用 mock 测试宣称 M0 或 M1 里程碑完成。
|
||||
- 真实设备、5 路自动建 path、探活、断线恢复和 MediaMTX 证据拆到 T-006,依赖 T-001 与 T-003。
|
||||
- 任务尚未领取;Issue #3 在本次规格合入默认分支前不进入 `status/todo`。
|
||||
|
||||
### 2026-08-04 版本与实现决策
|
||||
|
||||
- 冻结 Go `1.26.5`、MediaMTX `v1.19.3`、`oapi-codegen v2.8.0` 与 `modernc.org/sqlite v1.54.0`;许可证、module sum、MediaMTX 二进制 SHA-256、OpenAPI SHA-256、升级策略与退出路线已写入 `docs/03-tech-stack.md`。
|
||||
- SQLite driver 使用无 CGO 的 `modernc.org/sqlite`,但业务代码只依赖 `database/sql` repository;migration 避免 SQLite 专有业务语义,后续映射到 PostgreSQL `sense` schema。
|
||||
- 设备模型使用 `modality + capabilities`;视频配额只统计期望启用且具备视频采集能力的设备。站点默认 16、配置允许 1~128,超出时拒绝新增/启用,已有流不受影响。
|
||||
- 数据库持有期望态;ONVIF、MediaMTX 均由 port/adapter 隔离。T-003 实现确定性 fake 与假 HTTP 契约测试,不提供真实厂商兼容结论;对账只创建/修正应有 path,不做孤儿删除。
|
||||
- MediaMTX 官方 OpenAPI 按 tag vendoring,生成代码不可手改;薄封装负责状态码、幂等与领域错误映射。服务默认只监听 `127.0.0.1`,非回环监听必须显式开启。
|
||||
- 实现中发现标准 AI 入口仍会宣称“没有生产代码”。dispatcher 串行读回 Gitea 后确认只有 T-003 处于活跃状态、无路径冲突,并以完整 `CLAIM RENEWAL` 将 `docs/00-ai-start-here.md` 加入写入范围;worker 已同步本文件后才修改该入口。
|
||||
|
||||
### 2026-08-04 实现与验证证据
|
||||
|
||||
- 建立 `Sense/go.mod`、`cmd/sense-api`、SQLite v1 migration 与 device/store/onvif/mtx/reconcile/probe 包。进程默认回环监听,只提供 `/healthz`、`/readyz`;真实 ONVIF adapter 显式返回 unavailable,避免把 fake 冒充生产兼容实现。
|
||||
- vendoring MediaMTX `v1.19.3` 官方 OpenAPI,输入 SHA-256 为 `a2b58195f1ec76541e124b5de4ee54645e5a3e25f70c4a73acc4a44d6f2b9c52`;`go generate ./internal/mtx` 生成文件 SHA-256 在重复生成前后均为 `9e10d96eac1b332783d7cbfaba5872fede62a16ef6ccc1062cdb246607db7dac`。
|
||||
- SQLite 测试覆盖默认第 17 路拒绝、配置 128 路成功、第 129 路拒绝、禁用第 17 路重新启用拒绝、非视频设备不占额度,以及下调配额不关闭已有流。migration、唯一性、期望态 generation 和持久化退避由同一 repository 测试链路执行。
|
||||
- ONVIF fixture 覆盖 profile、脱敏 stream URI、校时、认证失败和取消/超时;MediaMTX 假 HTTP 服务覆盖官方生成客户端的 create/read/delete、ensure 幂等/patch、runtime path 探活和错误脱敏。
|
||||
- 对账测试覆盖成功收敛后不重复、指数退避、取消不消耗重试预算和关闭/重开 SQLite 后恢复;探活测试覆盖 online/offline 映射。对账器不调用 `DeletePath`,没有孤儿删除旁路。
|
||||
- `go test -race ./...` 全部通过;`go test ./... -count=2` 连续两轮通过;`go vet ./...`、`go build ./...` 通过。实际构建 `sense-api` 后在随机回环端口用绕过系统代理的 curl 验证 `health=ok`、`ready=ready`,临时 EXE、SQLite 和日志随后逐项删除。
|
||||
- 根标准入口 `./init.ps1` 通过:agent-context 校验、18 项治理测试、harness 治理校验、生成漂移检查、Sense 全量测试、vet 与 build 均成功。人工核对生成头为 `oapi-codegen v2.8.0`、OpenAPI/二进制下载使用固定 `v1.19.3` URL 和 SHA-256、MediaMTX/Sense 管理端默认只绑定回环地址。
|
||||
- 设备人工验收不适用:未连接摄像头,未宣称 T-001 白名单、真实 5 路或 M0/M1 出口完成;这些证据仍由 T-006 在 T-001 恢复后提供。
|
||||
|
||||
@@ -10,11 +10,10 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location -Path $PSScriptRoot
|
||||
|
||||
# 当前仍是文档/契约基线:没有业务依赖和可启动服务,标准入口先收敛治理验证。
|
||||
# M1 创建 Sense 脚手架时必须把真实安装、验证、启动命令同步到本文档链路。
|
||||
$InstallCmd = "Write-Host '当前无业务依赖需要安装'"
|
||||
$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }"
|
||||
$StartCmd = "Write-Host '当前无生产服务;请从 Gitea 工单开始 M0/M1 工作'"
|
||||
# Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
|
||||
$InstallCmd = "go -C Sense mod download"
|
||||
$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }"
|
||||
$StartCmd = "go -C Sense run ./cmd/sense-api"
|
||||
|
||||
function Assert-Configured {
|
||||
param(
|
||||
|
||||
@@ -12,11 +12,10 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# 当前仍是文档/契约基线:没有业务依赖和可启动服务,标准入口先收敛治理验证。
|
||||
# M1 创建 Sense 脚手架时必须把真实安装、验证、启动命令同步到本文档链路。
|
||||
INSTALL_CMD=(true)
|
||||
VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py")
|
||||
START_CMD=(echo "当前无生产服务;请从 Gitea 工单开始 M0/M1 工作")
|
||||
# Sense 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
|
||||
INSTALL_CMD=(go -C Sense mod download)
|
||||
VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./...")
|
||||
START_CMD=(go -C Sense run ./cmd/sense-api)
|
||||
|
||||
ensure_configured() {
|
||||
local name="$1"
|
||||
|
||||
Reference in New Issue
Block a user