142 lines
4.5 KiB
Go
142 lines
4.5 KiB
Go
// 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
|
|
}
|