Files

142 lines
4.5 KiB
Go
Raw Permalink Normal View History

// 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
}