feat(v2): add annotated evidence and alert dispatch
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
// Package alert owns durable, one-shot fall-event evidence.
|
||||
package alert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"silverpose/v2/internal/fall"
|
||||
)
|
||||
|
||||
var eventIDPattern = regexp.MustCompile(`^[A-Za-z0-9-]+$`)
|
||||
|
||||
type Record struct {
|
||||
Event fall.Event
|
||||
ScreenshotPath string
|
||||
LogPath string
|
||||
CreatedAtUTC time.Time
|
||||
Written bool
|
||||
}
|
||||
|
||||
// Dispatcher de-duplicates events before writing to avoid screenshot overwrite,
|
||||
// duplicate JSONL records, duplicate sound, or duplicate popups downstream.
|
||||
type Dispatcher struct {
|
||||
eventDirectory string
|
||||
sourceID string
|
||||
mu sync.Mutex
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
func NewDispatcher(eventDirectory, sourceID string) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
eventDirectory: eventDirectory,
|
||||
sourceID: sourceID,
|
||||
seen: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (dispatcher *Dispatcher) Dispatch(event fall.Event, canvas image.Image, now time.Time) (Record, error) {
|
||||
if event.State != fall.Confirmed {
|
||||
return Record{}, fmt.Errorf("only confirmed events can be persisted")
|
||||
}
|
||||
if !eventIDPattern.MatchString(event.EventID) {
|
||||
return Record{}, fmt.Errorf("event ID is invalid")
|
||||
}
|
||||
if canvas == nil {
|
||||
return Record{}, fmt.Errorf("event screenshot is missing")
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
|
||||
dispatcher.mu.Lock()
|
||||
if _, alreadyWritten := dispatcher.seen[event.EventID]; alreadyWritten {
|
||||
dispatcher.mu.Unlock()
|
||||
return Record{Event: event, Written: false}, nil
|
||||
}
|
||||
dispatcher.seen[event.EventID] = struct{}{}
|
||||
dispatcher.mu.Unlock()
|
||||
|
||||
record, err := dispatcher.write(event, canvas, now)
|
||||
if err != nil {
|
||||
dispatcher.mu.Lock()
|
||||
delete(dispatcher.seen, event.EventID)
|
||||
dispatcher.mu.Unlock()
|
||||
return Record{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (dispatcher *Dispatcher) write(event fall.Event, canvas image.Image, now time.Time) (Record, error) {
|
||||
if dispatcher.eventDirectory == "" || dispatcher.sourceID == "" {
|
||||
return Record{}, fmt.Errorf("event directory and source ID must be configured")
|
||||
}
|
||||
dayDirectory := filepath.Join(dispatcher.eventDirectory, now.Format("20060102"))
|
||||
if err := os.MkdirAll(dayDirectory, 0o755); err != nil {
|
||||
return Record{}, fmt.Errorf("create event directory: %w", err)
|
||||
}
|
||||
screenshotPath := filepath.Join(dayDirectory, event.EventID+".png")
|
||||
file, err := os.Create(screenshotPath)
|
||||
if err != nil {
|
||||
return Record{}, fmt.Errorf("create screenshot: %w", err)
|
||||
}
|
||||
encodeErr := png.Encode(file, canvas)
|
||||
closeErr := file.Close()
|
||||
if encodeErr != nil || closeErr != nil {
|
||||
_ = os.Remove(screenshotPath)
|
||||
if encodeErr != nil {
|
||||
return Record{}, fmt.Errorf("encode screenshot: %w", encodeErr)
|
||||
}
|
||||
return Record{}, fmt.Errorf("close screenshot: %w", closeErr)
|
||||
}
|
||||
|
||||
logPath := filepath.Join(dayDirectory, "events.jsonl")
|
||||
relativeScreenshot := filepath.ToSlash(filepath.Join(now.Format("20060102"), event.EventID+".png"))
|
||||
line, err := json.Marshal(struct {
|
||||
EventID string `json:"event_id"`
|
||||
TrackID string `json:"track_id"`
|
||||
ConfigVersion string `json:"config_version"`
|
||||
SourceID string `json:"source_id"`
|
||||
State fall.State `json:"state"`
|
||||
ConfirmedAtUTC string `json:"confirmed_at_utc"`
|
||||
SuspectedAtMonotonic float64 `json:"suspected_at_monotonic"`
|
||||
ConfirmedAtMonotonic float64 `json:"confirmed_at_monotonic"`
|
||||
LatencySeconds float64 `json:"latency_seconds"`
|
||||
Screenshot string `json:"screenshot"`
|
||||
}{
|
||||
EventID: event.EventID, TrackID: event.TrackID, ConfigVersion: event.ConfigVersion,
|
||||
SourceID: dispatcher.sourceID, State: event.State, ConfirmedAtUTC: now.Format(time.RFC3339Nano),
|
||||
SuspectedAtMonotonic: event.SuspectedAtMonotonic,
|
||||
ConfirmedAtMonotonic: event.ConfirmedAtMonotonic,
|
||||
LatencySeconds: event.LatencySeconds, Screenshot: relativeScreenshot,
|
||||
})
|
||||
if err != nil {
|
||||
_ = os.Remove(screenshotPath)
|
||||
return Record{}, fmt.Errorf("encode event record: %w", err)
|
||||
}
|
||||
log, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
_ = os.Remove(screenshotPath)
|
||||
return Record{}, fmt.Errorf("open event log: %w", err)
|
||||
}
|
||||
if _, err := log.Write(append(line, '\n')); err != nil {
|
||||
_ = log.Close()
|
||||
_ = os.Remove(screenshotPath)
|
||||
return Record{}, fmt.Errorf("write event log: %w", err)
|
||||
}
|
||||
if err := log.Close(); err != nil {
|
||||
return Record{}, fmt.Errorf("close event log: %w", err)
|
||||
}
|
||||
return Record{
|
||||
Event: event, ScreenshotPath: screenshotPath, LogPath: logPath, CreatedAtUTC: now, Written: true,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"silverpose/v2/internal/fall"
|
||||
)
|
||||
|
||||
func TestDispatchWritesOnePNGAndRedactedJSONL(t *testing.T) {
|
||||
dispatcher := NewDispatcher(t.TempDir(), "lobby-camera-01")
|
||||
event := fall.Event{
|
||||
EventID: "FALL-run-000001", TrackID: "P-0001", ConfigVersion: "cfg-safe",
|
||||
SuspectedAtMonotonic: 10, ConfirmedAtMonotonic: 11.8, LatencySeconds: 1.8, State: fall.Confirmed,
|
||||
}
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
canvas.SetRGBA(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255})
|
||||
now := time.Date(2026, 7, 22, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
first, err := dispatcher.Dispatch(event, canvas, now)
|
||||
if err != nil || !first.Written {
|
||||
t.Fatalf("first dispatch = %#v, %v", first, err)
|
||||
}
|
||||
second, err := dispatcher.Dispatch(event, canvas, now)
|
||||
if err != nil || second.Written {
|
||||
t.Fatalf("duplicate dispatch = %#v, %v", second, err)
|
||||
}
|
||||
if extension := filepath.Ext(first.ScreenshotPath); extension != ".png" {
|
||||
t.Fatalf("screenshot extension = %q", extension)
|
||||
}
|
||||
if _, err := os.Stat(first.ScreenshotPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jsonl, err := os.ReadFile(first.LogPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lines := strings.Count(strings.TrimSpace(string(jsonl)), "\n") + 1; lines != 1 {
|
||||
t.Fatalf("JSONL lines = %d: %s", lines, jsonl)
|
||||
}
|
||||
if strings.Contains(string(jsonl), "rtsp") || strings.Contains(string(jsonl), "secret") {
|
||||
t.Fatalf("JSONL leaked source details: %s", jsonl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRejectsNonConfirmedEvent(t *testing.T) {
|
||||
dispatcher := NewDispatcher(t.TempDir(), "lobby-camera-01")
|
||||
_, err := dispatcher.Dispatch(fall.Event{EventID: "FALL-run-000001", State: fall.Suspect}, image.NewRGBA(image.Rect(0, 0, 1, 1)), time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("expected non-confirmed event error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user