- Added comprehensive integration tests in test/acceptance/ covering all 6 acceptance scenarios from plan.md - AS-1: First-time setup in under 5 minutes - verifies PIN setup and node auto-discovery - AS-2: Person detected while walking - verifies blob detection during walker simulation - AS-3: Fall alert fires correctly - verifies fall detection with webhook integration - AS-4: BLE identity resolves to person name - verifies BLE device registration and identity matching - AS-5: OTA update succeeds / rollback on bad firmware - verifies OTA workflow and rollback - AS-6: Replay shows recorded history - verifies replay session creation, seeking, and playback Tests use spaxel-sim CLI as the test harness and verify: - API endpoint responses (/api/auth/setup, /api/nodes, /api/blobs, /api/events, /api/ble/devices, /api/replay/*) - Detection accuracy thresholds (>60% blob presence during walking) - Alert generation and webhook delivery - Firmware version updates and rollback behavior - Replay session lifecycle management All tests skip by default unless ACCEPTANCE_TEST=1 or SPAXEL_INTEGRATION_TEST=1 is set. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1718 lines
51 KiB
Go
1718 lines
51 KiB
Go
// Package analytics provides anomaly detection based on learned normal behaviour patterns.
|
|
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/spaxel/mothership/internal/events"
|
|
"github.com/spaxel/mothership/internal/learning"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// NormalBehaviourSlot represents expected behaviour for a specific hour_of_week and zone.
|
|
type NormalBehaviourSlot struct {
|
|
HourOfWeek int `json:"hour_of_week"` // 0-167
|
|
ZoneID string `json:"zone_id"`
|
|
ExpectedOccupancy float64 `json:"expected_occupancy"` // 0.0-1.0, fraction of samples with occupancy
|
|
TypicalPersonCount float64 `json:"typical_person_count"` // Mean person count
|
|
SampleCount int `json:"sample_count"`
|
|
TypicalBLEDevices map[string]float64 `json:"typical_ble_devices,omitempty"` // MAC -> frequency (0.0-1.0)
|
|
}
|
|
|
|
// DwellBehaviourSlot represents expected dwell duration for a person in a zone at a specific hour.
|
|
type DwellBehaviourSlot struct {
|
|
HourOfWeek int `json:"hour_of_week"`
|
|
ZoneID string `json:"zone_id"`
|
|
PersonID string `json:"person_id"`
|
|
MeanDwellDuration time.Duration `json:"mean_dwell_duration"`
|
|
StdDwellDuration time.Duration `json:"std_dwell_duration"`
|
|
SampleCount int `json:"sample_count"`
|
|
}
|
|
|
|
// AnomalyScoreConfig holds configurable thresholds for anomaly scoring.
|
|
type AnomalyScoreConfig struct {
|
|
// Unusual hour presence
|
|
UnusualHourScore float64 `json:"unusual_hour_score"` // Default: 0.7
|
|
UnusualHourScoreSecurity float64 `json:"unusual_hour_score_security"` // Default: 0.9
|
|
LateNightMultiplier float64 `json:"late_night_multiplier"` // Default: 1.5 (00:00-06:00)
|
|
|
|
// Unknown BLE device
|
|
UnknownBLEScore float64 `json:"unknown_ble_score"` // Default: 0.5
|
|
UnknownBLEScoreSecurity float64 `json:"unknown_ble_score_security"` // Default: 0.8
|
|
SeenOnceScore float64 `json:"seen_once_score"` // Default: 0.3
|
|
CloseRangeRSSIThreshold int `json:"close_range_rssi_threshold"` // Default: -60 dBm
|
|
|
|
// Motion during away
|
|
MotionDuringAwayScore float64 `json:"motion_during_away_score"` // Default: 0.95
|
|
|
|
// Unusual dwell duration
|
|
UnusualDwellScore float64 `json:"unusual_dwell_score"` // Default: 0.4
|
|
DwellMultiplierThreshold float64 `json:"dwell_multiplier_threshold"` // Default: 5.0
|
|
|
|
// Alert thresholds
|
|
AlertThresholdNormal float64 `json:"alert_threshold_normal"` // Default: 0.6
|
|
AlertThresholdSecurity float64 `json:"alert_threshold_security"` // Default: 0.4
|
|
|
|
// Auto-away/disarm
|
|
AutoAwayDuration time.Duration `json:"auto_away_duration"` // Default: 15 minutes
|
|
AutoDisarmRSSIThreshold int `json:"auto_disarm_rssi_threshold"` // Default: -70 dBm
|
|
ManualOverrideDuration time.Duration `json:"manual_override_duration"` // Default: 30 minutes
|
|
}
|
|
|
|
// DefaultAnomalyScoreConfig returns default configuration.
|
|
func DefaultAnomalyScoreConfig() AnomalyScoreConfig {
|
|
return AnomalyScoreConfig{
|
|
UnusualHourScore: 0.7,
|
|
UnusualHourScoreSecurity: 0.9,
|
|
LateNightMultiplier: 1.5,
|
|
UnknownBLEScore: 0.5,
|
|
UnknownBLEScoreSecurity: 0.8,
|
|
SeenOnceScore: 0.3,
|
|
CloseRangeRSSIThreshold: -60,
|
|
MotionDuringAwayScore: 0.95,
|
|
UnusualDwellScore: 0.4,
|
|
DwellMultiplierThreshold: 5.0,
|
|
AlertThresholdNormal: 0.6,
|
|
AlertThresholdSecurity: 0.4,
|
|
AutoAwayDuration: 15 * time.Minute,
|
|
AutoDisarmRSSIThreshold: -70,
|
|
ManualOverrideDuration: 30 * time.Minute,
|
|
}
|
|
}
|
|
|
|
// SecurityMode represents the current security mode state.
|
|
type SecurityMode string
|
|
|
|
const (
|
|
SecurityModeDisarmed SecurityMode = "disarmed"
|
|
SecurityModeArmed SecurityMode = "armed"
|
|
SecurityModeArmedStay SecurityMode = "armed_stay" // Armed but people are home
|
|
)
|
|
|
|
// AutoAwayState tracks state for auto-away functionality.
|
|
type AutoAwayState struct {
|
|
LastMotionTime time.Time `json:"last_motion_time"`
|
|
LastPersonCount int `json:"last_person_count"`
|
|
AutoAwayTriggered bool `json:"auto_away_triggered"`
|
|
}
|
|
|
|
// AutoDisarmState tracks state for auto-disarm functionality.
|
|
type AutoDisarmState struct {
|
|
RegisteredDeviceSeen bool `json:"registered_device_seen"`
|
|
SeenDeviceMAC string `json:"seen_device_mac"`
|
|
SeenDeviceRSSI int `json:"seen_device_rssi"`
|
|
LastSeenTime time.Time `json:"last_seen_time"`
|
|
}
|
|
|
|
// AnomalyCooldownConfig holds configuration for anomaly deduplication.
|
|
type AnomalyCooldownConfig struct {
|
|
// Cooldown duration per anomaly type+zone combination
|
|
UnusualHourCooldown time.Duration `json:"unusual_hour_cooldown"` // Default: 30 minutes
|
|
UnknownBLECooldown time.Duration `json:"unknown_ble_cooldown"` // Default: 10 minutes
|
|
MotionDuringAwayCooldown time.Duration `json:"motion_during_away_cooldown"` // Default: 5 minutes
|
|
UnusualDwellCooldown time.Duration `json:"unusual_dwell_cooldown"` // Default: 1 hour
|
|
}
|
|
|
|
// DefaultAnomalyCooldownConfig returns default cooldown configuration.
|
|
func DefaultAnomalyCooldownConfig() AnomalyCooldownConfig {
|
|
return AnomalyCooldownConfig{
|
|
UnusualHourCooldown: 30 * time.Minute,
|
|
UnknownBLECooldown: 10 * time.Minute,
|
|
MotionDuringAwayCooldown: 5 * time.Minute,
|
|
UnusualDwellCooldown: 1 * time.Hour,
|
|
}
|
|
}
|
|
|
|
// cooldownKey tracks the last time an anomaly was raised for deduplication.
|
|
type cooldownKey struct {
|
|
anomalyType events.AnomalyType
|
|
zoneID string
|
|
personID string
|
|
deviceMAC string
|
|
}
|
|
|
|
// Detector detects anomalies based on learned normal behaviour.
|
|
type Detector struct {
|
|
mu sync.RWMutex
|
|
db *sql.DB
|
|
config AnomalyScoreConfig
|
|
cooldownConfig AnomalyCooldownConfig
|
|
|
|
// Normal behaviour model (loaded from DB)
|
|
behaviourSlots map[string]*NormalBehaviourSlot // key: "hour-zone"
|
|
dwellSlots map[string]*DwellBehaviourSlot // key: "hour-zone-person"
|
|
|
|
// Active anomaly tracking
|
|
activeAnomalies map[string]*events.AnomalyEvent // id -> event
|
|
anomalyHistory []*events.AnomalyEvent
|
|
|
|
// Pending alert timers
|
|
pendingAlerts map[string]*alertTimerState
|
|
|
|
// Anomaly cooldown tracking for deduplication
|
|
anomalyCooldowns map[cooldownKey]time.Time // key -> last triggered time
|
|
|
|
// Model state
|
|
learningStartTime time.Time
|
|
modelReady bool
|
|
modelReadyAt time.Time
|
|
|
|
// Registered devices and people
|
|
registeredDevices map[string]bool // MAC -> registered
|
|
registeredPeople map[string]string // person_id -> name
|
|
deviceFirstSeen map[string]time.Time // MAC -> first seen time
|
|
|
|
// Security mode state
|
|
securityMode SecurityMode
|
|
autoAwayState AutoAwayState
|
|
autoDisarmState AutoDisarmState
|
|
manualOverrideUntil time.Time // Manual mode override expiry
|
|
|
|
// Providers
|
|
zoneProvider ZoneProvider
|
|
personProvider PersonProvider
|
|
deviceProvider DeviceProvider
|
|
positionProvider PositionProvider
|
|
alertHandler AlertHandler
|
|
|
|
// Feedback store for accuracy tracking
|
|
feedbackStore *learning.FeedbackStore
|
|
|
|
// Callbacks
|
|
onAnomaly func(event events.AnomalyEvent)
|
|
onModeChange func(event events.SystemModeChangeEvent)
|
|
onSecurityModeChange func(oldMode, newMode SecurityMode, reason string)
|
|
}
|
|
|
|
// ZoneProvider provides zone information.
|
|
type ZoneProvider interface {
|
|
GetZoneName(zoneID string) string
|
|
GetZoneOccupancy(zoneID string) (count int, blobIDs []int)
|
|
}
|
|
|
|
// PersonProvider provides person information.
|
|
type PersonProvider interface {
|
|
GetPersonDevices(personID string) ([]string, error)
|
|
GetAllRegisteredDevices() (map[string]string, error) // MAC -> person_id
|
|
GetPersonName(personID string) string
|
|
}
|
|
|
|
// DeviceProvider provides device information.
|
|
type DeviceProvider interface {
|
|
IsDeviceRegistered(mac string) bool
|
|
IsDeviceSeenBefore(mac string) bool
|
|
GetDeviceName(mac string) string
|
|
}
|
|
|
|
// PositionProvider provides position for blobs.
|
|
type PositionProvider interface {
|
|
GetBlobPosition(blobID int) (x, y, z float64, ok bool)
|
|
}
|
|
|
|
// AlertHandler handles alert delivery.
|
|
type AlertHandler interface {
|
|
SendAlert(event events.AnomalyEvent, immediate bool) error
|
|
SendWebhook(event events.AnomalyEvent, immediate bool) error
|
|
SendEscalation(event events.AnomalyEvent) error
|
|
}
|
|
|
|
type alertTimerState struct {
|
|
alertTimer *time.Timer
|
|
webhookTimer *time.Timer
|
|
escalationTimer *time.Timer
|
|
anomalyID string
|
|
}
|
|
|
|
// NewDetector creates a new anomaly detector.
|
|
func NewDetector(dbPath string, config AnomalyScoreConfig) (*Detector, 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)
|
|
|
|
d := &Detector{
|
|
db: db,
|
|
config: config,
|
|
cooldownConfig: DefaultAnomalyCooldownConfig(),
|
|
behaviourSlots: make(map[string]*NormalBehaviourSlot),
|
|
dwellSlots: make(map[string]*DwellBehaviourSlot),
|
|
activeAnomalies: make(map[string]*events.AnomalyEvent),
|
|
pendingAlerts: make(map[string]*alertTimerState),
|
|
anomalyCooldowns: make(map[cooldownKey]time.Time),
|
|
registeredDevices: make(map[string]bool),
|
|
registeredPeople: make(map[string]string),
|
|
deviceFirstSeen: make(map[string]time.Time),
|
|
securityMode: SecurityModeDisarmed,
|
|
}
|
|
|
|
if err := d.migrate(); err != nil {
|
|
db.Close() //nolint:errcheck
|
|
return nil, fmt.Errorf("migrate: %w", err)
|
|
}
|
|
|
|
if err := d.loadBehaviourModel(); err != nil {
|
|
log.Printf("[WARN] Failed to load behaviour model: %v", err)
|
|
}
|
|
|
|
if err := d.loadLearningState(); err != nil {
|
|
log.Printf("[WARN] Failed to load learning state: %v", err)
|
|
}
|
|
|
|
return d, nil
|
|
}
|
|
|
|
func (d *Detector) migrate() error {
|
|
_, err := d.db.Exec(` //nolint:errcheck
|
|
CREATE TABLE IF NOT EXISTS behaviour_slots (
|
|
hour_of_week INTEGER NOT NULL,
|
|
zone_id TEXT NOT NULL,
|
|
expected_occupancy REAL NOT NULL DEFAULT 0,
|
|
typical_person_count REAL NOT NULL DEFAULT 0,
|
|
sample_count INTEGER NOT NULL DEFAULT 0,
|
|
typical_ble_devices TEXT NOT NULL DEFAULT '{}',
|
|
PRIMARY KEY (hour_of_week, zone_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dwell_slots (
|
|
hour_of_week INTEGER NOT NULL,
|
|
zone_id TEXT NOT NULL,
|
|
person_id TEXT NOT NULL,
|
|
mean_dwell_ns INTEGER NOT NULL DEFAULT 0,
|
|
std_dwell_ns INTEGER NOT NULL DEFAULT 0,
|
|
sample_count INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (hour_of_week, zone_id, person_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS occupancy_samples (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
hour_of_week INTEGER NOT NULL,
|
|
zone_id TEXT NOT NULL,
|
|
person_count INTEGER NOT NULL,
|
|
ble_devices TEXT NOT NULL DEFAULT '[]',
|
|
timestamp INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS dwell_samples (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
hour_of_week INTEGER NOT NULL,
|
|
zone_id TEXT NOT NULL,
|
|
person_id TEXT NOT NULL,
|
|
dwell_ns INTEGER NOT NULL,
|
|
timestamp INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS anomaly_events (
|
|
id TEXT PRIMARY KEY,
|
|
type TEXT NOT NULL,
|
|
score REAL NOT NULL,
|
|
description TEXT NOT NULL,
|
|
timestamp INTEGER NOT NULL,
|
|
zone_id TEXT,
|
|
zone_name TEXT,
|
|
blob_id INTEGER,
|
|
person_id TEXT,
|
|
person_name TEXT,
|
|
device_mac TEXT,
|
|
device_name TEXT,
|
|
position_x REAL,
|
|
position_y REAL,
|
|
position_z REAL,
|
|
hour_of_week INTEGER,
|
|
expected_occupancy REAL,
|
|
dwell_duration_ns INTEGER,
|
|
expected_dwell_ns INTEGER,
|
|
rssi_dbm INTEGER,
|
|
seen_before INTEGER,
|
|
acknowledged INTEGER NOT NULL DEFAULT 0,
|
|
acknowledged_at INTEGER,
|
|
feedback TEXT,
|
|
alert_sent INTEGER NOT NULL DEFAULT 0,
|
|
webhook_sent INTEGER NOT NULL DEFAULT 0,
|
|
escalation_sent INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS learning_state (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS device_first_seen (
|
|
mac TEXT PRIMARY KEY,
|
|
first_seen_ns INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_occupancy_samples_time ON occupancy_samples(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_dwell_samples_time ON dwell_samples(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_anomaly_events_time ON anomaly_events(timestamp);
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func (d *Detector) loadBehaviourModel() error {
|
|
// Load behaviour slots
|
|
rows, err := d.db.Query(`
|
|
SELECT hour_of_week, zone_id, expected_occupancy, typical_person_count, sample_count, typical_ble_devices
|
|
FROM behaviour_slots
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close() //nolint:errcheck
|
|
|
|
for rows.Next() {
|
|
slot := &NormalBehaviourSlot{
|
|
TypicalBLEDevices: make(map[string]float64),
|
|
}
|
|
var bleDevicesJSON string
|
|
if err := rows.Scan(&slot.HourOfWeek, &slot.ZoneID, &slot.ExpectedOccupancy,
|
|
&slot.TypicalPersonCount, &slot.SampleCount, &bleDevicesJSON); err != nil {
|
|
continue
|
|
}
|
|
// Parse BLE devices JSON
|
|
if bleDevicesJSON != "" && bleDevicesJSON != "{}" {
|
|
var devices map[string]float64
|
|
if err := jsonUnmarshal(bleDevicesJSON, &devices); err == nil {
|
|
slot.TypicalBLEDevices = devices
|
|
}
|
|
}
|
|
key := fmt.Sprintf("%d-%s", slot.HourOfWeek, slot.ZoneID)
|
|
d.behaviourSlots[key] = slot
|
|
}
|
|
|
|
// Load dwell slots
|
|
dwellRows, err := d.db.Query(`
|
|
SELECT hour_of_week, zone_id, person_id, mean_dwell_ns, std_dwell_ns, sample_count
|
|
FROM dwell_slots
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dwellRows.Close() //nolint:errcheck
|
|
|
|
for dwellRows.Next() {
|
|
slot := &DwellBehaviourSlot{}
|
|
var meanNS, stdNS int64
|
|
if err := dwellRows.Scan(&slot.HourOfWeek, &slot.ZoneID, &slot.PersonID,
|
|
&meanNS, &stdNS, &slot.SampleCount); err != nil {
|
|
continue
|
|
}
|
|
slot.MeanDwellDuration = time.Duration(meanNS)
|
|
slot.StdDwellDuration = time.Duration(stdNS)
|
|
key := fmt.Sprintf("%d-%s-%s", slot.HourOfWeek, slot.ZoneID, slot.PersonID)
|
|
d.dwellSlots[key] = slot
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *Detector) loadLearningState() error {
|
|
var startNS int64
|
|
err := d.db.QueryRow(`SELECT value FROM learning_state WHERE key = 'learning_start'`).Scan(&startNS)
|
|
if err == sql.ErrNoRows {
|
|
// Initialize learning start time
|
|
d.learningStartTime = time.Now()
|
|
d.db.Exec(`INSERT INTO learning_state (key, value) VALUES ('learning_start', ?)`, time.Now().UnixNano()) //nolint:errcheck
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d.learningStartTime = time.Unix(0, startNS)
|
|
|
|
// Check if 7 days have passed
|
|
if time.Since(d.learningStartTime) >= 7*24*time.Hour {
|
|
d.modelReady = true
|
|
d.modelReadyAt = d.learningStartTime.Add(7 * 24 * time.Hour)
|
|
}
|
|
|
|
// Load security_mode from database (persisted across restarts)
|
|
var securityModeStr string
|
|
err = d.db.QueryRow(`SELECT value FROM learning_state WHERE key = 'security_mode'`).Scan(&securityModeStr)
|
|
if err == nil {
|
|
d.securityMode = SecurityMode(securityModeStr)
|
|
log.Printf("[INFO] Loaded security mode from database: %s", d.securityMode)
|
|
} else if err != sql.ErrNoRows {
|
|
log.Printf("[WARN] Failed to load security_mode from database: %v", err)
|
|
}
|
|
// If security_mode doesn't exist in DB, default to disarmed (already set in NewDetector)
|
|
|
|
// Load device first seen times
|
|
deviceRows, err := d.db.Query(`SELECT mac, first_seen_ns FROM device_first_seen`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer deviceRows.Close() //nolint:errcheck
|
|
|
|
for deviceRows.Next() {
|
|
var mac string
|
|
var firstSeenNS int64
|
|
if err := deviceRows.Scan(&mac, &firstSeenNS); err != nil {
|
|
continue
|
|
}
|
|
d.deviceFirstSeen[mac] = time.Unix(0, firstSeenNS)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close closes the database.
|
|
func (d *Detector) Close() error {
|
|
return d.db.Close()
|
|
}
|
|
|
|
// SetZoneProvider sets the zone provider.
|
|
func (d *Detector) SetZoneProvider(p ZoneProvider) {
|
|
d.mu.Lock()
|
|
d.zoneProvider = p
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// SetPersonProvider sets the person provider.
|
|
func (d *Detector) SetPersonProvider(p PersonProvider) {
|
|
d.mu.Lock()
|
|
d.personProvider = p
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// SetDeviceProvider sets the device provider.
|
|
func (d *Detector) SetDeviceProvider(p DeviceProvider) {
|
|
d.mu.Lock()
|
|
d.deviceProvider = p
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// SetPositionProvider sets the position provider.
|
|
func (d *Detector) SetPositionProvider(p PositionProvider) {
|
|
d.mu.Lock()
|
|
d.positionProvider = p
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// SetAlertHandler sets the alert handler.
|
|
func (d *Detector) SetAlertHandler(h AlertHandler) {
|
|
d.mu.Lock()
|
|
d.alertHandler = h
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// SetFeedbackStore sets the feedback store for accuracy tracking.
|
|
func (d *Detector) SetFeedbackStore(store *learning.FeedbackStore) {
|
|
d.mu.Lock()
|
|
d.feedbackStore = store
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// SetOnAnomaly sets callback for anomaly events.
|
|
func (d *Detector) SetOnAnomaly(cb func(event events.AnomalyEvent)) {
|
|
d.mu.Lock()
|
|
d.onAnomaly = cb
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// SetOnModeChange sets callback for mode change events.
|
|
func (d *Detector) SetOnModeChange(cb func(event events.SystemModeChangeEvent)) {
|
|
d.mu.Lock()
|
|
d.onModeChange = cb
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// SetOnSecurityModeChange sets callback for security mode changes.
|
|
func (d *Detector) SetOnSecurityModeChange(cb func(oldMode, newMode SecurityMode, reason string)) {
|
|
d.mu.Lock()
|
|
d.onSecurityModeChange = cb
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// GetSecurityMode returns the current security mode.
|
|
func (d *Detector) GetSecurityMode() SecurityMode {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.securityMode
|
|
}
|
|
|
|
// SetSecurityMode sets the security mode.
|
|
func (d *Detector) SetSecurityMode(mode SecurityMode, reason string) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
if d.securityMode == mode {
|
|
return // No change
|
|
}
|
|
|
|
oldMode := d.securityMode
|
|
d.securityMode = mode
|
|
|
|
log.Printf("[INFO] Security mode changed: %s -> %s (reason: %s)", oldMode, mode, reason)
|
|
|
|
// Fire callback
|
|
if d.onSecurityModeChange != nil {
|
|
go d.onSecurityModeChange(oldMode, mode, reason)
|
|
}
|
|
|
|
// Persist to database
|
|
d.db.Exec(`INSERT OR REPLACE INTO learning_state (key, value) VALUES ('security_mode', ?)`, string(mode)) //nolint:errcheck
|
|
|
|
if mode == SecurityModeArmed || mode == SecurityModeArmedStay {
|
|
// Record armed timestamp for persistence across restarts
|
|
d.db.Exec(`INSERT OR REPLACE INTO learning_state (key, value) VALUES ('security_mode_armed_at', ?)`, time.Now().UnixNano()) //nolint:errcheck
|
|
d.manualOverrideUntil = time.Time{}
|
|
} else {
|
|
// Clear armed timestamp on disarm
|
|
d.db.Exec(`DELETE FROM learning_state WHERE key = 'security_mode_armed_at'`) //nolint:errcheck
|
|
}
|
|
}
|
|
|
|
// IsSecurityModeActive returns true if security mode is armed or armed_stay.
|
|
func (d *Detector) IsSecurityModeActive() bool {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.securityMode == SecurityModeArmed || d.securityMode == SecurityModeArmedStay
|
|
}
|
|
|
|
// GetArmedAt returns the timestamp when security mode was last armed, or nil if disarmed.
|
|
// The timestamp is loaded from the persisted learning_state table on startup.
|
|
func (d *Detector) GetArmedAt() *time.Time {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
|
|
if d.securityMode != SecurityModeArmed && d.securityMode != SecurityModeArmedStay {
|
|
return nil
|
|
}
|
|
|
|
var armedAtNS int64
|
|
err := d.db.QueryRow(
|
|
`SELECT value FROM learning_state WHERE key = 'security_mode_armed_at'`,
|
|
).Scan(&armedAtNS)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
t := time.Unix(0, armedAtNS)
|
|
return &t
|
|
}
|
|
|
|
// SetManualOverride sets a temporary override for security mode.
|
|
func (d *Detector) SetManualOverride(duration time.Duration) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.manualOverrideUntil = time.Now().Add(duration)
|
|
log.Printf("[INFO] Manual security override set for %v", duration)
|
|
}
|
|
|
|
// ClearManualOverride clears the manual override.
|
|
func (d *Detector) ClearManualOverride() {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.manualOverrideUntil = time.Time{}
|
|
log.Printf("[INFO] Manual security override cleared")
|
|
}
|
|
|
|
// IsManualOverrideActive returns true if manual override is active.
|
|
func (d *Detector) IsManualOverrideActive() bool {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return !d.manualOverrideUntil.IsZero() && time.Now().Before(d.manualOverrideUntil)
|
|
}
|
|
|
|
// SetRegisteredDevices sets the list of registered BLE devices.
|
|
func (d *Detector) SetRegisteredDevices(devices []string) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
d.registeredDevices = make(map[string]bool)
|
|
for _, mac := range devices {
|
|
d.registeredDevices[mac] = true
|
|
}
|
|
}
|
|
|
|
// IsModelReady returns true if 7 days of learning have passed.
|
|
func (d *Detector) IsModelReady() bool {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.modelReady
|
|
}
|
|
|
|
// GetLearningProgress returns the fraction of learning completed (0.0-1.0).
|
|
func (d *Detector) GetLearningProgress() float64 {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
|
|
if d.modelReady {
|
|
return 1.0
|
|
}
|
|
|
|
elapsed := time.Since(d.learningStartTime)
|
|
total := 7 * 24 * time.Hour
|
|
progress := float64(elapsed) / float64(total)
|
|
if progress > 1.0 {
|
|
progress = 1.0
|
|
}
|
|
return progress
|
|
}
|
|
|
|
// ProcessBLEDeviceSighting processes a BLE device sighting for auto-away/auto-disarm.
|
|
// Returns a SystemModeChangeEvent if the mode changed, nil otherwise.
|
|
func (d *Detector) ProcessBLEDeviceSighting(mac string, rssi int, nodeMAC string) *events.SystemModeChangeEvent {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
|
|
// Track when registered devices are seen
|
|
if d.registeredDevices[mac] && rssi > d.config.AutoDisarmRSSIThreshold {
|
|
// Registered device detected in close range
|
|
d.autoDisarmState.RegisteredDeviceSeen = true
|
|
d.autoDisarmState.SeenDeviceMAC = mac
|
|
d.autoDisarmState.SeenDeviceRSSI = rssi
|
|
d.autoDisarmState.LastSeenTime = now
|
|
|
|
// Auto-disarm: if currently in away mode, switch to home
|
|
if d.securityMode == SecurityModeArmed && !d.IsManualOverrideActive() {
|
|
// Get person name from device provider
|
|
personName := ""
|
|
if d.personProvider != nil {
|
|
if devices, err := d.personProvider.GetAllRegisteredDevices(); err == nil {
|
|
if personID, ok := devices[mac]; ok {
|
|
personName = d.personProvider.GetPersonName(personID)
|
|
}
|
|
}
|
|
}
|
|
|
|
reason := "Auto-disarm activated — registered device detected"
|
|
if personName != "" {
|
|
reason = fmt.Sprintf("Welcome home — %s arrived", personName)
|
|
}
|
|
|
|
return d.setSystemMode(events.ModeHome, reason, personName)
|
|
}
|
|
}
|
|
|
|
// Update last motion time for auto-away (any BLE device indicates presence)
|
|
d.autoAwayState.LastMotionTime = now
|
|
|
|
return nil
|
|
}
|
|
|
|
// ProcessMotionForAutoAway should be called when any motion is detected.
|
|
// It updates the last motion time to prevent auto-away while activity is present.
|
|
func (d *Detector) ProcessMotionForAutoAway() {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.autoAwayState.LastMotionTime = time.Now()
|
|
}
|
|
|
|
// CheckAutoAway checks if auto-away should be triggered (all registered devices absent for > 15 minutes).
|
|
// Should be called periodically (e.g., every minute).
|
|
// Returns a SystemModeChangeEvent if mode changed, nil otherwise.
|
|
func (d *Detector) CheckAutoAway() *events.SystemModeChangeEvent {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
|
|
// Check if all registered devices have been absent for > 15 minutes
|
|
if len(d.registeredDevices) == 0 {
|
|
return nil // No registered devices, can't do auto-away
|
|
}
|
|
|
|
// Check if any registered device has been seen recently (within last 15 minutes)
|
|
// This is a simplified check - in practice, we'd track per-device last seen times
|
|
timeSinceLastMotion := now.Sub(d.autoAwayState.LastMotionTime)
|
|
|
|
if timeSinceLastMotion > d.config.AutoAwayDuration && !d.IsManualOverrideActive() {
|
|
// Only trigger if not already in away mode
|
|
if d.securityMode != SecurityModeArmed {
|
|
return d.setSystemMode(events.ModeAway, "Auto-away activated — all BLE devices absent", "")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setSystemMode sets the system mode and fires the mode change callback.
|
|
// Must be called while holding the mutex.
|
|
func (d *Detector) setSystemMode(newMode events.SystemMode, reason, personName string) *events.SystemModeChangeEvent {
|
|
oldSecurityMode := d.securityMode
|
|
oldMode := d.securityModeToSystemMode(d.securityMode)
|
|
event := &events.SystemModeChangeEvent{
|
|
PreviousMode: oldMode,
|
|
NewMode: newMode,
|
|
Reason: reason,
|
|
Timestamp: time.Now(),
|
|
PersonName: personName,
|
|
}
|
|
|
|
// Update security mode state
|
|
switch newMode {
|
|
case events.ModeAway:
|
|
d.securityMode = SecurityModeArmed
|
|
case events.ModeHome:
|
|
d.securityMode = SecurityModeDisarmed
|
|
case events.ModeSleep:
|
|
d.securityMode = SecurityModeArmedStay // Stay mode for sleep
|
|
}
|
|
|
|
// Fire callback
|
|
if d.onModeChange != nil {
|
|
go d.onModeChange(*event)
|
|
}
|
|
|
|
// Broadcast to dashboard
|
|
if d.onSecurityModeChange != nil {
|
|
go d.onSecurityModeChange(oldSecurityMode, d.securityMode, reason)
|
|
}
|
|
|
|
// Persist to database
|
|
d.db.Exec(`INSERT OR REPLACE INTO learning_state (key, value) VALUES ('security_mode', ?)`, string(d.securityMode)) //nolint:errcheck
|
|
|
|
log.Printf("[INFO] System mode changed: %s -> %s (reason: %s)", oldMode, newMode, reason)
|
|
|
|
return event
|
|
}
|
|
|
|
// securityModeToSystemMode converts SecurityMode to SystemMode.
|
|
func (d *Detector) securityModeToSystemMode(mode SecurityMode) events.SystemMode {
|
|
switch mode {
|
|
case SecurityModeArmed:
|
|
return events.ModeAway
|
|
case SecurityModeArmedStay:
|
|
return events.ModeSleep
|
|
default:
|
|
return events.ModeHome
|
|
}
|
|
}
|
|
|
|
// GetSystemMode returns the current SystemMode (Home/Away/Sleep).
|
|
func (d *Detector) GetSystemMode() events.SystemMode {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
return d.securityModeToSystemMode(d.securityMode)
|
|
}
|
|
|
|
// ProcessOccupancy records an occupancy observation and checks for unusual hour anomalies.
|
|
func (d *Detector) ProcessOccupancy(zoneID string, personCount int, bleDevices []string, isSecurityMode bool) *events.AnomalyEvent {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
hourOfWeek := getHourOfWeek(now)
|
|
|
|
// Record the sample
|
|
d.recordOccupancySample(hourOfWeek, zoneID, personCount, bleDevices, now)
|
|
|
|
// Update person count for auto-away tracking
|
|
d.autoAwayState.LastPersonCount = personCount
|
|
if personCount > 0 {
|
|
d.autoAwayState.LastMotionTime = now
|
|
}
|
|
|
|
// Check for anomaly (only if model is ready, or if in security mode)
|
|
if !d.modelReady && !isSecurityMode {
|
|
return nil
|
|
}
|
|
|
|
key := fmt.Sprintf("%d-%s", hourOfWeek, zoneID)
|
|
slot, exists := d.behaviourSlots[key]
|
|
|
|
if !exists || slot.SampleCount < 10 {
|
|
// Not enough data for this slot
|
|
return nil
|
|
}
|
|
|
|
// Check if this is an unusual hour (low expected occupancy but we see people)
|
|
if personCount > 0 && slot.ExpectedOccupancy < 0.1 {
|
|
// Check cooldown for this anomaly type+zone
|
|
cooldownKey := cooldownKey{
|
|
anomalyType: events.AnomalyUnusualHour,
|
|
zoneID: zoneID,
|
|
}
|
|
if d.isInCooldown(cooldownKey, d.cooldownConfig.UnusualHourCooldown) {
|
|
return nil
|
|
}
|
|
|
|
score := d.config.UnusualHourScore
|
|
if isSecurityMode {
|
|
score = d.config.UnusualHourScoreSecurity
|
|
}
|
|
|
|
// Apply late night multiplier (00:00-06:00)
|
|
hour := now.Hour()
|
|
if hour >= 0 && hour < 6 {
|
|
score *= d.config.LateNightMultiplier
|
|
if score > 1.0 {
|
|
score = 1.0
|
|
}
|
|
}
|
|
|
|
// Get zone name
|
|
zoneName := zoneID
|
|
if d.zoneProvider != nil {
|
|
zoneName = d.zoneProvider.GetZoneName(zoneID)
|
|
}
|
|
|
|
// Create anomaly event
|
|
event := events.AnomalyEvent{
|
|
ID: uuid.New().String(),
|
|
Type: events.AnomalyUnusualHour,
|
|
Score: score,
|
|
Description: fmt.Sprintf("Motion detected in %s at %s (unusual hour)", zoneName, now.Format("3:04pm")),
|
|
Timestamp: now,
|
|
ZoneID: zoneID,
|
|
ZoneName: zoneName,
|
|
HourOfWeek: hourOfWeek,
|
|
ExpectedOccupancy: slot.ExpectedOccupancy,
|
|
}
|
|
|
|
// Mark cooldown
|
|
d.anomalyCooldowns[cooldownKey] = now
|
|
|
|
return d.createAnomaly(&event, isSecurityMode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ProcessBLEDevice checks for unknown BLE device anomalies.
|
|
func (d *Detector) ProcessBLEDevice(mac string, rssi int, isSecurityMode bool) *events.AnomalyEvent {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
|
|
// Track first seen time for this device
|
|
if _, exists := d.deviceFirstSeen[mac]; !exists {
|
|
d.deviceFirstSeen[mac] = now
|
|
d.db.Exec(`INSERT OR REPLACE INTO device_first_seen (mac, first_seen_ns) VALUES (?, ?)`, //nolint:errcheck
|
|
mac, now.UnixNano())
|
|
}
|
|
|
|
// Check if device is registered
|
|
if d.registeredDevices[mac] {
|
|
return nil
|
|
}
|
|
|
|
// Check if close range
|
|
if rssi < d.config.CloseRangeRSSIThreshold {
|
|
return nil // Not close enough to be concerning
|
|
}
|
|
|
|
// Check cooldown for this device
|
|
cooldownKey := cooldownKey{
|
|
anomalyType: events.AnomalyUnknownBLE,
|
|
deviceMAC: mac,
|
|
}
|
|
if d.isInCooldown(cooldownKey, d.cooldownConfig.UnknownBLECooldown) {
|
|
return nil
|
|
}
|
|
|
|
// Check if device was seen before
|
|
seenBefore := false
|
|
if d.deviceProvider != nil {
|
|
seenBefore = d.deviceProvider.IsDeviceSeenBefore(mac)
|
|
}
|
|
|
|
// Calculate score
|
|
var score float64
|
|
if !seenBefore {
|
|
// Never seen before
|
|
score = d.config.UnknownBLEScore
|
|
if isSecurityMode {
|
|
score = d.config.UnknownBLEScoreSecurity
|
|
}
|
|
} else {
|
|
// Seen before but not registered
|
|
score = d.config.SeenOnceScore
|
|
}
|
|
|
|
if score < d.getAlertThreshold(isSecurityMode) {
|
|
return nil
|
|
}
|
|
|
|
// Get device name
|
|
deviceName := mac
|
|
if d.deviceProvider != nil {
|
|
deviceName = d.deviceProvider.GetDeviceName(mac)
|
|
}
|
|
|
|
event := events.AnomalyEvent{
|
|
ID: uuid.New().String(),
|
|
Type: events.AnomalyUnknownBLE,
|
|
Score: score,
|
|
Description: fmt.Sprintf("Unknown device detected nearby: %s (RSSI: %d dBm)", deviceName, rssi),
|
|
Timestamp: now,
|
|
DeviceMAC: mac,
|
|
DeviceName: deviceName,
|
|
RSSIdBm: rssi,
|
|
SeenBefore: seenBefore,
|
|
}
|
|
|
|
// Mark cooldown
|
|
d.anomalyCooldowns[cooldownKey] = now
|
|
|
|
return d.createAnomaly(&event, isSecurityMode)
|
|
}
|
|
|
|
// ProcessMotionDuringAway checks for motion when system is in away mode.
|
|
func (d *Detector) ProcessMotionDuringAway(zoneID string, blobID int, isSecurityMode bool) *events.AnomalyEvent {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
|
|
// Check cooldown for this anomaly type+zone
|
|
cooldownKey := cooldownKey{
|
|
anomalyType: events.AnomalyMotionDuringAway,
|
|
zoneID: zoneID,
|
|
}
|
|
if d.isInCooldown(cooldownKey, d.cooldownConfig.MotionDuringAwayCooldown) {
|
|
return nil
|
|
}
|
|
|
|
// This anomaly always fires regardless of model training status
|
|
score := d.config.MotionDuringAwayScore
|
|
|
|
// Get zone name
|
|
zoneName := zoneID
|
|
if d.zoneProvider != nil {
|
|
zoneName = d.zoneProvider.GetZoneName(zoneID)
|
|
}
|
|
|
|
// Get position
|
|
var pos events.Position
|
|
if d.positionProvider != nil {
|
|
x, y, z, ok := d.positionProvider.GetBlobPosition(blobID)
|
|
if ok {
|
|
pos = events.Position{X: x, Y: y, Z: z}
|
|
}
|
|
}
|
|
|
|
event := events.AnomalyEvent{
|
|
ID: uuid.New().String(),
|
|
Type: events.AnomalyMotionDuringAway,
|
|
Score: score,
|
|
Description: fmt.Sprintf("Motion detected in %s while everyone is away", zoneName),
|
|
Timestamp: now,
|
|
ZoneID: zoneID,
|
|
ZoneName: zoneName,
|
|
BlobID: blobID,
|
|
Position: pos,
|
|
}
|
|
|
|
// Mark cooldown
|
|
d.anomalyCooldowns[cooldownKey] = now
|
|
|
|
return d.createAnomaly(&event, isSecurityMode)
|
|
}
|
|
|
|
// ProcessDwellDuration checks for unusual dwell duration.
|
|
func (d *Detector) ProcessDwellDuration(zoneID, personID string, dwellDuration time.Duration, isSecurityMode bool, isFallDetected bool) *events.AnomalyEvent {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
// Don't report if fall is already detected (fall detection takes priority)
|
|
if isFallDetected {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now()
|
|
hourOfWeek := getHourOfWeek(now)
|
|
|
|
// Record the sample
|
|
d.recordDwellSample(hourOfWeek, zoneID, personID, dwellDuration, now)
|
|
|
|
// Only check if model is ready (this anomaly requires learned patterns)
|
|
if !d.modelReady {
|
|
return nil
|
|
}
|
|
|
|
key := fmt.Sprintf("%d-%s-%s", hourOfWeek, zoneID, personID)
|
|
slot, exists := d.dwellSlots[key]
|
|
|
|
if !exists || slot.SampleCount < 5 {
|
|
return nil
|
|
}
|
|
|
|
// Check if dwelling for > 5x mean
|
|
if dwellDuration > time.Duration(float64(slot.MeanDwellDuration)*d.config.DwellMultiplierThreshold) {
|
|
// Check cooldown for this anomaly type+zone+person
|
|
cooldownKey := cooldownKey{
|
|
anomalyType: events.AnomalyUnusualDwell,
|
|
zoneID: zoneID,
|
|
personID: personID,
|
|
}
|
|
if d.isInCooldown(cooldownKey, d.cooldownConfig.UnusualDwellCooldown) {
|
|
return nil
|
|
}
|
|
|
|
score := d.config.UnusualDwellScore
|
|
|
|
// Get names
|
|
zoneName := zoneID
|
|
if d.zoneProvider != nil {
|
|
zoneName = d.zoneProvider.GetZoneName(zoneID)
|
|
}
|
|
personName := personID
|
|
if d.personProvider != nil {
|
|
personName = d.personProvider.GetPersonName(personID)
|
|
}
|
|
|
|
event := events.AnomalyEvent{
|
|
ID: uuid.New().String(),
|
|
Type: events.AnomalyUnusualDwell,
|
|
Score: score,
|
|
Description: fmt.Sprintf("%s in %s for longer than usual (%.0f minutes)", personName, zoneName, dwellDuration.Minutes()),
|
|
Timestamp: now,
|
|
ZoneID: zoneID,
|
|
ZoneName: zoneName,
|
|
PersonID: personID,
|
|
PersonName: personName,
|
|
DwellDuration: dwellDuration,
|
|
ExpectedDwell: slot.MeanDwellDuration,
|
|
}
|
|
|
|
// Mark cooldown
|
|
d.anomalyCooldowns[cooldownKey] = now
|
|
|
|
return d.createAnomaly(&event, isSecurityMode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *Detector) createAnomaly(event *events.AnomalyEvent, isSecurityMode bool) *events.AnomalyEvent {
|
|
threshold := d.getAlertThreshold(isSecurityMode)
|
|
if event.Score < threshold {
|
|
return nil
|
|
}
|
|
|
|
// Store in active anomalies
|
|
d.activeAnomalies[event.ID] = event
|
|
|
|
// Also append to history
|
|
d.anomalyHistory = append(d.anomalyHistory, event)
|
|
|
|
// Persist to database
|
|
d.persistAnomaly(event)
|
|
|
|
// Start alert chain
|
|
d.startAlertChain(event, isSecurityMode)
|
|
|
|
// Fire callback
|
|
if d.onAnomaly != nil {
|
|
go d.onAnomaly(*event)
|
|
}
|
|
|
|
log.Printf("[INFO] Anomaly detected: %s (score=%.2f, type=%s)", event.Description, event.Score, event.Type)
|
|
|
|
return event
|
|
}
|
|
|
|
func (d *Detector) getAlertThreshold(isSecurityMode bool) float64 {
|
|
if isSecurityMode {
|
|
return d.config.AlertThresholdSecurity
|
|
}
|
|
return d.config.AlertThresholdNormal
|
|
}
|
|
|
|
// isInCooldown checks if an anomaly key is in cooldown period.
|
|
func (d *Detector) isInCooldown(key cooldownKey, duration time.Duration) bool {
|
|
if lastTime, exists := d.anomalyCooldowns[key]; exists {
|
|
return time.Since(lastTime) < duration
|
|
}
|
|
return false
|
|
}
|
|
|
|
// cleanupStaleCooldowns removes expired cooldown entries.
|
|
func (d *Detector) cleanupStaleCooldowns() {
|
|
now := time.Now()
|
|
maxCooldown := d.cooldownConfig.UnusualDwellCooldown // Use the longest cooldown
|
|
for key, lastTime := range d.anomalyCooldowns {
|
|
if now.Sub(lastTime) > maxCooldown {
|
|
delete(d.anomalyCooldowns, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *Detector) recordOccupancySample(hourOfWeek int, zoneID string, personCount int, bleDevices []string, timestamp time.Time) {
|
|
devicesJSON, _ := jsonMarshal(bleDevices)
|
|
_, err := d.db.Exec(` //nolint:errcheck
|
|
INSERT INTO occupancy_samples (hour_of_week, zone_id, person_count, ble_devices, timestamp)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, hourOfWeek, zoneID, personCount, string(devicesJSON), timestamp.UnixNano())
|
|
if err != nil {
|
|
log.Printf("[WARN] Failed to record occupancy sample: %v", err)
|
|
}
|
|
}
|
|
|
|
func (d *Detector) recordDwellSample(hourOfWeek int, zoneID, personID string, dwellDuration time.Duration, timestamp time.Time) {
|
|
_, err := d.db.Exec(` //nolint:errcheck
|
|
INSERT INTO dwell_samples (hour_of_week, zone_id, person_id, dwell_ns, timestamp)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`, hourOfWeek, zoneID, personID, dwellDuration.Nanoseconds(), timestamp.UnixNano())
|
|
if err != nil {
|
|
log.Printf("[WARN] Failed to record dwell sample: %v", err)
|
|
}
|
|
}
|
|
|
|
func (d *Detector) persistAnomaly(event *events.AnomalyEvent) {
|
|
_, err := d.db.Exec(` //nolint:errcheck
|
|
INSERT INTO anomaly_events (
|
|
id, type, score, description, timestamp,
|
|
zone_id, zone_name, blob_id, person_id, person_name,
|
|
device_mac, device_name, position_x, position_y, position_z,
|
|
hour_of_week, expected_occupancy, dwell_duration_ns, expected_dwell_ns,
|
|
rssi_dbm, seen_before
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, event.ID, event.Type, event.Score, event.Description, event.Timestamp.UnixNano(),
|
|
nullString(event.ZoneID), nullString(event.ZoneName), event.BlobID,
|
|
nullString(event.PersonID), nullString(event.PersonName),
|
|
nullString(event.DeviceMAC), nullString(event.DeviceName),
|
|
event.Position.X, event.Position.Y, event.Position.Z,
|
|
event.HourOfWeek, event.ExpectedOccupancy,
|
|
event.DwellDuration.Nanoseconds(), event.ExpectedDwell.Nanoseconds(),
|
|
event.RSSIdBm, event.SeenBefore)
|
|
if err != nil {
|
|
log.Printf("[WARN] Failed to persist anomaly: %v", err)
|
|
}
|
|
}
|
|
|
|
func (d *Detector) startAlertChain(event *events.AnomalyEvent, isSecurityMode bool) {
|
|
state := &alertTimerState{
|
|
anomalyID: event.ID,
|
|
}
|
|
|
|
// T+0: Dashboard alarm (immediate - handled by UI via callback)
|
|
// Fire alert handler immediately for dashboard
|
|
if d.alertHandler != nil {
|
|
go d.alertHandler.SendAlert(*event, isSecurityMode) //nolint:errcheck
|
|
}
|
|
|
|
if isSecurityMode {
|
|
// Security mode: all alerts fire immediately
|
|
if d.alertHandler != nil {
|
|
d.alertHandler.SendWebhook(*event, true) //nolint:errcheck
|
|
d.alertHandler.SendEscalation(*event) //nolint:errcheck
|
|
}
|
|
event.AlertSent = true
|
|
event.WebhookSent = true
|
|
event.EscalationSent = true
|
|
now := time.Now()
|
|
event.AlertSentAt = now
|
|
event.WebhookSentAt = now
|
|
event.EscalationSentAt = now
|
|
d.updateAnomalyAlertState(event)
|
|
} else {
|
|
// Normal mode: staged alerts
|
|
// T+30s: notification
|
|
state.alertTimer = time.AfterFunc(30*time.Second, func() {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if anomaly, exists := d.activeAnomalies[event.ID]; exists && !anomaly.Acknowledged {
|
|
if d.alertHandler != nil {
|
|
d.alertHandler.SendAlert(*anomaly, false) //nolint:errcheck
|
|
}
|
|
anomaly.AlertSent = true
|
|
anomaly.AlertSentAt = time.Now()
|
|
d.updateAnomalyAlertState(anomaly)
|
|
}
|
|
})
|
|
|
|
// T+2min: webhook
|
|
state.webhookTimer = time.AfterFunc(2*time.Minute, func() {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if anomaly, exists := d.activeAnomalies[event.ID]; exists && !anomaly.Acknowledged {
|
|
if d.alertHandler != nil {
|
|
d.alertHandler.SendWebhook(*anomaly, false) //nolint:errcheck
|
|
}
|
|
anomaly.WebhookSent = true
|
|
anomaly.WebhookSentAt = time.Now()
|
|
d.updateAnomalyAlertState(anomaly)
|
|
}
|
|
})
|
|
|
|
// T+5min: escalation
|
|
state.escalationTimer = time.AfterFunc(5*time.Minute, func() {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if anomaly, exists := d.activeAnomalies[event.ID]; exists && !anomaly.Acknowledged {
|
|
if d.alertHandler != nil {
|
|
d.alertHandler.SendEscalation(*anomaly) //nolint:errcheck
|
|
}
|
|
anomaly.EscalationSent = true
|
|
anomaly.EscalationSentAt = time.Now()
|
|
d.updateAnomalyAlertState(anomaly)
|
|
}
|
|
})
|
|
}
|
|
|
|
d.pendingAlerts[event.ID] = state
|
|
}
|
|
|
|
func (d *Detector) updateAnomalyAlertState(event *events.AnomalyEvent) {
|
|
_, _ = d.db.Exec(` //nolint:errcheck
|
|
UPDATE anomaly_events SET
|
|
alert_sent = ?, alert_sent_at = ?,
|
|
webhook_sent = ?, webhook_sent_at = ?,
|
|
escalation_sent = ?, escalation_sent_at = ?
|
|
WHERE id = ?
|
|
`, event.AlertSent, nullTime(event.AlertSentAt),
|
|
event.WebhookSent, nullTime(event.WebhookSentAt),
|
|
event.EscalationSent, nullTime(event.EscalationSentAt),
|
|
event.ID)
|
|
}
|
|
|
|
// AcknowledgeAnomaly acknowledges an anomaly and cancels pending timers.
|
|
func (d *Detector) AcknowledgeAnomaly(anomalyID, feedback, acknowledgedBy string) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
event, exists := d.activeAnomalies[anomalyID]
|
|
if !exists {
|
|
return fmt.Errorf("anomaly not found: %s", anomalyID)
|
|
}
|
|
|
|
// Cancel pending timers
|
|
if state, exists := d.pendingAlerts[anomalyID]; exists {
|
|
if state.alertTimer != nil {
|
|
state.alertTimer.Stop()
|
|
}
|
|
if state.webhookTimer != nil {
|
|
state.webhookTimer.Stop()
|
|
}
|
|
if state.escalationTimer != nil {
|
|
state.escalationTimer.Stop()
|
|
}
|
|
delete(d.pendingAlerts, anomalyID)
|
|
}
|
|
|
|
// Update event
|
|
event.Acknowledged = true
|
|
event.AcknowledgedAt = time.Now()
|
|
event.Feedback = feedback
|
|
event.AcknowledgedBy = acknowledgedBy
|
|
|
|
// Update database
|
|
_, err := d.db.Exec(` //nolint:errcheck
|
|
UPDATE anomaly_events SET
|
|
acknowledged = 1,
|
|
acknowledged_at = ?,
|
|
feedback = ?
|
|
WHERE id = ?
|
|
`, event.AcknowledgedAt.UnixNano(), feedback, anomalyID)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Record feedback to learning store for accuracy tracking
|
|
if d.feedbackStore != nil && feedback != "" {
|
|
feedbackType := mapFeedbackToLearningType(feedback)
|
|
details := map[string]interface{}{
|
|
"anomaly_type": string(event.Type),
|
|
"score": event.Score,
|
|
"zone_id": event.ZoneID,
|
|
"person_id": event.PersonID,
|
|
"device_mac": event.DeviceMAC,
|
|
"hour_of_week": event.HourOfWeek,
|
|
"acknowledged_by": acknowledgedBy,
|
|
}
|
|
if event.Position.X != 0 || event.Position.Y != 0 || event.Position.Z != 0 {
|
|
details["position_x"] = event.Position.X
|
|
details["position_y"] = event.Position.Y
|
|
details["position_z"] = event.Position.Z
|
|
}
|
|
|
|
learningFeedback := learning.FeedbackRecord{
|
|
ID: uuid.New().String(),
|
|
EventID: anomalyID,
|
|
EventType: learning.Anomaly,
|
|
FeedbackType: feedbackType,
|
|
Details: details,
|
|
Timestamp: time.Now(),
|
|
Applied: false,
|
|
}
|
|
|
|
if err := d.feedbackStore.RecordFeedback(learningFeedback); err != nil {
|
|
log.Printf("[WARN] Failed to record anomaly feedback to learning store: %v", err)
|
|
} else {
|
|
log.Printf("[INFO] Recorded anomaly feedback: %s -> %s", anomalyID, feedbackType)
|
|
}
|
|
}
|
|
|
|
log.Printf("[INFO] Anomaly acknowledged: %s (feedback: %s)", anomalyID, feedback)
|
|
|
|
return nil
|
|
}
|
|
|
|
// mapFeedbackToLearningType maps anomaly feedback to learning feedback types.
|
|
func mapFeedbackToLearningType(feedback string) learning.FeedbackType {
|
|
switch feedback {
|
|
case "expected":
|
|
return learning.FalsePositive // User says it was expected, so detection was wrong
|
|
case "intrusion":
|
|
return learning.TruePositive // User confirms it was real
|
|
case "false_alarm":
|
|
return learning.FalsePositive
|
|
default:
|
|
return learning.FalsePositive
|
|
}
|
|
}
|
|
|
|
// UpdateBehaviourModel updates the behaviour model from collected samples.
|
|
// Should be called periodically (e.g., weekly).
|
|
func (d *Detector) UpdateBehaviourModel() error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
log.Printf("[INFO] Updating behaviour model from collected samples...")
|
|
|
|
// Update behaviour slots from occupancy samples
|
|
// First, collect all slots into memory to avoid holding a query connection
|
|
// while doing nested queries (deadlock with SetMaxOpenConns(1)).
|
|
type aggSlot struct {
|
|
HourOfWeek int
|
|
ZoneID string
|
|
ExpectedOccupancy float64
|
|
TypicalPersonCount float64
|
|
SampleCount int
|
|
}
|
|
rows, err := d.db.Query(`
|
|
SELECT hour_of_week, zone_id,
|
|
AVG(CASE WHEN person_count > 0 THEN 1.0 ELSE 0.0 END) as expected_occupancy,
|
|
AVG(person_count) as typical_person_count,
|
|
COUNT(*) as sample_count
|
|
FROM occupancy_samples
|
|
GROUP BY hour_of_week, zone_id
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var slots []aggSlot
|
|
for rows.Next() {
|
|
var s aggSlot
|
|
if err := rows.Scan(&s.HourOfWeek, &s.ZoneID, &s.ExpectedOccupancy,
|
|
&s.TypicalPersonCount, &s.SampleCount); err != nil {
|
|
continue
|
|
}
|
|
slots = append(slots, s)
|
|
}
|
|
rows.Close() //nolint:errcheck
|
|
|
|
for _, s := range slots {
|
|
slot := &NormalBehaviourSlot{
|
|
TypicalBLEDevices: make(map[string]float64),
|
|
}
|
|
slot.HourOfWeek = s.HourOfWeek
|
|
slot.ZoneID = s.ZoneID
|
|
slot.ExpectedOccupancy = s.ExpectedOccupancy
|
|
slot.TypicalPersonCount = s.TypicalPersonCount
|
|
slot.SampleCount = s.SampleCount
|
|
|
|
// Calculate typical BLE devices (seen in > 50% of this slot)
|
|
bleRows, err := d.db.Query(`
|
|
SELECT ble_devices FROM occupancy_samples
|
|
WHERE hour_of_week = ? AND zone_id = ?
|
|
`, slot.HourOfWeek, slot.ZoneID)
|
|
if err == nil {
|
|
deviceCounts := make(map[string]int)
|
|
totalSamples := 0
|
|
for bleRows.Next() {
|
|
var devicesJSON string
|
|
if err := bleRows.Scan(&devicesJSON); err != nil {
|
|
continue
|
|
}
|
|
var devices []string
|
|
if jsonUnmarshal(devicesJSON, &devices) == nil {
|
|
totalSamples++
|
|
for _, mac := range devices {
|
|
deviceCounts[mac]++
|
|
}
|
|
}
|
|
}
|
|
bleRows.Close() //nolint:errcheck
|
|
|
|
// Only include devices seen > 50% of the time
|
|
if totalSamples > 0 {
|
|
for mac, count := range deviceCounts {
|
|
frequency := float64(count) / float64(totalSamples)
|
|
if frequency > 0.5 {
|
|
slot.TypicalBLEDevices[mac] = frequency
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Upsert to database
|
|
devicesJSON, _ := jsonMarshal(slot.TypicalBLEDevices)
|
|
_, _ = d.db.Exec(` //nolint:errcheck
|
|
INSERT INTO behaviour_slots (hour_of_week, zone_id, expected_occupancy, typical_person_count, sample_count, typical_ble_devices)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(hour_of_week, zone_id) DO UPDATE SET
|
|
expected_occupancy = excluded.expected_occupancy,
|
|
typical_person_count = excluded.typical_person_count,
|
|
sample_count = excluded.sample_count,
|
|
typical_ble_devices = excluded.typical_ble_devices
|
|
`, slot.HourOfWeek, slot.ZoneID, slot.ExpectedOccupancy,
|
|
slot.TypicalPersonCount, slot.SampleCount, string(devicesJSON))
|
|
|
|
key := fmt.Sprintf("%d-%s", slot.HourOfWeek, slot.ZoneID)
|
|
d.behaviourSlots[key] = slot
|
|
}
|
|
|
|
// Update dwell slots
|
|
dwellRows, err := d.db.Query(`
|
|
SELECT hour_of_week, zone_id, person_id,
|
|
AVG(dwell_ns) as mean_dwell_ns,
|
|
SQRT(MAX(0, AVG(dwell_ns * dwell_ns) - AVG(dwell_ns) * AVG(dwell_ns))) as std_dwell_ns,
|
|
COUNT(*) as sample_count
|
|
FROM dwell_samples
|
|
GROUP BY hour_of_week, zone_id, person_id
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dwellRows.Close() //nolint:errcheck
|
|
|
|
for dwellRows.Next() {
|
|
slot := &DwellBehaviourSlot{}
|
|
var meanNS, stdNS int64
|
|
if err := dwellRows.Scan(&slot.HourOfWeek, &slot.ZoneID, &slot.PersonID,
|
|
&meanNS, &stdNS, &slot.SampleCount); err != nil {
|
|
continue
|
|
}
|
|
slot.MeanDwellDuration = time.Duration(meanNS)
|
|
slot.StdDwellDuration = time.Duration(stdNS)
|
|
|
|
_, _ = d.db.Exec(` //nolint:errcheck
|
|
INSERT INTO dwell_slots (hour_of_week, zone_id, person_id, mean_dwell_ns, std_dwell_ns, sample_count)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(hour_of_week, zone_id, person_id) DO UPDATE SET
|
|
mean_dwell_ns = excluded.mean_dwell_ns,
|
|
std_dwell_ns = excluded.std_dwell_ns,
|
|
sample_count = excluded.sample_count
|
|
`, slot.HourOfWeek, slot.ZoneID, slot.PersonID,
|
|
slot.MeanDwellDuration.Nanoseconds(), slot.StdDwellDuration.Nanoseconds(), slot.SampleCount)
|
|
|
|
key := fmt.Sprintf("%d-%s-%s", slot.HourOfWeek, slot.ZoneID, slot.PersonID)
|
|
d.dwellSlots[key] = slot
|
|
}
|
|
|
|
// Check if model should become ready
|
|
if !d.modelReady && time.Since(d.learningStartTime) >= 7*24*time.Hour {
|
|
d.modelReady = true
|
|
d.modelReadyAt = time.Now()
|
|
log.Printf("[INFO] Behaviour model is now ready after 7 days of learning")
|
|
}
|
|
|
|
log.Printf("[INFO] Behaviour model updated: %d occupancy slots, %d dwell slots",
|
|
len(d.behaviourSlots), len(d.dwellSlots))
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetActiveAnomalies returns all unacknowledged anomalies.
|
|
func (d *Detector) GetActiveAnomalies() []*events.AnomalyEvent {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
|
|
result := make([]*events.AnomalyEvent, 0, len(d.activeAnomalies))
|
|
for _, event := range d.activeAnomalies {
|
|
if !event.Acknowledged {
|
|
result = append(result, event)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetAnomalyHistory returns recent anomaly events from memory.
|
|
func (d *Detector) GetAnomalyHistory(limit int) []*events.AnomalyEvent {
|
|
d.mu.RLock()
|
|
history := d.anomalyHistory
|
|
d.mu.RUnlock()
|
|
|
|
if len(history) <= limit {
|
|
return history
|
|
}
|
|
return history[len(history)-limit:]
|
|
}
|
|
|
|
// QueryAnomalyEvents queries persisted anomaly events from the database.
|
|
// This works across server restarts unlike GetAnomalyHistory which is in-memory only.
|
|
func (d *Detector) QueryAnomalyEvents(since time.Time, limit int) ([]*events.AnomalyEvent, error) {
|
|
rows, err := d.db.Query(`
|
|
SELECT id, type, score, description, timestamp,
|
|
zone_id, zone_name, blob_id, person_id, person_name,
|
|
device_mac, device_name, position_x, position_y, position_z,
|
|
acknowledged
|
|
FROM anomaly_events
|
|
WHERE timestamp >= ?
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?
|
|
`, since.UnixNano(), limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query anomaly events: %w", err)
|
|
}
|
|
defer rows.Close() //nolint:errcheck
|
|
|
|
var result []*events.AnomalyEvent
|
|
for rows.Next() {
|
|
var e events.AnomalyEvent
|
|
var tsNS int64
|
|
var acknowledged int
|
|
if err := rows.Scan(&e.ID, &e.Type, &e.Score, &e.Description, &tsNS,
|
|
&e.ZoneID, &e.ZoneName, &e.BlobID,
|
|
&e.PersonID, &e.PersonName,
|
|
&e.DeviceMAC, &e.DeviceName,
|
|
&e.Position.X, &e.Position.Y, &e.Position.Z,
|
|
&acknowledged); err != nil {
|
|
continue
|
|
}
|
|
e.Timestamp = time.Unix(0, tsNS)
|
|
e.Acknowledged = acknowledged == 1
|
|
result = append(result, &e)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// CountAnomaliesSince returns the count of anomaly events since the given time.
|
|
func (d *Detector) CountAnomaliesSince(since time.Time) (int, error) {
|
|
var count int
|
|
err := d.db.QueryRow(
|
|
`SELECT COUNT(*) FROM anomaly_events WHERE timestamp >= ?`,
|
|
since.UnixNano(),
|
|
).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
// GetWeeklySummary returns a summary of anomalies for the past week.
|
|
func (d *Detector) GetWeeklySummary() events.WeeklyAnomalySummary {
|
|
d.mu.RLock()
|
|
defer d.mu.RUnlock()
|
|
|
|
summary := events.WeeklyAnomalySummary{
|
|
ByType: make(map[events.AnomalyType]int),
|
|
}
|
|
|
|
oneWeekAgo := time.Now().Add(-7 * 24 * time.Hour)
|
|
|
|
for _, event := range d.anomalyHistory {
|
|
if event.Timestamp.Before(oneWeekAgo) {
|
|
continue
|
|
}
|
|
|
|
summary.TotalAnomalies++
|
|
summary.ByType[event.Type]++
|
|
|
|
if event.Acknowledged {
|
|
switch event.Feedback {
|
|
case "expected":
|
|
summary.ExpectedEvents++
|
|
case "intrusion":
|
|
summary.GenuineIntrusions++
|
|
case "false_alarm":
|
|
summary.FalseAlarms++
|
|
}
|
|
} else {
|
|
summary.Unacknowledged++
|
|
}
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
// ClearAnomaly removes an anomaly from active state.
|
|
func (d *Detector) ClearAnomaly(anomalyID string) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
// Cancel timers
|
|
if state, exists := d.pendingAlerts[anomalyID]; exists {
|
|
if state.alertTimer != nil {
|
|
state.alertTimer.Stop()
|
|
}
|
|
if state.webhookTimer != nil {
|
|
state.webhookTimer.Stop()
|
|
}
|
|
if state.escalationTimer != nil {
|
|
state.escalationTimer.Stop()
|
|
}
|
|
delete(d.pendingAlerts, anomalyID)
|
|
}
|
|
|
|
// Move to history
|
|
if event, exists := d.activeAnomalies[anomalyID]; exists {
|
|
d.anomalyHistory = append(d.anomalyHistory, event)
|
|
delete(d.activeAnomalies, anomalyID)
|
|
}
|
|
}
|
|
|
|
// RunPeriodicUpdate starts a goroutine that updates the behaviour model periodically.
|
|
func (d *Detector) RunPeriodicUpdate(ctx context.Context, interval time.Duration) {
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := d.UpdateBehaviourModel(); err != nil {
|
|
log.Printf("[WARN] Failed to update behaviour model: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// getHourOfWeek returns the hour of the week (0-167) for a given time.
|
|
func getHourOfWeek(t time.Time) int {
|
|
weekday := int(t.Weekday())
|
|
hour := t.Hour()
|
|
return weekday*24 + hour
|
|
}
|
|
|
|
func nullString(s string) interface{} {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
func nullTime(t time.Time) interface{} {
|
|
if t.IsZero() {
|
|
return nil
|
|
}
|
|
return t.UnixNano()
|
|
}
|
|
|
|
// JSON helpers using standard library
|
|
func jsonMarshal(v interface{}) ([]byte, error) {
|
|
return json.Marshal(v)
|
|
}
|
|
|
|
func jsonUnmarshal(data string, v interface{}) error {
|
|
return json.Unmarshal([]byte(data), v)
|
|
}
|
|
|
|
// Math helper
|
|
var _ = math.E // Use math package to avoid unused import error
|