feat(sense): establish M1 offline intake skeleton
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user