feat(v2): T-309 SQLite fall-event persistence (mattn/go-sqlite3)
Persist confirmed fall events to a local SQLite DB under artifacts/: camera host/port/channel + event id/track/source/config version/state/ latency/screenshot path/confirmed-at. Schema stores no password (guarded by test). Wire openRecorder/saveRecord into the alerts loop; failures are non-fatal. Re-applied the SILVER-POSE vendored os_windows.go patch that go mod vendor reverted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,9 @@ type SourceConfig struct {
|
||||
ID string
|
||||
URL string
|
||||
RTSPURLEnv string
|
||||
Host string
|
||||
Port int
|
||||
Channel string
|
||||
Transport string
|
||||
Timeout time.Duration
|
||||
LowLatency bool
|
||||
@@ -202,11 +205,31 @@ func resolveSource(raw rawSource) (SourceConfig, error) {
|
||||
}
|
||||
|
||||
if raw.Host != nil {
|
||||
streamURL, err := structuredSourceURL(raw)
|
||||
if err != nil {
|
||||
return SourceConfig{}, err
|
||||
host := strings.TrimSpace(*raw.Host)
|
||||
if host == "" {
|
||||
return SourceConfig{}, fmt.Errorf("source.host must be non-empty")
|
||||
}
|
||||
resolved.URL = streamURL
|
||||
if raw.Username == nil || strings.TrimSpace(*raw.Username) == "" {
|
||||
return SourceConfig{}, fmt.Errorf("source.username must be non-empty")
|
||||
}
|
||||
if raw.Password == nil || *raw.Password == "" {
|
||||
return SourceConfig{}, fmt.Errorf("source.password must be non-empty")
|
||||
}
|
||||
port := 554
|
||||
if raw.Port != nil {
|
||||
port = *raw.Port
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return SourceConfig{}, fmt.Errorf("source.port must be between 1 and 65535")
|
||||
}
|
||||
channel := "101"
|
||||
if raw.Channel != nil && strings.TrimSpace(*raw.Channel) != "" {
|
||||
channel = strings.TrimSpace(*raw.Channel)
|
||||
}
|
||||
resolved.Host = host
|
||||
resolved.Port = port
|
||||
resolved.Channel = channel
|
||||
resolved.URL = BuildRTSPURL(host, port, strings.TrimSpace(*raw.Username), *raw.Password, channel)
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
@@ -223,34 +246,6 @@ func resolveSource(raw rawSource) (SourceConfig, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// structuredSourceURL builds an RTSP URL from local host/credential fields.
|
||||
// net/url percent-encodes the userinfo, so a password with @/:/ does not break
|
||||
// parsing. These fields are only permitted in the untracked local config.
|
||||
func structuredSourceURL(raw rawSource) (string, error) {
|
||||
host := strings.TrimSpace(*raw.Host)
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("source.host must be non-empty")
|
||||
}
|
||||
if raw.Username == nil || strings.TrimSpace(*raw.Username) == "" {
|
||||
return "", fmt.Errorf("source.username must be non-empty")
|
||||
}
|
||||
if raw.Password == nil || *raw.Password == "" {
|
||||
return "", fmt.Errorf("source.password must be non-empty")
|
||||
}
|
||||
port := 554
|
||||
if raw.Port != nil {
|
||||
port = *raw.Port
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return "", fmt.Errorf("source.port must be between 1 and 65535")
|
||||
}
|
||||
channel := "101"
|
||||
if raw.Channel != nil && strings.TrimSpace(*raw.Channel) != "" {
|
||||
channel = strings.TrimSpace(*raw.Channel)
|
||||
}
|
||||
return BuildRTSPURL(host, port, strings.TrimSpace(*raw.Username), *raw.Password, channel), nil
|
||||
}
|
||||
|
||||
// BuildRTSPURL assembles a Hikvision RTSP URL with percent-encoded credentials.
|
||||
func BuildRTSPURL(host string, port int, username, password, channel string) string {
|
||||
address := url.URL{
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package store persists confirmed fall events to a local SQLite database.
|
||||
// The database is local, untracked evidence: it records the camera host, port
|
||||
// and channel but never the password.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Record is one confirmed fall event row.
|
||||
type Record struct {
|
||||
EventID string
|
||||
TrackID string
|
||||
SourceID string
|
||||
CameraHost string
|
||||
CameraPort int
|
||||
CameraChannel string
|
||||
ConfigVersion string
|
||||
State string
|
||||
LatencySeconds float64
|
||||
ScreenshotPath string
|
||||
ConfirmedAtUTC string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS fall_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT NOT NULL,
|
||||
track_id TEXT,
|
||||
source_id TEXT,
|
||||
camera_host TEXT,
|
||||
camera_port INTEGER,
|
||||
camera_channel TEXT,
|
||||
config_version TEXT,
|
||||
state TEXT,
|
||||
latency_seconds REAL,
|
||||
screenshot_path TEXT,
|
||||
confirmed_at_utc TEXT
|
||||
);`
|
||||
|
||||
// Open opens (creating if needed) the SQLite database and ensures the schema.
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open fall database: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("create fall table: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Save inserts one confirmed fall event.
|
||||
func (store *Store) Save(record Record) error {
|
||||
_, err := store.db.Exec(
|
||||
`INSERT INTO fall_events
|
||||
(event_id, track_id, source_id, camera_host, camera_port, camera_channel,
|
||||
config_version, state, latency_seconds, screenshot_path, confirmed_at_utc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.EventID, record.TrackID, record.SourceID,
|
||||
record.CameraHost, record.CameraPort, record.CameraChannel,
|
||||
record.ConfigVersion, record.State, record.LatencySeconds,
|
||||
record.ScreenshotPath, record.ConfirmedAtUTC,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert fall event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count returns the number of stored events (used by tests).
|
||||
func (store *Store) Count() (int, error) {
|
||||
var count int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM fall_events`).Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (store *Store) Close() error {
|
||||
if store == nil || store.db == nil {
|
||||
return nil
|
||||
}
|
||||
return store.db.Close()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveAndCount(t *testing.T) {
|
||||
database, err := Open(filepath.Join(t.TempDir(), "fall.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
record := Record{
|
||||
EventID: "FALL-1", TrackID: "P-1", SourceID: "cam",
|
||||
CameraHost: "192.0.2.10", CameraPort: 554, CameraChannel: "102",
|
||||
ConfigVersion: "cfg-x", State: "CONFIRMED", LatencySeconds: 1.8,
|
||||
ScreenshotPath: "20260723/FALL-1.png", ConfirmedAtUTC: "2026-07-23T12:00:00Z",
|
||||
}
|
||||
if err := database.Save(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Save(record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, err := database.Count()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("count = %d, want 2", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaHasNoPasswordColumn(t *testing.T) {
|
||||
if strings.Contains(strings.ToLower(schema), "password") {
|
||||
t.Fatal("fall_events schema must not store a password")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user