feat(sense): establish M1 offline intake skeleton
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-04 15:45:50 +08:00
parent bafd8d983e
commit 5208891e02
36 changed files with 17728 additions and 22 deletions
+113
View File
@@ -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
}
+35
View File
@@ -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")
}
}
+179
View File
@@ -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)
}
+30
View File
@@ -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",
}
}
+143
View File
@@ -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
}
+150
View File
@@ -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)
}
}
+5
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
package: mediamtxapi
output: generated/client.gen.go
generate:
models: true
client: true
output-options:
skip-prune: false
+126
View File
@@ -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
}
}
+56
View File
@@ -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)
}
}
+70
View File
@@ -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
View File
@@ -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"
}
}
}
}
+20
View File
@@ -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")}
}
+79
View File
@@ -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)
}
}
}
}
+53
View File
@@ -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)
}
}
+134
View File
@@ -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)
}
}
}
}
+159
View File
@@ -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)
}
}
+594
View File
@@ -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(&current)
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, &current, &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
}
+168
View File
@@ -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, &quotaError) || 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, &quotaError) || 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, &quotaError) || 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,
}
}