spaxel/mothership/internal/volume/shape.go
jedarden 22b745f274 feat: webhook action firing & fault tolerance for automations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 20:29:53 -04:00

1076 lines
30 KiB
Go

// Package volume provides 3D trigger volume geometry and point-in-volume testing
// for spatial automation triggers.
package volume
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
// ShapeType represents the type of volume geometry.
type ShapeType string
const (
ShapeBox ShapeType = "box"
ShapeCylinder ShapeType = "cylinder"
)
// ShapeJSON represents the geometry of a trigger volume as JSON.
// For box: {"type":"box","x":0,"y":0,"z":0,"w":1,"d":1,"h":2}
// For cylinder: {"type":"cylinder","cx":0,"cy":0,"z":0,"r":0.5,"h":2}
type ShapeJSON struct {
Type ShapeType `json:"type"`
// Box fields (axis-aligned bounding box)
X *float64 `json:"x,omitempty"` // Box origin X
Y *float64 `json:"y,omitempty"` // Box origin Y
Z *float64 `json:"z,omitempty"` // Box origin Z
W *float64 `json:"w,omitempty"` // Box width
D *float64 `json:"d,omitempty"` // Box depth
H *float64 `json:"h,omitempty"` // Box height (or cylinder height)
// Cylinder fields
CX *float64 `json:"cx,omitempty"` // Cylinder center X
CY *float64 `json:"cy,omitempty"` // Cylinder center Y
R *float64 `json:"r,omitempty"` // Cylinder radius
}
// Point3D represents a 3D point for volume testing.
type Point3D struct {
X float64
Y float64
Z float64
}
// IsInside returns true if the point is inside the volume.
func (s *ShapeJSON) IsInside(p Point3D) bool {
switch s.Type {
case ShapeBox:
return s.isInsideBox(p)
case ShapeCylinder:
return s.isInsideCylinder(p)
default:
return false
}
}
// isInsideBox tests if a point is inside an axis-aligned box.
// Box definition: {type:"box", x, y, z, w, d, h}
// The box spans from (x, y, z) to (x+w, y+h, z+d).
func (s *ShapeJSON) isInsideBox(p Point3D) bool {
if s.X == nil || s.Y == nil || s.Z == nil || s.W == nil || s.D == nil || s.H == nil {
return false
}
x, y, z := *s.X, *s.Y, *s.Z
w, d, h := *s.W, *s.D, *s.H
// Box spans from (x, y, z) to (x+w, y+h, z+d)
return p.X >= x && p.X < x+w &&
p.Y >= y && p.Y < y+h &&
p.Z >= z && p.Z < z+d
}
// isInsideCylinder tests if a point is inside a cylinder.
// Cylinder definition: {type:"cylinder", cx, cy, z, r, h}
// The cylinder is vertical (aligned with Y axis), centered at (cx, cy, z),
// with radius r and extending from z to z+h in height.
func (s *ShapeJSON) isInsideCylinder(p Point3D) bool {
if s.CX == nil || s.CY == nil || s.Z == nil || s.R == nil || s.H == nil {
return false
}
cx, cy := *s.CX, *s.CY
z, r, h := *s.Z, *s.R, *s.H
// Check horizontal distance from center (in X-Z plane for vertical cylinder)
dx := p.X - cx
dy := p.Y - cy
distSq := dx*dx + dy*dy
// Check if within radius and within height bounds
return distSq <= r*r && p.Z >= z && p.Z < z+h
}
// Trigger represents a spatial automation trigger from the triggers table.
type Trigger struct {
ID string `json:"id"`
Name string `json:"name"`
Shape ShapeJSON `json:"shape"`
Condition string `json:"condition"` // enter, leave, dwell, vacant, count
ConditionParams ConditionParams `json:"condition_params"`
TimeConstraint *TimeConstraint `json:"time_constraint,omitempty"`
Actions []Action `json:"actions"`
Enabled bool `json:"enabled"`
ErrorMessage string `json:"error_message,omitempty"` // Set when disabled by 4xx
ErrorCount int `json:"error_count"` // Incremented on 5xx/timeout, reset on 2xx
LastFired *time.Time `json:"last_fired,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ConditionParams holds trigger condition parameters.
type ConditionParams struct {
DurationS *int `json:"duration_s,omitempty"` // For dwell: seconds inside volume
CountThreshold *int `json:"count_threshold,omitempty"` // For count: minimum blob count
PersonID string `json:"person_id,omitempty"` // Filter by person ID
}
// TimeConstraint represents a time window constraint.
type TimeConstraint struct {
From string `json:"from"` // HH:MM format
To string `json:"to"` // HH:MM format
}
// Action represents an action to execute when a trigger fires.
type Action struct {
Type string `json:"type"` // webhook, mqtt, internal
Params map[string]interface{} `json:"params"`
}
// BlobState represents the state of a tracked blob relative to a trigger.
type BlobState struct {
BlobID int
Inside bool // Current inside/outside state
EnterTime time.Time // When blob entered the volume
LastCheckTime time.Time // Last evaluation time
}
// TriggerState holds the state machine for a trigger across all blobs.
type TriggerState struct {
TriggerID string
Blobs map[int]*BlobState // blob_id -> state
LastFired time.Time
VacantTimerStart time.Time // Separate field so fireTrigger doesn't clobber the vacant timer
}
// FiredEvent represents a trigger firing event.
type FiredEvent struct {
TriggerID string
TriggerName string
Condition string
BlobIDs []int
Timestamp time.Time
}
// FiringCallback is called when a trigger fires.
type FiringCallback func(event FiredEvent)
// Store provides trigger storage and state management.
type Store struct {
mu sync.RWMutex
db *sql.DB
triggers map[string]*Trigger
triggerState map[string]*TriggerState // trigger_id -> state
blobVolumes map[int]string // blob_id -> current volume_id (for tracking)
onFired FiringCallback // Called when a trigger fires
}
// NewStore creates a new trigger volume store.
func NewStore(dbPath string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
s := &Store{
db: db,
triggers: make(map[string]*Trigger),
triggerState: make(map[string]*TriggerState),
blobVolumes: make(map[int]string),
}
// Enable foreign keys
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
db.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
if err := s.init(); err != nil {
db.Close()
return nil, fmt.Errorf("init store: %w", err)
}
return s, nil
}
func (s *Store) init() error {
// Create triggers table if not exists (matches schema in migrations.go)
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS triggers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
shape_json TEXT NOT NULL,
condition TEXT NOT NULL CHECK (condition IN ('enter','leave','dwell','vacant','count')),
condition_params_json TEXT,
time_constraint_json TEXT,
actions_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
last_fired INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
);
`)
if err != nil {
return fmt.Errorf("create triggers table: %w", err)
}
// Add error_message and error_count columns (idempotent via try/catch pattern)
s.db.Exec(`ALTER TABLE triggers ADD COLUMN error_message TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE triggers ADD COLUMN error_count INTEGER NOT NULL DEFAULT 0`)
// Create trigger_state table for persisting blob states across restarts
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS trigger_state (
trigger_id INTEGER NOT NULL,
blob_id INTEGER NOT NULL,
inside INTEGER NOT NULL DEFAULT 0,
enter_time INTEGER NOT NULL DEFAULT 0,
last_check INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (trigger_id, blob_id),
FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
);
`)
if err != nil {
return fmt.Errorf("create trigger_state table: %w", err)
}
// Create webhook_log audit table
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS webhook_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trigger_id INTEGER NOT NULL,
fired_at_ms INTEGER NOT NULL,
url TEXT NOT NULL,
status_code INTEGER,
latency_ms INTEGER NOT NULL DEFAULT 0,
error TEXT DEFAULT '',
FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_webhook_log_trigger ON webhook_log(trigger_id, fired_at_ms DESC);
`)
if err != nil {
return fmt.Errorf("create webhook_log table: %w", err)
}
return s.load()
}
func (s *Store) load() error {
// Load triggers
rows, err := s.db.Query(`
SELECT id, name, shape_json, condition, condition_params_json,
time_constraint_json, actions_json, enabled, last_fired, created_at, updated_at,
COALESCE(error_message, ''), COALESCE(error_count, 0)
FROM triggers
`)
if err != nil {
return fmt.Errorf("query triggers: %w", err)
}
defer rows.Close()
for rows.Next() {
var t Trigger
var id int
var shapeJSON, conditionParamsJSON, timeConstraintJSON, actionsJSON string
var enabled int
var lastFiredMs sql.NullInt64
var createdAtMs, updatedAtMs int64
if err := rows.Scan(&id, &t.Name, &shapeJSON, &t.Condition, &conditionParamsJSON,
&timeConstraintJSON, &actionsJSON, &enabled, &lastFiredMs, &createdAtMs, &updatedAtMs,
&t.ErrorMessage, &t.ErrorCount); err != nil {
log.Printf("[WARN] Failed to scan trigger: %v", err)
continue
}
t.ID = fmt.Sprintf("%d", id)
t.Enabled = enabled != 0
if err := json.Unmarshal([]byte(shapeJSON), &t.Shape); err != nil {
log.Printf("[WARN] Failed to parse shape_json for trigger %s: %v", t.ID, err)
continue
}
if conditionParamsJSON != "" && conditionParamsJSON != "{}" {
json.Unmarshal([]byte(conditionParamsJSON), &t.ConditionParams)
}
if timeConstraintJSON != "" && timeConstraintJSON != "{}" {
json.Unmarshal([]byte(timeConstraintJSON), &t.TimeConstraint)
}
if actionsJSON != "" && actionsJSON != "[]" {
json.Unmarshal([]byte(actionsJSON), &t.Actions)
}
if lastFiredMs.Valid && lastFiredMs.Int64 > 0 {
ts := time.Unix(0, lastFiredMs.Int64)
t.LastFired = &ts
}
t.CreatedAt = time.Unix(0, createdAtMs)
t.UpdatedAt = time.Unix(0, updatedAtMs)
s.triggers[t.ID] = &t
s.triggerState[t.ID] = &TriggerState{
TriggerID: t.ID,
Blobs: make(map[int]*BlobState),
}
}
// Load blob states
stateRows, err := s.db.Query(`SELECT trigger_id, blob_id, inside, enter_time, last_check FROM trigger_state`)
if err != nil {
return fmt.Errorf("query trigger_state: %w", err)
}
defer stateRows.Close()
for stateRows.Next() {
var triggerID string
var blobID int
var inside int
var enterTimeMs, lastCheckMs int64
if err := stateRows.Scan(&triggerID, &blobID, &inside, &enterTimeMs, &lastCheckMs); err != nil {
continue
}
state := s.triggerState[triggerID]
if state == nil {
continue
}
state.Blobs[blobID] = &BlobState{
BlobID: blobID,
Inside: inside != 0,
EnterTime: time.Unix(0, enterTimeMs),
LastCheckTime: time.Unix(0, lastCheckMs),
}
}
return nil
}
// Close closes the database connection.
func (s *Store) Close() error {
return s.db.Close()
}
// Create creates a new trigger.
func (s *Store) Create(t *Trigger) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
shapeJSON, err := json.Marshal(t.Shape)
if err != nil {
return "", fmt.Errorf("marshal shape: %w", err)
}
conditionParamsJSON, _ := json.Marshal(t.ConditionParams)
var timeConstraintJSON []byte
if t.TimeConstraint != nil {
timeConstraintJSON, _ = json.Marshal(t.TimeConstraint)
}
actionsJSON, _ := json.Marshal(t.Actions)
now := time.Now().UnixNano()
enabled := 0
if t.Enabled {
enabled = 1
}
result, err := s.db.Exec(`
INSERT INTO triggers (name, shape_json, condition, condition_params_json,
time_constraint_json, actions_json, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, t.Name, string(shapeJSON), t.Condition, string(conditionParamsJSON),
string(timeConstraintJSON), string(actionsJSON), enabled, now, now)
if err != nil {
return "", fmt.Errorf("insert trigger: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return "", fmt.Errorf("get last insert id: %w", err)
}
t.ID = fmt.Sprintf("%d", id)
t.CreatedAt = time.Unix(0, now)
t.UpdatedAt = time.Unix(0, now)
s.triggers[t.ID] = t
s.triggerState[t.ID] = &TriggerState{
TriggerID: t.ID,
Blobs: make(map[int]*BlobState),
}
return t.ID, nil
}
// Update updates an existing trigger.
func (s *Store) Update(t *Trigger) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.triggers[t.ID]; !exists {
return fmt.Errorf("trigger not found: %s", t.ID)
}
shapeJSON, err := json.Marshal(t.Shape)
if err != nil {
return fmt.Errorf("marshal shape: %w", err)
}
conditionParamsJSON, _ := json.Marshal(t.ConditionParams)
var timeConstraintJSON []byte
if t.TimeConstraint != nil {
timeConstraintJSON, _ = json.Marshal(t.TimeConstraint)
}
actionsJSON, _ := json.Marshal(t.Actions)
now := time.Now().UnixNano()
enabled := 0
if t.Enabled {
enabled = 1
}
_, err = s.db.Exec(`
UPDATE triggers SET name=?, shape_json=?, condition=?, condition_params_json=?,
time_constraint_json=?, actions_json=?, enabled=?, updated_at=?
WHERE id=?
`, t.Name, string(shapeJSON), t.Condition, string(conditionParamsJSON),
string(timeConstraintJSON), string(actionsJSON), enabled, now, t.ID)
if err != nil {
return fmt.Errorf("update trigger: %w", err)
}
t.UpdatedAt = time.Unix(0, now)
s.triggers[t.ID] = t
return nil
}
// Delete deletes a trigger.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM triggers WHERE id=?`, id)
if err != nil {
return fmt.Errorf("delete trigger: %w", err)
}
delete(s.triggers, id)
delete(s.triggerState, id)
// Clear blob volumes for deleted trigger
for blobID, volID := range s.blobVolumes {
if volID == id {
delete(s.blobVolumes, blobID)
}
}
return nil
}
// Get retrieves a trigger by ID.
func (s *Store) Get(id string) (*Trigger, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, exists := s.triggers[id]
if !exists {
return nil, fmt.Errorf("trigger not found: %s", id)
}
return t, nil
}
// GetAll returns all triggers.
func (s *Store) GetAll() []*Trigger {
s.mu.RLock()
defer s.mu.RUnlock()
triggers := make([]*Trigger, 0, len(s.triggers))
for _, t := range s.triggers {
triggers = append(triggers, t)
}
return triggers
}
// SetOnFired sets the callback that is invoked when a trigger fires.
func (s *Store) SetOnFired(cb FiringCallback) {
s.mu.Lock()
defer s.mu.Unlock()
s.onFired = cb
}
// Evaluate evaluates all enabled triggers against the current blob positions.
// Returns a list of trigger IDs that should fire.
func (s *Store) Evaluate(blobs []BlobPos, now time.Time) []string {
s.mu.Lock()
defer s.mu.Unlock()
var fired []string
for _, t := range s.triggers {
if !t.Enabled {
continue
}
// Check time constraint
if t.TimeConstraint != nil && !s.isTimeInRange(t.TimeConstraint, now) {
continue
}
state := s.triggerState[t.ID]
// Initialize blob states for all current blobs.
// New blobs start with Inside=false so the condition evaluator can
// detect the first transition (enter/leave/etc). Without this, a blob
// that starts inside the volume would never trigger "enter" because
// no outside→inside transition would be observed.
for _, blob := range blobs {
if _, exists := state.Blobs[blob.ID]; !exists {
state.Blobs[blob.ID] = &BlobState{
BlobID: blob.ID,
Inside: false,
LastCheckTime: now,
}
}
}
// Evaluate condition
shouldFire := false
switch t.Condition {
case "enter":
shouldFire = s.evaluateEnter(t, state, blobs, now)
case "leave":
shouldFire = s.evaluateLeave(t, state, blobs, now)
case "dwell":
shouldFire = s.evaluateDwell(t, state, blobs, now)
case "vacant":
shouldFire = s.evaluateVacant(t, state, blobs, now)
case "count":
shouldFire = s.evaluateCount(t, state, blobs, now)
}
if shouldFire {
fired = append(fired, t.ID)
s.fireTrigger(t.ID, now)
// Reset vacant timer after firing so it doesn't re-fire immediately
if t.Condition == "vacant" {
state.VacantTimerStart = time.Time{}
}
}
// Persist blob states
s.persistBlobStates(t.ID, state)
}
return fired
}
// evaluateEnter triggers when a blob transitions from outside to inside the volume.
func (s *Store) evaluateEnter(t *Trigger, state *TriggerState, blobs []BlobPos, now time.Time) bool {
var fired bool
for _, blob := range blobs {
// Check person filter
if t.ConditionParams.PersonID != "" && t.ConditionParams.PersonID != "anyone" {
// TODO: Filter by person ID when blob has person info
}
blobState := state.Blobs[blob.ID]
wasInside := blobState != nil && blobState.Inside
isInside := t.Shape.IsInside(Point3D{X: blob.X, Y: blob.Y, Z: blob.Z})
if !wasInside && isInside {
// Transition from outside to inside
blobState.Inside = true
blobState.EnterTime = now
blobState.LastCheckTime = now
s.blobVolumes[blob.ID] = t.ID
fired = true
} else if blobState != nil {
blobState.Inside = isInside
blobState.LastCheckTime = now
if !isInside {
delete(s.blobVolumes, blob.ID)
}
}
}
return fired
}
// evaluateLeave triggers when a blob transitions from inside to outside the volume.
func (s *Store) evaluateLeave(t *Trigger, state *TriggerState, blobs []BlobPos, now time.Time) bool {
var fired bool
for _, blob := range blobs {
blobState := state.Blobs[blob.ID]
wasInside := blobState != nil && blobState.Inside
isInside := t.Shape.IsInside(Point3D{X: blob.X, Y: blob.Y, Z: blob.Z})
if wasInside && !isInside {
// Transition from inside to outside
blobState.Inside = false
blobState.LastCheckTime = now
delete(s.blobVolumes, blob.ID)
fired = true
} else if blobState != nil {
blobState.Inside = isInside
blobState.LastCheckTime = now
if isInside && blobState.EnterTime.IsZero() {
blobState.EnterTime = now
s.blobVolumes[blob.ID] = t.ID
}
}
}
// Clean up blobs that no longer exist
for blobID, blobState := range state.Blobs {
found := false
for _, blob := range blobs {
if blob.ID == blobID {
found = true
break
}
}
if !found && blobState.Inside {
// Blob disappeared - treat as leave
blobState.Inside = false
delete(s.blobVolumes, blobID)
fired = true
}
}
return fired
}
// evaluateDwell triggers after a blob has been inside for N continuous seconds.
// Per spec: fires exactly once per entry; re-fires after blob leaves and re-enters.
// Fire rate limiting: minimum 60s between dwell firings (after firing, must exit and re-enter).
func (s *Store) evaluateDwell(t *Trigger, state *TriggerState, blobs []BlobPos, now time.Time) bool {
if t.ConditionParams.DurationS == nil {
return false
}
durationThreshold := time.Duration(*t.ConditionParams.DurationS) * time.Second
var fired bool
for _, blob := range blobs {
blobState := state.Blobs[blob.ID]
isInside := t.Shape.IsInside(Point3D{X: blob.X, Y: blob.Y, Z: blob.Z})
if isInside {
if !blobState.Inside {
// Just entered — start dwell timer
blobState.Inside = true
blobState.EnterTime = now
s.blobVolumes[blob.ID] = t.ID
} else if blobState.EnterTime.IsZero() {
// Inside but no enter time — was set to fired state, must leave and re-enter
// Don't restart timer until blob exits and re-enters
} else {
// Was inside with active timer, check dwell time
elapsed := now.Sub(blobState.EnterTime)
if elapsed >= durationThreshold {
fired = true
// Mark as fired — enter time zeroed means "waiting for exit/re-entry"
blobState.EnterTime = time.Time{}
}
}
} else {
if blobState.Inside {
// Just left — reset for potential re-entry
blobState.Inside = false
blobState.EnterTime = time.Time{}
delete(s.blobVolumes, blob.ID)
}
}
blobState.LastCheckTime = now
}
return fired
}
// evaluateVacant triggers when the volume has been empty for N continuous seconds.
func (s *Store) evaluateVacant(t *Trigger, state *TriggerState, blobs []BlobPos, now time.Time) bool {
if t.ConditionParams.DurationS == nil {
// If no duration specified, trigger immediately when vacant
for _, blob := range blobs {
if t.Shape.IsInside(Point3D{X: blob.X, Y: blob.Y, Z: blob.Z}) {
return false // Someone is inside
}
}
return true
}
durationThreshold := time.Duration(*t.ConditionParams.DurationS) * time.Second
// Check if any blob is inside
anyInside := false
for _, blob := range blobs {
if t.Shape.IsInside(Point3D{X: blob.X, Y: blob.Y, Z: blob.Z}) {
anyInside = true
break
}
}
if anyInside {
// Reset vacant timer when someone enters
state.VacantTimerStart = time.Time{}
return false
}
// Check if vacant long enough
if !state.VacantTimerStart.IsZero() && now.Sub(state.VacantTimerStart) >= durationThreshold {
return true
}
// Start vacant timer
if state.VacantTimerStart.IsZero() {
state.VacantTimerStart = now
}
return false
}
// evaluateCount triggers when the blob count inside crosses a threshold.
// Fires on rising edge only: count was below threshold, now >= threshold.
func (s *Store) evaluateCount(t *Trigger, state *TriggerState, blobs []BlobPos, now time.Time) bool {
if t.ConditionParams.CountThreshold == nil {
return false
}
// Count blobs inside
insideCount := 0
for _, blob := range blobs {
if t.Shape.IsInside(Point3D{X: blob.X, Y: blob.Y, Z: blob.Z}) {
insideCount++
}
}
threshold := *t.ConditionParams.CountThreshold
// Get previous count from the special -999 slot (never persisted — in-memory only)
prevCount := 0
if prevSlot := state.Blobs[-999]; prevSlot != nil {
prevCount = int(prevSlot.EnterTime.UnixNano())
}
// Check if we crossed the threshold from below to at/above (rising edge only)
crossedThreshold := prevCount < threshold && insideCount >= threshold
// Store current count for next evaluation (in-memory only, never persisted)
if state.Blobs == nil {
state.Blobs = make(map[int]*BlobState)
}
state.Blobs[-999] = &BlobState{
BlobID: -999, // Special in-memory-only slot for count storage
EnterTime: time.Unix(0, int64(insideCount)),
Inside: false,
}
return crossedThreshold
}
// isTimeInRange checks if the current time is within the constraint window.
func (s *Store) isTimeInRange(tc *TimeConstraint, now time.Time) bool {
if tc == nil {
return true
}
fromMins := s.parseTimeOfDay(tc.From)
toMins := s.parseTimeOfDay(tc.To)
currentMins := now.Hour()*60 + now.Minute()
if fromMins <= toMins {
// Normal range (e.g., 09:00-17:00)
return currentMins >= fromMins && currentMins <= toMins
}
// Range crosses midnight (e.g., 22:00-07:00)
return currentMins >= fromMins || currentMins <= toMins
}
func (s *Store) parseTimeOfDay(timeStr string) int {
// Parse HH:MM format
var hours, minutes int
for i, c := range timeStr {
if c >= '0' && c <= '9' {
if i < 2 {
hours = hours*10 + int(c-'0')
} else {
minutes = minutes*10 + int(c-'0')
}
}
}
return hours*60 + minutes
}
// fireTrigger marks a trigger as fired, persists to database, and invokes the firing callback.
func (s *Store) fireTrigger(triggerID string, now time.Time) {
t := s.triggers[triggerID]
if t == nil {
return
}
t.LastFired = &now
s.triggerState[triggerID].LastFired = now
// Persist to database
lastFiredNs := now.UnixNano()
s.db.Exec(`UPDATE triggers SET last_fired=? WHERE id=?`, lastFiredNs, triggerID)
// Collect blob IDs that are inside this volume
var blobIDs []int
for blobID, volID := range s.blobVolumes {
if volID == triggerID {
blobIDs = append(blobIDs, blobID)
}
}
// Invoke firing callback if set
if s.onFired != nil {
// Unlock before calling callback to avoid deadlock
s.mu.Unlock()
s.onFired(FiredEvent{
TriggerID: triggerID,
TriggerName: t.Name,
Condition: t.Condition,
BlobIDs: blobIDs,
Timestamp: now,
})
s.mu.Lock()
}
}
// persistBlobStates persists blob states to the trigger_state table.
// Skips in-memory-only slots (negative blob IDs used for count tracking).
func (s *Store) persistBlobStates(triggerID string, state *TriggerState) {
// Delete existing states for this trigger
s.db.Exec(`DELETE FROM trigger_state WHERE trigger_id=?`, triggerID)
// Insert current states (skip in-memory-only slots with negative blob IDs)
for blobID, blobState := range state.Blobs {
if blobID < 0 {
continue // In-memory-only, don't persist
}
inside := 0
if blobState.Inside {
inside = 1
}
enterTimeNs := blobState.EnterTime.UnixNano()
lastCheckNs := blobState.LastCheckTime.UnixNano()
s.db.Exec(`
INSERT INTO trigger_state (trigger_id, blob_id, inside, enter_time, last_check)
VALUES (?, ?, ?, ?, ?)
`, triggerID, blobID, inside, enterTimeNs, lastCheckNs)
}
}
// GetRecentFirings returns the last N trigger firings with details.
func (s *Store) GetRecentFirings(limit int) []FiringRecord {
rows, err := s.db.Query(`
SELECT t.id, t.name, t.condition, t.last_fired
FROM triggers t
WHERE t.last_fired > 0
ORDER BY t.last_fired DESC
LIMIT ?
`, limit)
if err != nil {
return nil
}
defer rows.Close()
var records []FiringRecord
for rows.Next() {
var r FiringRecord
var id int
var lastFiredNs int64
if err := rows.Scan(&id, &r.TriggerName, &r.Condition, &lastFiredNs); err != nil {
continue
}
r.TriggerID = fmt.Sprintf("%d", id)
r.FiredAt = time.Unix(0, lastFiredNs)
records = append(records, r)
}
return records
}
// FiringRecord represents a trigger firing event.
type FiringRecord struct {
TriggerID string `json:"trigger_id"`
TriggerName string `json:"trigger_name"`
Condition string `json:"condition"`
FiredAt time.Time `json:"fired_at"`
BlobID int `json:"blob_id,omitempty"` // Future: track which blob caused it
}
// WebhookLogEntry represents an entry in the webhook audit log.
type WebhookLogEntry struct {
ID int64 `json:"id"`
TriggerID string `json:"trigger_id"`
URL string `json:"url"`
FiredAtMs int64 `json:"fired_at_ms"`
Status int `json:"status_code,omitempty"`
LatencyMs int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
}
// BlobPos represents a blob's position for trigger evaluation.
type BlobPos struct {
ID int
X, Y, Z float64
}
// IsInVolume is a convenience function to test if a point is in a trigger's volume.
func (s *Store) IsInVolume(triggerID string, x, y, z float64) bool {
s.mu.RLock()
defer s.mu.RUnlock()
t := s.triggers[triggerID]
if t == nil {
return false
}
return t.Shape.IsInside(Point3D{X: x, Y: y, Z: z})
}
// GetTrigger retrieves a trigger by ID (without locking - for use in callbacks).
func (s *Store) GetTrigger(id string) *Trigger {
s.mu.RLock()
defer s.mu.RUnlock()
return s.triggers[id]
}
// DisableTriggerWithError disables a trigger and sets its error message.
// Called when a webhook returns a 4xx response.
func (s *Store) DisableTriggerWithError(triggerID, errMsg string) {
s.mu.Lock()
defer s.mu.Unlock()
t, exists := s.triggers[triggerID]
if !exists {
return
}
t.Enabled = false
t.ErrorMessage = errMsg
s.db.Exec(`UPDATE triggers SET enabled=0, error_message=? WHERE id=?`, errMsg, triggerID)
log.Printf("[WARN] Trigger %q disabled due to webhook error: %s", t.Name, errMsg)
}
// IncrementErrorCount increments the error count for a trigger.
// Called when a webhook returns a 5xx or times out.
func (s *Store) IncrementErrorCount(triggerID string) {
s.mu.Lock()
defer s.mu.Unlock()
t, exists := s.triggers[triggerID]
if !exists {
return
}
t.ErrorCount++
s.db.Exec(`UPDATE triggers SET error_count=? WHERE id=?`, t.ErrorCount, triggerID)
log.Printf("[WARN] Trigger %q webhook error count: %d", t.Name, t.ErrorCount)
}
// ResetErrorCount resets the error count for a trigger (on first 2xx).
func (s *Store) ResetErrorCount(triggerID string) {
s.mu.Lock()
defer s.mu.Unlock()
t, exists := s.triggers[triggerID]
if !exists {
return
}
t.ErrorCount = 0
s.db.Exec(`UPDATE triggers SET error_count=0 WHERE id=?`, triggerID)
}
// EnableTrigger clears error state and re-enables a trigger.
func (s *Store) EnableTrigger(triggerID string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, exists := s.triggers[triggerID]
if !exists {
return fmt.Errorf("trigger not found: %s", triggerID)
}
t.Enabled = true
t.ErrorMessage = ""
t.ErrorCount = 0
_, err := s.db.Exec(`UPDATE triggers SET enabled=1, error_message='', error_count=0 WHERE id=?`, triggerID)
if err != nil {
return fmt.Errorf("enable trigger: %w", err)
}
log.Printf("[INFO] Trigger %q re-enabled", t.Name)
return nil
}
// WriteWebhookLog writes an entry to the webhook_log audit table.
func (s *Store) WriteWebhookLog(triggerID string, url string, firedAtMs int64, statusCode int, latencyMs int64, errMsg string) {
s.db.Exec(`
INSERT INTO webhook_log (trigger_id, fired_at_ms, url, status_code, latency_ms, error)
VALUES (?, ?, ?, ?, ?, ?)
`, triggerID, firedAtMs, url, statusCode, latencyMs, errMsg)
}
// GetWebhookLog returns the last N webhook log entries for a specific trigger.
func (s *Store) GetWebhookLog(triggerID string, limit int) []WebhookLogEntry {
rows, err := s.db.Query(`
SELECT id, trigger_id, fired_at_ms, url, status_code, latency_ms, COALESCE(error, '')
FROM webhook_log
WHERE trigger_id = ?
ORDER BY fired_at_ms DESC
LIMIT ?
`, triggerID, limit)
if err != nil {
return nil
}
defer rows.Close()
var entries []WebhookLogEntry
for rows.Next() {
var e WebhookLogEntry
if err := rows.Scan(&e.ID, &e.TriggerID, &e.FiredAtMs, &e.URL, &e.Status, &e.LatencyMs, &e.Error); err != nil {
continue
}
entries = append(entries, e)
}
return entries
}