feat(sense): implement Control API v1 [T-011]
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
"yovision/sense/internal/onvif"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
@@ -22,6 +23,7 @@ type Repository interface {
|
||||
|
||||
type MediaPaths interface {
|
||||
EnsurePath(ctx context.Context, name, source string) (bool, error)
|
||||
DeletePath(ctx context.Context, name string) error
|
||||
}
|
||||
|
||||
type Reconciler struct {
|
||||
@@ -60,6 +62,15 @@ func (r *Reconciler) RunOnce(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.ReconcileCandidate, now time.Time) error {
|
||||
if candidate.Device.DesiredState == device.DesiredDisabled {
|
||||
if err := r.media.DeletePath(ctx, candidate.Device.PathName); err == nil {
|
||||
return r.repository.MarkReconciled(ctx, candidate.Device.ID, candidate.Device.Generation, now)
|
||||
} else if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
} else {
|
||||
return r.persistFailure(ctx, candidate, now, err)
|
||||
}
|
||||
}
|
||||
result, err := r.discovery.Probe(ctx, onvif.Target{
|
||||
EndpointRef: candidate.Device.EndpointRef, CredentialRef: candidate.Device.CredentialRef,
|
||||
})
|
||||
@@ -75,6 +86,12 @@ func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.Reconcile
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return r.persistFailure(ctx, candidate, now, err)
|
||||
}
|
||||
|
||||
func (r *Reconciler) persistFailure(
|
||||
ctx context.Context, candidate store.ReconcileCandidate, now time.Time, err error,
|
||||
) error {
|
||||
failureCount := candidate.FailureCount + 1
|
||||
nextAttempt := now.Add(r.backoff(failureCount))
|
||||
errorCode := string(onvif.CodeOf(err))
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
type recordingMedia struct {
|
||||
calls int
|
||||
changed int
|
||||
deleted int
|
||||
paths map[string]string
|
||||
}
|
||||
|
||||
@@ -31,6 +32,15 @@ func (m *recordingMedia) EnsurePath(_ context.Context, name, source string) (boo
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *recordingMedia) DeletePath(_ context.Context, name string) error {
|
||||
m.calls++
|
||||
if m.paths != nil {
|
||||
delete(m.paths, name)
|
||||
}
|
||||
m.deleted++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReconcileConvergesOnceAndPersistsGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||
@@ -132,6 +142,26 @@ func TestCancellationDoesNotPersistFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledDeviceDeletesOnlyItsExactPath(t *testing.T) {
|
||||
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||
createReconcileDevice(t, repository)
|
||||
media := &recordingMedia{paths: map[string]string{"camera-1": "source", "other": "keep"}}
|
||||
if err := repository.SetDesiredState(context.Background(), "camera-1", device.DesiredDisabled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconciler := New(repository, onvif.NewFake(nil), media)
|
||||
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.deleted != 1 || media.paths["other"] != "keep" {
|
||||
t.Fatalf("disabled convergence touched the wrong paths: %+v", media.paths)
|
||||
}
|
||||
value, err := repository.GetDevice(context.Background(), "camera-1")
|
||||
if err != nil || value.ActualState != device.ActualOffline {
|
||||
t.Fatalf("disabled device did not converge offline: %+v %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func openRepository(t *testing.T, path string) *store.SQLite {
|
||||
t.Helper()
|
||||
repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path))
|
||||
|
||||
Reference in New Issue
Block a user