style(mothership): run go fmt to format all Go code
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Ran gofmt across the entire mothership codebase to ensure consistent code formatting per Go standards. All tests pass after formatting.
This commit is contained in:
parent
7b6feb9318
commit
c0ae81a6b0
216 changed files with 3527 additions and 3514 deletions
|
|
@ -4,12 +4,14 @@
|
|||
// This is a standalone binary for running migrations without starting the full application.
|
||||
//
|
||||
// Build:
|
||||
// go build -tags ignore_migrate -o migrate ./cmd/mothership
|
||||
//
|
||||
// go build -tags ignore_migrate -o migrate ./cmd/mothership
|
||||
//
|
||||
// Usage:
|
||||
// ./migrate [options]
|
||||
// ./migrate --version
|
||||
// ./migrate --prune
|
||||
//
|
||||
// ./migrate [options]
|
||||
// ./migrate --version
|
||||
// ./migrate --prune
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
const (
|
||||
// WiFi physical constants
|
||||
wavelength = 0.123 // meters (2.4 GHz)
|
||||
wavelength = 0.123 // meters (2.4 GHz)
|
||||
halfWavelength = wavelength / 2.0
|
||||
subcarrierSpacing = 312.5e3 // Hz
|
||||
c = 3e8 // speed of light m/s
|
||||
|
|
@ -18,9 +18,9 @@ const (
|
|||
|
||||
// Path loss model constants (log-distance model)
|
||||
// PL(d) = PL_0 + 10*n*log10(d/d_0)
|
||||
pl0 = 40.0 // dBm reference power at d0
|
||||
d0 = 1.0 // meters reference distance
|
||||
n = 2.0 // path loss exponent (free space)
|
||||
pl0 = 40.0 // dBm reference power at d0
|
||||
d0 = 1.0 // meters reference distance
|
||||
n = 2.0 // path loss exponent (free space)
|
||||
|
||||
// Reflection coefficient (power, dimensionless)
|
||||
reflectionCoeff = 0.3
|
||||
|
|
@ -50,14 +50,14 @@ func generateCSIFrame(tx, rx *VirtualNode, walkers []*Walker, walls []Wall, fram
|
|||
frame := make([]byte, headerSize+nSub*2)
|
||||
|
||||
// Write header (matches ingestion/frame.go ParseFrame layout)
|
||||
copy(frame[0:6], tx.MAC[:]) // node_mac
|
||||
copy(frame[6:12], rx.MAC[:]) // peer_mac
|
||||
copy(frame[0:6], tx.MAC[:]) // node_mac
|
||||
copy(frame[6:12], rx.MAC[:]) // peer_mac
|
||||
binary.LittleEndian.PutUint64(frame[12:20], uint64(frameNum*50000)) // timestamp_us
|
||||
frame[20] = byte(rssi) // rssi
|
||||
frame[20] = byte(rssi) // rssi
|
||||
var noiseFloor int8 = -95
|
||||
frame[21] = byte(noiseFloor) // noise_floor: -95 dBm
|
||||
frame[21] = byte(noiseFloor) // noise_floor: -95 dBm
|
||||
frame[22] = byte(*flagChannel) // channel (from --channel flag)
|
||||
frame[23] = nSub // n_sub
|
||||
frame[23] = nSub // n_sub
|
||||
|
||||
// Generate I/Q pairs for each subcarrier
|
||||
for k := 0; k < nSub; k++ {
|
||||
|
|
|
|||
|
|
@ -35,36 +35,36 @@ const (
|
|||
defaultMothership = "ws://localhost:8080/ws/node"
|
||||
defaultNodes = 2
|
||||
defaultWalkers = 1
|
||||
defaultRate = 20 // Hz
|
||||
defaultDuration = 60 // seconds
|
||||
defaultChannel = 6 // 2.4 GHz channel 6
|
||||
defaultSeed = 0 // random seed (0 = use current time)
|
||||
defaultRate = 20 // Hz
|
||||
defaultDuration = 60 // seconds
|
||||
defaultChannel = 6 // 2.4 GHz channel 6
|
||||
defaultSeed = 0 // random seed (0 = use current time)
|
||||
defaultSpace = "5x5x2.5" // room dimensions
|
||||
defaultNoiseSigma = 0.005
|
||||
)
|
||||
|
||||
var (
|
||||
// CLI flags
|
||||
flagMothership = flag.String("mothership", defaultMothership, "URL of the mothership WebSocket endpoint")
|
||||
flagToken = flag.String("token", "", "Provisioning token (auto-generated if empty)")
|
||||
flagNodes = flag.Int("nodes", defaultNodes, "Number of virtual nodes")
|
||||
flagWalkers = flag.Int("walkers", defaultWalkers, "Number of synthetic walkers")
|
||||
flagRate = flag.Int("rate", defaultRate, "CSI transmission rate in Hz per node pair")
|
||||
flagDuration = flag.Int("duration", defaultDuration, "Total run time in seconds (0 = run until Ctrl+C)")
|
||||
flagSeed = flag.Int64("seed", defaultSeed, "Random seed for reproducible walker paths")
|
||||
flagSpace = flag.String("space", defaultSpace, "Room dimensions in WxDxH format (meters)")
|
||||
flagBLE = flag.Bool("ble", false, "Include synthetic BLE advertisements")
|
||||
flagVerify = flag.Bool("verify", false, "Verify blob detection after duration")
|
||||
flagNoiseSigma = flag.Float64("noise-sigma", defaultNoiseSigma, "Gaussian noise standard deviation for I/Q")
|
||||
flagOutputCSV = flag.String("output-csv", "", "Write ground truth to CSV file")
|
||||
flagChannel = flag.Int("channel", defaultChannel, "WiFi channel (1-14 for 2.4 GHz)")
|
||||
flagWalkerType = flag.String("walker-type", "random", "Walker type: random, path, node-to-node")
|
||||
flagPathFile = flag.String("path-file", "", "JSON file containing walker paths")
|
||||
flagMothership = flag.String("mothership", defaultMothership, "URL of the mothership WebSocket endpoint")
|
||||
flagToken = flag.String("token", "", "Provisioning token (auto-generated if empty)")
|
||||
flagNodes = flag.Int("nodes", defaultNodes, "Number of virtual nodes")
|
||||
flagWalkers = flag.Int("walkers", defaultWalkers, "Number of synthetic walkers")
|
||||
flagRate = flag.Int("rate", defaultRate, "CSI transmission rate in Hz per node pair")
|
||||
flagDuration = flag.Int("duration", defaultDuration, "Total run time in seconds (0 = run until Ctrl+C)")
|
||||
flagSeed = flag.Int64("seed", defaultSeed, "Random seed for reproducible walker paths")
|
||||
flagSpace = flag.String("space", defaultSpace, "Room dimensions in WxDxH format (meters)")
|
||||
flagBLE = flag.Bool("ble", false, "Include synthetic BLE advertisements")
|
||||
flagVerify = flag.Bool("verify", false, "Verify blob detection after duration")
|
||||
flagNoiseSigma = flag.Float64("noise-sigma", defaultNoiseSigma, "Gaussian noise standard deviation for I/Q")
|
||||
flagOutputCSV = flag.String("output-csv", "", "Write ground truth to CSV file")
|
||||
flagChannel = flag.Int("channel", defaultChannel, "WiFi channel (1-14 for 2.4 GHz)")
|
||||
flagWalkerType = flag.String("walker-type", "random", "Walker type: random, path, node-to-node")
|
||||
flagPathFile = flag.String("path-file", "", "JSON file containing walker paths")
|
||||
|
||||
// GDOP and shopping list flags
|
||||
flagGDOPOverlay = flag.Bool("gdop-overlay", false, "Output GDOP overlay data as JSON to stdout")
|
||||
flagGDOPOverlay = flag.Bool("gdop-overlay", false, "Output GDOP overlay data as JSON to stdout")
|
||||
flagShoppingList = flag.Bool("shopping-list", false, "Output shopping list as JSON to stdout")
|
||||
flagCellSize = flag.Float64("cell-size", 0.2, "GDOP grid cell size in meters")
|
||||
flagCellSize = flag.Float64("cell-size", 0.2, "GDOP grid cell size in meters")
|
||||
|
||||
// Scenario flags
|
||||
flagScenario = flag.String("scenario", "normal", "Scenario type: normal, fall, ota, bag-on-couch")
|
||||
|
|
@ -143,9 +143,9 @@ type VirtualNode struct {
|
|||
type WalkerType string
|
||||
|
||||
const (
|
||||
WalkerTypeRandomWalk WalkerType = "random"
|
||||
WalkerTypePathFollow WalkerType = "path"
|
||||
WalkerTypeNodeToNode WalkerType = "node-to-node"
|
||||
WalkerTypeRandomWalk WalkerType = "random"
|
||||
WalkerTypePathFollow WalkerType = "path"
|
||||
WalkerTypeNodeToNode WalkerType = "node-to-node"
|
||||
)
|
||||
|
||||
// Walker represents a simulated person
|
||||
|
|
@ -156,10 +156,10 @@ type Walker struct {
|
|||
Speed float64
|
||||
Height float64
|
||||
Type WalkerType
|
||||
Path []Point // For path-following mode
|
||||
PathIdx int // Current position along path
|
||||
Path []Point // For path-following mode
|
||||
PathIdx int // Current position along path
|
||||
Nodes []*VirtualNode // For node-to-node mode
|
||||
NodeIdx int // Current target node index
|
||||
NodeIdx int // Current target node index
|
||||
}
|
||||
|
||||
// Point represents a 3D position
|
||||
|
|
@ -191,8 +191,8 @@ const (
|
|||
// Wall represents a wall segment with material properties
|
||||
type Wall struct {
|
||||
X1, Y1, X2, Y2 float64
|
||||
Material WallMaterial
|
||||
Attenuation float64 // dB loss
|
||||
Material WallMaterial
|
||||
Attenuation float64 // dB loss
|
||||
}
|
||||
|
||||
// Stats tracks simulation statistics
|
||||
|
|
@ -395,7 +395,7 @@ func createVirtualNodes(count int, space *Space, rng *rand.Rand) []*VirtualNode
|
|||
}
|
||||
|
||||
// Distribute nodes around perimeter
|
||||
perimeter := 2*(space.Width+space.Depth)
|
||||
perimeter := 2 * (space.Width + space.Depth)
|
||||
pos := float64(i) / float64(count) * perimeter
|
||||
|
||||
if pos < space.Width {
|
||||
|
|
@ -1334,25 +1334,25 @@ func outputGDOPOverlay(space *Space, nodes []*VirtualNode, cellSize float64) err
|
|||
|
||||
// Output JSON
|
||||
output := map[string]interface{}{
|
||||
"type": "gdop_overlay",
|
||||
"type": "gdop_overlay",
|
||||
"space_dimensions": map[string]float64{
|
||||
"width_m": space.Width,
|
||||
"depth_m": space.Depth,
|
||||
"height_m": space.Height,
|
||||
},
|
||||
"grid_dimensions": []int{heatmapData.Width, heatmapData.Depth, 1},
|
||||
"cell_size_m": cellSize,
|
||||
"origin": map[string]float64{"x": heatmapData.OriginX, "y": heatmapData.OriginY},
|
||||
"gdop_values": heatmapData.GDOPValues,
|
||||
"qualities": heatmapData.Qualities,
|
||||
"colors": heatmapData.Colors,
|
||||
"accuracy_map": heatmapData.AccuracyMap,
|
||||
"coverage_score": gdopComp.CoverageScore(results),
|
||||
"average_gdop": avgGDOPOutput,
|
||||
"quality_counts": gdopComp.QualityCounts(results),
|
||||
"dead_zones": gdopComp.FindDeadZones(results),
|
||||
"links": links,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"cell_size_m": cellSize,
|
||||
"origin": map[string]float64{"x": heatmapData.OriginX, "y": heatmapData.OriginY},
|
||||
"gdop_values": heatmapData.GDOPValues,
|
||||
"qualities": heatmapData.Qualities,
|
||||
"colors": heatmapData.Colors,
|
||||
"accuracy_map": heatmapData.AccuracyMap,
|
||||
"coverage_score": gdopComp.CoverageScore(results),
|
||||
"average_gdop": avgGDOPOutput,
|
||||
"quality_counts": gdopComp.QualityCounts(results),
|
||||
"dead_zones": gdopComp.FindDeadZones(results),
|
||||
"links": links,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
|
|
@ -1399,10 +1399,10 @@ func outputShoppingList(space *Space, nodes []*VirtualNode) error {
|
|||
|
||||
// Output JSON
|
||||
output := map[string]interface{}{
|
||||
"type": "shopping_list",
|
||||
"list": shoppingList,
|
||||
"type": "shopping_list",
|
||||
"list": shoppingList,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"note": "This is a pre-deployment estimate. Actual accuracy may vary based on environment.",
|
||||
"note": "This is a pre-deployment estimate. Actual accuracy may vary based on environment.",
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
|
|
|
|||
|
|
@ -464,15 +464,15 @@ func TestDeltaRMSComputation(t *testing.T) {
|
|||
rx := Point{X: 5, Y: 0, Z: 2}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
walker Point
|
||||
minRMS float64
|
||||
maxRMS float64
|
||||
name string
|
||||
walker Point
|
||||
minRMS float64
|
||||
maxRMS float64
|
||||
}{
|
||||
{
|
||||
name: "Walker on direct line (zone 1)",
|
||||
walker: Point{X: 2.5, Y: 0, Z: 1.7},
|
||||
minRMS: 0.1, // zone 1 should have high deltaRMS
|
||||
minRMS: 0.1, // zone 1 should have high deltaRMS
|
||||
maxRMS: 0.2,
|
||||
},
|
||||
{
|
||||
|
|
@ -540,7 +540,7 @@ func TestWalkerBounce(t *testing.T) {
|
|||
ID: 0,
|
||||
Type: WalkerTypeRandomWalk,
|
||||
Position: Point{X: 0.1, Y: 2.5, Z: 1.7}, // Near left wall
|
||||
Velocity: Point{X: -1.0, Y: 0, Z: 0}, // Moving left
|
||||
Velocity: Point{X: -1.0, Y: 0, Z: 0}, // Moving left
|
||||
Speed: 1.0,
|
||||
Height: 1.7,
|
||||
}
|
||||
|
|
@ -845,29 +845,29 @@ func TestLineIntersection(t *testing.T) {
|
|||
wantY float64
|
||||
}{
|
||||
{
|
||||
name: "Crossing lines",
|
||||
p1: Point{X: 0, Y: 0, Z: 0},
|
||||
p2: Point{X: 2, Y: 2, Z: 0},
|
||||
p3: Point{X: 0, Y: 2, Z: 0},
|
||||
p4: Point{X: 2, Y: 0, Z: 0},
|
||||
name: "Crossing lines",
|
||||
p1: Point{X: 0, Y: 0, Z: 0},
|
||||
p2: Point{X: 2, Y: 2, Z: 0},
|
||||
p3: Point{X: 0, Y: 2, Z: 0},
|
||||
p4: Point{X: 2, Y: 0, Z: 0},
|
||||
wantOK: true,
|
||||
wantX: 1,
|
||||
wantY: 1,
|
||||
},
|
||||
{
|
||||
name: "Parallel lines",
|
||||
p1: Point{X: 0, Y: 0, Z: 0},
|
||||
p2: Point{X: 1, Y: 0, Z: 0},
|
||||
p3: Point{X: 0, Y: 1, Z: 0},
|
||||
p4: Point{X: 1, Y: 1, Z: 0},
|
||||
name: "Parallel lines",
|
||||
p1: Point{X: 0, Y: 0, Z: 0},
|
||||
p2: Point{X: 1, Y: 0, Z: 0},
|
||||
p3: Point{X: 0, Y: 1, Z: 0},
|
||||
p4: Point{X: 1, Y: 1, Z: 0},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "Non-intersecting segments",
|
||||
p1: Point{X: 0, Y: 0, Z: 0},
|
||||
p2: Point{X: 1, Y: 0, Z: 0},
|
||||
p3: Point{X: 2, Y: 0, Z: 0},
|
||||
p4: Point{X: 3, Y: 0, Z: 0},
|
||||
name: "Non-intersecting segments",
|
||||
p1: Point{X: 0, Y: 0, Z: 0},
|
||||
p2: Point{X: 1, Y: 0, Z: 0},
|
||||
p3: Point{X: 2, Y: 0, Z: 0},
|
||||
p4: Point{X: 3, Y: 0, Z: 0},
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ const (
|
|||
|
||||
// ScenarioConfig holds scenario-specific configuration
|
||||
type ScenarioConfig struct {
|
||||
Type ScenarioType
|
||||
FallParams FallScenarioParams
|
||||
OTAParams OTAScenarioParams
|
||||
StartedAt time.Time
|
||||
Phase string // for multi-phase scenarios
|
||||
Type ScenarioType
|
||||
FallParams FallScenarioParams
|
||||
OTAParams OTAScenarioParams
|
||||
StartedAt time.Time
|
||||
Phase string // for multi-phase scenarios
|
||||
}
|
||||
|
||||
// FallScenarioParams defines parameters for fall detection scenario
|
||||
|
|
@ -46,12 +46,12 @@ type FallScenarioParams struct {
|
|||
|
||||
// OTAScenarioParams defines parameters for OTA update scenario
|
||||
type OTAScenarioParams struct {
|
||||
UpdateAfter time.Duration // Time before OTA starts
|
||||
FirmwareSize int64 // Size of firmware in bytes
|
||||
NewVersion string // New firmware version
|
||||
RebootDelay time.Duration // Delay before rebooting
|
||||
BootFailDuration time.Duration // How long to simulate boot failure (for rollback test)
|
||||
SimulateFailure bool // Whether to simulate a boot failure
|
||||
UpdateAfter time.Duration // Time before OTA starts
|
||||
FirmwareSize int64 // Size of firmware in bytes
|
||||
NewVersion string // New firmware version
|
||||
RebootDelay time.Duration // Delay before rebooting
|
||||
BootFailDuration time.Duration // How long to simulate boot failure (for rollback test)
|
||||
SimulateFailure bool // Whether to simulate a boot failure
|
||||
}
|
||||
|
||||
// FallScenarioState tracks fall scenario state for a walker
|
||||
|
|
@ -160,14 +160,14 @@ func (s *FallScenarioState) StartFall(params FallScenarioParams) {
|
|||
|
||||
// OTAScenarioState tracks OTA scenario state for a node
|
||||
type OTAScenarioState struct {
|
||||
Node *VirtualNode
|
||||
State string // "idle", "downloading", "installing", "rebooting", "updated", "rollback"
|
||||
CurrentVersion string
|
||||
DownloadedBytes int64
|
||||
DownloadStart time.Time
|
||||
RebootStart time.Time
|
||||
FailureStart time.Time
|
||||
AllNodes []*VirtualNode
|
||||
Node *VirtualNode
|
||||
State string // "idle", "downloading", "installing", "rebooting", "updated", "rollback"
|
||||
CurrentVersion string
|
||||
DownloadedBytes int64
|
||||
DownloadStart time.Time
|
||||
RebootStart time.Time
|
||||
FailureStart time.Time
|
||||
AllNodes []*VirtualNode
|
||||
}
|
||||
|
||||
// SendOTAStatus sends OTA status message to mothership
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ func (w *CSVWriter) WriteRow(walkers []*Walker, nodes []*VirtualNode, walls []Wa
|
|||
fmt.Sprintf("%.3f", walker.Velocity.X),
|
||||
fmt.Sprintf("%.3f", walker.Velocity.Y),
|
||||
fmt.Sprintf("%.3f", walker.Velocity.Z),
|
||||
"", // link_id — empty for position-only rows
|
||||
"", // delta_rms — empty for position-only rows
|
||||
"", // link_id — empty for position-only rows
|
||||
"", // delta_rms — empty for position-only rows
|
||||
}
|
||||
if err := w.writer.Write(row); err != nil {
|
||||
log.Printf("[SIM] CSV write error: %v", err)
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import (
|
|||
// NotificationAlertHandler implements AlertHandler using a notification service.
|
||||
type NotificationAlertHandler struct {
|
||||
notifyService NotificationService
|
||||
httpClient *http.Client
|
||||
webhookURL string
|
||||
httpClient *http.Client
|
||||
webhookURL string
|
||||
escalationURL string
|
||||
}
|
||||
|
||||
|
|
@ -24,9 +24,9 @@ type NotificationAlertHandler struct {
|
|||
type NotificationService interface {
|
||||
Send(notif Notification) error
|
||||
GenerateFloorPlanThumbnail(width, height int, blobs []struct {
|
||||
X, Y, Z float64
|
||||
Identity string
|
||||
IsFall bool
|
||||
X, Y, Z float64
|
||||
Identity string
|
||||
IsFall bool
|
||||
}) ([]byte, error)
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ type Notification struct {
|
|||
func NewNotificationAlertHandler(notifyService NotificationService) *NotificationAlertHandler {
|
||||
return &NotificationAlertHandler{
|
||||
notifyService: notifyService,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,9 +64,9 @@ func (h *NotificationAlertHandler) SetEscalationURL(url string) {
|
|||
func (h *NotificationAlertHandler) SendAlert(event events.AnomalyEvent, immediate bool) error {
|
||||
// Generate floor plan thumbnail
|
||||
thumbnail, err := h.notifyService.GenerateFloorPlanThumbnail(400, 300, []struct {
|
||||
X, Y, Z float64
|
||||
Identity string
|
||||
IsFall bool
|
||||
X, Y, Z float64
|
||||
Identity string
|
||||
IsFall bool
|
||||
}{
|
||||
{
|
||||
X: event.Position.X,
|
||||
|
|
@ -88,14 +88,14 @@ func (h *NotificationAlertHandler) SendAlert(event events.AnomalyEvent, immediat
|
|||
Image: thumbnail,
|
||||
ImageType: "image/png",
|
||||
Data: map[string]interface{}{
|
||||
"anomaly_id": event.ID,
|
||||
"anomaly_type": string(event.Type),
|
||||
"zone_id": event.ZoneID,
|
||||
"zone_name": event.ZoneName,
|
||||
"person_id": event.PersonID,
|
||||
"person_name": event.PersonName,
|
||||
"timestamp": event.Timestamp.Format(time.RFC3339),
|
||||
"immediate": immediate,
|
||||
"anomaly_id": event.ID,
|
||||
"anomaly_type": string(event.Type),
|
||||
"zone_id": event.ZoneID,
|
||||
"zone_name": event.ZoneName,
|
||||
"person_id": event.PersonID,
|
||||
"person_name": event.PersonName,
|
||||
"timestamp": event.Timestamp.Format(time.RFC3339),
|
||||
"immediate": immediate,
|
||||
},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
|
@ -116,20 +116,20 @@ func (h *NotificationAlertHandler) SendWebhook(event events.AnomalyEvent, immedi
|
|||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"anomaly_id": event.ID,
|
||||
"type": string(event.Type),
|
||||
"score": event.Score,
|
||||
"description": event.Description,
|
||||
"timestamp": event.Timestamp.Format(time.RFC3339),
|
||||
"zone_id": event.ZoneID,
|
||||
"zone_name": event.ZoneName,
|
||||
"person_id": event.PersonID,
|
||||
"person_name": event.PersonName,
|
||||
"device_mac": event.DeviceMAC,
|
||||
"position": event.Position,
|
||||
"hour_of_week": event.HourOfWeek,
|
||||
"anomaly_id": event.ID,
|
||||
"type": string(event.Type),
|
||||
"score": event.Score,
|
||||
"description": event.Description,
|
||||
"timestamp": event.Timestamp.Format(time.RFC3339),
|
||||
"zone_id": event.ZoneID,
|
||||
"zone_name": event.ZoneName,
|
||||
"person_id": event.PersonID,
|
||||
"person_name": event.PersonName,
|
||||
"device_mac": event.DeviceMAC,
|
||||
"position": event.Position,
|
||||
"hour_of_week": event.HourOfWeek,
|
||||
"expected_occupancy": event.ExpectedOccupancy,
|
||||
"immediate": immediate,
|
||||
"immediate": immediate,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
|
|
@ -165,16 +165,16 @@ func (h *NotificationAlertHandler) SendEscalation(event events.AnomalyEvent) err
|
|||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"anomaly_id": event.ID,
|
||||
"type": string(event.Type),
|
||||
"score": event.Score,
|
||||
"description": event.Description,
|
||||
"timestamp": event.Timestamp.Format(time.RFC3339),
|
||||
"zone_id": event.ZoneID,
|
||||
"zone_name": event.ZoneName,
|
||||
"person_id": event.PersonID,
|
||||
"person_name": event.PersonName,
|
||||
"escalation": true,
|
||||
"anomaly_id": event.ID,
|
||||
"type": string(event.Type),
|
||||
"score": event.Score,
|
||||
"description": event.Description,
|
||||
"timestamp": event.Timestamp.Format(time.RFC3339),
|
||||
"zone_id": event.ZoneID,
|
||||
"zone_name": event.ZoneName,
|
||||
"person_id": event.PersonID,
|
||||
"person_name": event.PersonName,
|
||||
"escalation": true,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
|
|
|
|||
|
|
@ -22,11 +22,11 @@ import (
|
|||
|
||||
// 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"`
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -43,51 +43,51 @@ type DwellBehaviourSlot struct {
|
|||
// 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)
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,33 +95,33 @@ func DefaultAnomalyScoreConfig() AnomalyScoreConfig {
|
|||
type SecurityMode string
|
||||
|
||||
const (
|
||||
SecurityModeDisarmed SecurityMode = "disarmed"
|
||||
SecurityModeArmed SecurityMode = "armed"
|
||||
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"`
|
||||
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"`
|
||||
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
|
||||
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
|
||||
UnusualDwellCooldown time.Duration `json:"unusual_dwell_cooldown"` // Default: 1 hour
|
||||
}
|
||||
|
||||
// DefaultAnomalyCooldownConfig returns default cooldown configuration.
|
||||
|
|
@ -144,21 +144,21 @@ type cooldownKey struct {
|
|||
|
||||
// Detector detects anomalies based on learned normal behaviour.
|
||||
type Detector struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
config AnomalyScoreConfig
|
||||
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"
|
||||
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
|
||||
activeAnomalies map[string]*events.AnomalyEvent // id -> event
|
||||
anomalyHistory []*events.AnomalyEvent
|
||||
|
||||
// Pending alert timers
|
||||
pendingAlerts map[string]*alertTimerState
|
||||
pendingAlerts map[string]*alertTimerState
|
||||
|
||||
// Anomaly cooldown tracking for deduplication
|
||||
anomalyCooldowns map[cooldownKey]time.Time // key -> last triggered time
|
||||
|
|
@ -169,29 +169,29 @@ type Detector struct {
|
|||
modelReadyAt time.Time
|
||||
|
||||
// Registered devices and people
|
||||
registeredDevices map[string]bool // MAC -> registered
|
||||
registeredPeople map[string]string // person_id -> name
|
||||
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
|
||||
securityMode SecurityMode
|
||||
autoAwayState AutoAwayState
|
||||
autoDisarmState AutoDisarmState
|
||||
manualOverrideUntil time.Time // Manual mode override expiry
|
||||
|
||||
// Providers
|
||||
zoneProvider ZoneProvider
|
||||
personProvider PersonProvider
|
||||
deviceProvider DeviceProvider
|
||||
positionProvider PositionProvider
|
||||
alertHandler AlertHandler
|
||||
zoneProvider ZoneProvider
|
||||
personProvider PersonProvider
|
||||
deviceProvider DeviceProvider
|
||||
positionProvider PositionProvider
|
||||
alertHandler AlertHandler
|
||||
|
||||
// Feedback store for accuracy tracking
|
||||
feedbackStore *learning.FeedbackStore
|
||||
feedbackStore *learning.FeedbackStore
|
||||
|
||||
// Callbacks
|
||||
onAnomaly func(event events.AnomalyEvent)
|
||||
onModeChange func(event events.SystemModeChangeEvent)
|
||||
onAnomaly func(event events.AnomalyEvent)
|
||||
onModeChange func(event events.SystemModeChangeEvent)
|
||||
onSecurityModeChange func(oldMode, newMode SecurityMode, reason string)
|
||||
}
|
||||
|
||||
|
|
@ -247,14 +247,14 @@ func NewDetector(dbPath string, config AnomalyScoreConfig) (*Detector, error) {
|
|||
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),
|
||||
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),
|
||||
|
|
@ -1074,17 +1074,17 @@ func (d *Detector) ProcessDwellDuration(zoneID, personID string, dwellDuration t
|
|||
}
|
||||
|
||||
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,
|
||||
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
|
||||
|
|
@ -1208,7 +1208,7 @@ func (d *Detector) startAlertChain(event *events.AnomalyEvent, isSecurityMode bo
|
|||
// Security mode: all alerts fire immediately
|
||||
if d.alertHandler != nil {
|
||||
d.alertHandler.SendWebhook(*event, true) //nolint:errcheck
|
||||
d.alertHandler.SendEscalation(*event) //nolint:errcheck
|
||||
d.alertHandler.SendEscalation(*event) //nolint:errcheck
|
||||
}
|
||||
event.AlertSent = true
|
||||
event.WebhookSent = true
|
||||
|
|
@ -1267,7 +1267,7 @@ func (d *Detector) startAlertChain(event *events.AnomalyEvent, isSecurityMode bo
|
|||
}
|
||||
|
||||
func (d *Detector) updateAnomalyAlertState(event *events.AnomalyEvent) {
|
||||
_, _ = d.db.Exec(`
|
||||
_, _ = d.db.Exec(`
|
||||
UPDATE anomaly_events SET
|
||||
alert_sent = ?, alert_sent_at = ?,
|
||||
webhook_sent = ?, webhook_sent_at = ?,
|
||||
|
|
@ -1463,7 +1463,7 @@ func (d *Detector) UpdateBehaviourModel() error {
|
|||
|
||||
// Upsert to database
|
||||
devicesJSON, _ := jsonMarshal(slot.TypicalBLEDevices)
|
||||
_, _ = d.db.Exec(`
|
||||
_, _ = d.db.Exec(`
|
||||
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
|
||||
|
|
@ -1502,7 +1502,7 @@ func (d *Detector) UpdateBehaviourModel() error {
|
|||
slot.MeanDwellDuration = time.Duration(meanNS)
|
||||
slot.StdDwellDuration = time.Duration(stdNS)
|
||||
|
||||
_, _ = d.db.Exec(`
|
||||
_, _ = d.db.Exec(`
|
||||
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
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ func (h *AnomalyHandler) RegisterRoutes(r chi.Router) {
|
|||
// handleGetAnomalies returns anomalies filtered by the `since` query parameter.
|
||||
// Query params:
|
||||
// - since: duration string (e.g. "24h", "7d", "1h"). Default "24h".
|
||||
//
|
||||
// Uses DB-backed QueryAnomalyEvents so results survive server restarts.
|
||||
func (h *AnomalyHandler) handleGetAnomalies(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
|
|
@ -259,9 +260,9 @@ func (h *AnomalyHandler) handleGetLearningProgress(w http.ResponseWriter, r *htt
|
|||
ready := h.detector.IsModelReady()
|
||||
|
||||
response := map[string]interface{}{
|
||||
"progress": progress,
|
||||
"model_ready": ready,
|
||||
"days_learned": int(progress * 7),
|
||||
"progress": progress,
|
||||
"model_ready": ready,
|
||||
"days_learned": int(progress * 7),
|
||||
"days_remaining": int((1 - progress) * 7),
|
||||
}
|
||||
writeJSON(w, response)
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ const (
|
|||
// PatternSlot represents a single (zone_id, hour_of_day, day_of_week) statistical slot.
|
||||
type PatternSlot struct {
|
||||
ZoneID string `json:"zone_id"`
|
||||
HourOfDay int `json:"hour_of_day"` // 0-23
|
||||
DayOfWeek int `json:"day_of_week"` // 0-6 (0=Sunday)
|
||||
HourOfDay int `json:"hour_of_day"` // 0-23
|
||||
DayOfWeek int `json:"day_of_week"` // 0-6 (0=Sunday)
|
||||
MeanCount float64 `json:"mean_count"`
|
||||
Variance float64 `json:"variance"`
|
||||
SampleCount int `json:"sample_count"`
|
||||
|
|
|
|||
|
|
@ -491,18 +491,18 @@ func TestPatternLearner_SurvivesRestart(t *testing.T) {
|
|||
|
||||
func TestPatternLearner_AlertThresholds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
name string
|
||||
observations []int
|
||||
testCount int
|
||||
wantAlert bool
|
||||
wantWarning bool
|
||||
testCount int
|
||||
wantAlert bool
|
||||
wantWarning bool
|
||||
}{
|
||||
{
|
||||
name: "normal observation at mean",
|
||||
name: "normal observation at mean",
|
||||
observations: makeConst(2, 50),
|
||||
testCount: 2,
|
||||
wantAlert: false,
|
||||
wantWarning: false,
|
||||
testCount: 2,
|
||||
wantAlert: false,
|
||||
wantWarning: false,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,22 +30,22 @@ type BSSIDReport struct {
|
|||
|
||||
// APInfo holds information about a detected AP
|
||||
type APInfo struct {
|
||||
BSSID string
|
||||
Channel int
|
||||
Manufacturer string
|
||||
ReportCount int
|
||||
TotalNodes int
|
||||
AgreementPct float64
|
||||
LastUpdated time.Time
|
||||
BSSID string
|
||||
Channel int
|
||||
Manufacturer string
|
||||
ReportCount int
|
||||
TotalNodes int
|
||||
AgreementPct float64
|
||||
LastUpdated time.Time
|
||||
}
|
||||
|
||||
// Detector manages AP BSSID detection and virtual node creation
|
||||
type Detector struct {
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
reports map[string][]BSSIDReport // keyed by normalized BSSID
|
||||
currentAP *APInfo
|
||||
subscribers []chan APInfo
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
reports map[string][]BSSIDReport // keyed by normalized BSSID
|
||||
currentAP *APInfo
|
||||
subscribers []chan APInfo
|
||||
}
|
||||
|
||||
// NewDetector creates a new AP detector
|
||||
|
|
|
|||
|
|
@ -16,29 +16,29 @@ import (
|
|||
|
||||
// AlertsHandler manages the unified alerts API.
|
||||
type AlertsHandler struct {
|
||||
mu sync.RWMutex
|
||||
fallDetector *falldetect.Detector
|
||||
anomalyDetector *analytics.Detector
|
||||
fleetRegistry *fleet.Registry
|
||||
mu sync.RWMutex
|
||||
fallDetector *falldetect.Detector
|
||||
anomalyDetector *analytics.Detector
|
||||
fleetRegistry *fleet.Registry
|
||||
}
|
||||
|
||||
// Alert represents a unified alert from any source.
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "fall", "anomaly", "node_offline"
|
||||
Severity string `json:"severity"` // "critical", "warning", "info"
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Person string `json:"person,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "fall", "anomaly", "node_offline"
|
||||
Severity string `json:"severity"` // "critical", "warning", "info"
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Person string `json:"person,omitempty"`
|
||||
Timestamp int64 `json:"timestamp_ms"`
|
||||
Data any `json:"data,omitempty"` // Type-specific data
|
||||
Data any `json:"data,omitempty"` // Type-specific data
|
||||
}
|
||||
|
||||
// ActiveAlertsResponse is the response for GET /api/alerts/active.
|
||||
type ActiveAlertsResponse struct {
|
||||
Alerts []Alert `json:"alerts"`
|
||||
Count int `json:"count"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// NewAlertsHandler creates a new alerts handler.
|
||||
|
|
@ -316,4 +316,3 @@ func (h *AlertsHandler) handleAcknowledgeAnomaly(w http.ResponseWriter, r *http.
|
|||
// Override the path parameter to use the prefixed version
|
||||
// This is handled by the unified handler
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
// AnalyticsHandler manages the crowd flow analytics API endpoints.
|
||||
type AnalyticsHandler struct {
|
||||
flowAccumulator *analytics.FlowAccumulator
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewAnalyticsHandler creates a new analytics handler.
|
||||
|
|
@ -39,7 +39,7 @@ func NewAnalyticsHandler(db *sql.DB, cellSizeM float64) *AnalyticsHandler {
|
|||
|
||||
return &AnalyticsHandler{
|
||||
flowAccumulator: flowAcc,
|
||||
db: db,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ func (h *BaselineHandler) RegisterRoutes(r chi.Router) {
|
|||
type BaselineEntry struct {
|
||||
LinkID string `json:"link_id"`
|
||||
SnapshotTime int64 `json:"snapshot_time_ms"` // Unix milliseconds
|
||||
Confidence float64 `json:"confidence"` // 0.0–1.0
|
||||
NSub int `json:"n_sub"` // Number of subcarriers
|
||||
Confidence float64 `json:"confidence"` // 0.0–1.0
|
||||
NSub int `json:"n_sub"` // Number of subcarriers
|
||||
}
|
||||
|
||||
// listBaselines handles GET /api/baseline
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func TestBaselineHandler_ListBaselines(t *testing.T) {
|
|||
capturedAt int64
|
||||
confidence float64
|
||||
nSub int
|
||||
amplitude []float32
|
||||
amplitude []float32
|
||||
phase []float32
|
||||
}{
|
||||
{"AA:BB:CC:DD:EE:FF", now - 10000, 0.8, 64, []float32{1.0, 2.0}, []float32{0.1, 0.2}},
|
||||
|
|
|
|||
|
|
@ -245,24 +245,24 @@ func TestUpdateBLEDevice(t *testing.T) {
|
|||
wantPerson string
|
||||
}{
|
||||
{
|
||||
name: "update label only",
|
||||
mac: "AA:BB:CC:DD:EE:01",
|
||||
body: `{"label": "Alice's iPhone"}`,
|
||||
name: "update label only",
|
||||
mac: "AA:BB:CC:DD:EE:01",
|
||||
body: `{"label": "Alice's iPhone"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantLabel: "Alice's iPhone",
|
||||
wantLabel: "Alice's iPhone",
|
||||
},
|
||||
{
|
||||
name: "update device type",
|
||||
mac: "AA:BB:CC:DD:EE:02",
|
||||
body: `{"device_type": "apple_phone"}`,
|
||||
name: "update device type",
|
||||
mac: "AA:BB:CC:DD:EE:02",
|
||||
body: `{"device_type": "apple_phone"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "update all fields",
|
||||
mac: "AA:BB:CC:DD:EE:03",
|
||||
body: `{"label": "Bob's Phone", "device_type": "samsung"}`,
|
||||
name: "update all fields",
|
||||
mac: "AA:BB:CC:DD:EE:03",
|
||||
body: `{"label": "Bob's Phone", "device_type": "samsung"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantLabel: "Bob's Phone",
|
||||
wantLabel: "Bob's Phone",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -612,7 +612,7 @@ func TestListPeople(t *testing.T) {
|
|||
|
||||
// Create people
|
||||
registry.CreatePerson("Alice", "#ff0000") //nolint:errcheck
|
||||
registry.CreatePerson("Bob", "#0000ff") //nolint:errcheck
|
||||
registry.CreatePerson("Bob", "#0000ff") //nolint:errcheck
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/people", nil)
|
||||
|
|
|
|||
|
|
@ -10,19 +10,19 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
_ "modernc.org/sqlite"
|
||||
"github.com/spaxel/mothership/internal/briefing"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// BriefingHandler manages morning briefing REST endpoints.
|
||||
type BriefingHandler struct {
|
||||
generator *briefing.Generator
|
||||
db *sql.DB
|
||||
notifyService briefing.NotifyService
|
||||
zoneProvider briefing.ZoneProvider
|
||||
personProvider briefing.PersonProvider
|
||||
generator *briefing.Generator
|
||||
db *sql.DB
|
||||
notifyService briefing.NotifyService
|
||||
zoneProvider briefing.ZoneProvider
|
||||
personProvider briefing.PersonProvider
|
||||
predictionProvider briefing.PredictionProvider
|
||||
healthProvider briefing.HealthProvider
|
||||
healthProvider briefing.HealthProvider
|
||||
}
|
||||
|
||||
// NewBriefingHandler creates a new briefing handler.
|
||||
|
|
@ -262,9 +262,9 @@ func (h *BriefingHandler) handleTestNotification(w http.ResponseWriter, r *http.
|
|||
if h.notifyService != nil {
|
||||
notif := briefing.Notification{
|
||||
Title: "Morning Briefing (Test)",
|
||||
Body: b.Content,
|
||||
Body: b.Content,
|
||||
Priority: 1,
|
||||
Tags: []string{"briefing", "test"},
|
||||
Tags: []string{"briefing", "test"},
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if err := h.notifyService.Send(notif); err != nil {
|
||||
|
|
|
|||
|
|
@ -123,4 +123,3 @@ func (h *DiurnalHandler) getDiurnalSlots(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ const (
|
|||
|
||||
// EventsHandler manages the events timeline.
|
||||
type EventsHandler struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
hub DashboardHub
|
||||
ownsDB bool
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
hub DashboardHub
|
||||
ownsDB bool
|
||||
feedbackHandler any // FeedbackHandler for POST /api/events/{id}/feedback
|
||||
}
|
||||
|
||||
|
|
@ -39,14 +39,14 @@ type DashboardHub interface {
|
|||
|
||||
// Event represents a timeline event.
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp int64 `json:"timestamp_ms"`
|
||||
Type string `json:"type"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Person string `json:"person,omitempty"`
|
||||
BlobID int `json:"blob_id,omitempty"`
|
||||
DetailJSON string `json:"detail_json,omitempty"`
|
||||
Severity string `json:"severity"`
|
||||
ID int64 `json:"id"`
|
||||
Timestamp int64 `json:"timestamp_ms"`
|
||||
Type string `json:"type"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Person string `json:"person,omitempty"`
|
||||
BlobID int `json:"blob_id,omitempty"`
|
||||
DetailJSON string `json:"detail_json,omitempty"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
// LogEvent logs a new event to the database and broadcasts it.
|
||||
|
|
@ -574,8 +574,7 @@ func (e *EventsHandler) postEventFeedback(w http.ResponseWriter, r *http.Request
|
|||
fmt.Sprintf(`{"event_id":%d,"type":"%s","blob_id":%d}`, eventID, req.Type, req.BlobID), "info")
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"ok": true,
|
||||
"message": "Feedback recorded",
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ type FeedbackRequest struct {
|
|||
|
||||
// FeedbackHandler handles simple feedback submissions from the UI.
|
||||
type FeedbackHandler struct {
|
||||
eventsHandler *EventsHandler
|
||||
learningHandler any // Learning handler with ProcessFeedback method
|
||||
eventsHandler *EventsHandler
|
||||
learningHandler any // Learning handler with ProcessFeedback method
|
||||
explainabilityHandler *explainability.Handler
|
||||
diagnosticEngine *diagnostics.DiagnosticEngine
|
||||
diagnosticEngine *diagnostics.DiagnosticEngine
|
||||
}
|
||||
|
||||
// NewFeedbackHandler creates a new feedback handler.
|
||||
|
|
@ -153,7 +153,7 @@ func (h *FeedbackHandler) handleSubmitFeedback(w http.ResponseWriter, r *http.Re
|
|||
|
||||
// Return success response with inline message
|
||||
response := map[string]interface{}{
|
||||
"ok": true,
|
||||
"ok": true,
|
||||
"message": "Feedback recorded",
|
||||
}
|
||||
|
||||
|
|
@ -179,12 +179,12 @@ func (h *FeedbackHandler) handleSubmitFeedback(w http.ResponseWriter, r *http.Re
|
|||
contributingLinksData := make([]map[string]interface{}, 0, len(exp.ContributingLinks))
|
||||
for _, link := range exp.ContributingLinks {
|
||||
linkData := map[string]interface{}{
|
||||
"link_id": link.LinkID,
|
||||
"node_mac": link.NodeMAC,
|
||||
"peer_mac": link.PeerMAC,
|
||||
"delta_rms": link.DeltaRMS,
|
||||
"zone_number": link.ZoneNumber,
|
||||
"weight": link.Weight,
|
||||
"link_id": link.LinkID,
|
||||
"node_mac": link.NodeMAC,
|
||||
"peer_mac": link.PeerMAC,
|
||||
"delta_rms": link.DeltaRMS,
|
||||
"zone_number": link.ZoneNumber,
|
||||
"weight": link.Weight,
|
||||
"contributing": link.Contributing,
|
||||
}
|
||||
contributingLinksData = append(contributingLinksData, linkData)
|
||||
|
|
@ -307,7 +307,7 @@ func (h *FeedbackHandler) SubmitFeedback(w http.ResponseWriter, r *http.Request,
|
|||
|
||||
// Return success response
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"ok": true,
|
||||
"message": "Feedback recorded",
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ type GuidedManager interface {
|
|||
// GuidedHandler provides endpoints for proactive contextual help.
|
||||
type GuidedHandler struct {
|
||||
guidedMgr GuidedManager
|
||||
zonesHandler any
|
||||
nodesHandler any
|
||||
zonesHandler any
|
||||
nodesHandler any
|
||||
diagnosticsHandler DiagnosticsHandler
|
||||
}
|
||||
|
||||
|
|
@ -103,10 +103,10 @@ func (h *GuidedHandler) handleGetLinkDiagnostics(w http.ResponseWriter, r *http.
|
|||
health := h.getCurrentLinkHealth(linkID)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"link_id": linkID,
|
||||
"link_id": linkID,
|
||||
"diagnosis": diagnosis,
|
||||
"diagnoses": diagnoses,
|
||||
"health": health,
|
||||
"health": health,
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
|
|
@ -132,11 +132,11 @@ func (h *GuidedHandler) getCurrentLinkHealth(linkID string) map[string]interface
|
|||
|
||||
// Build health map with current metrics
|
||||
health := map[string]interface{}{
|
||||
"packet_rate": 20.0, // Default expected rate
|
||||
"snr": 0.5, // Default SNR
|
||||
"phase_stability": 0.5, // Default stability
|
||||
"drift_rate": 0.0, // No drift
|
||||
"composite_score": mostRecent.ConfidenceScore,
|
||||
"packet_rate": 20.0, // Default expected rate
|
||||
"snr": 0.5, // Default SNR
|
||||
"phase_stability": 0.5, // Default stability
|
||||
"drift_rate": 0.0, // No drift
|
||||
"composite_score": mostRecent.ConfidenceScore,
|
||||
}
|
||||
|
||||
// If the diagnosis has specific rule info, we can infer health metrics
|
||||
|
|
@ -233,10 +233,10 @@ func (h *GuidedHandler) handleGetFeedbackResponse(w http.ResponseWriter, r *http
|
|||
var req struct {
|
||||
FeedbackType string `json:"feedback_type"` // "incorrect" or "correct"
|
||||
Links []struct {
|
||||
LinkID string `json:"link_id"`
|
||||
LinkID string `json:"link_id"`
|
||||
DeltaRMS float64 `json:"delta_rms"`
|
||||
} `json:"links,omitempty"`
|
||||
ZoneID *int `json:"zone_id,omitempty"`
|
||||
ZoneID *int `json:"zone_id,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
|
|
@ -278,9 +278,9 @@ func (h *GuidedHandler) handleGetFeedbackResponse(w http.ResponseWriter, r *http
|
|||
// handleCalibrationComplete reports calibration completion and triggers reinforcement.
|
||||
func (h *GuidedHandler) handleCalibrationComplete(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ZoneID int `json:"zone_id"`
|
||||
QualityBefore float64 `json:"quality_before"`
|
||||
QualityAfter float64 `json:"quality_after"`
|
||||
ZoneID int `json:"zone_id"`
|
||||
QualityBefore float64 `json:"quality_before"`
|
||||
QualityAfter float64 `json:"quality_after"`
|
||||
LinksCalibrated int `json:"links_calibrated"`
|
||||
}
|
||||
|
||||
|
|
@ -298,12 +298,12 @@ func (h *GuidedHandler) handleCalibrationComplete(w http.ResponseWriter, r *http
|
|||
improvementPct := int(improvement)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"type": "calibration_complete",
|
||||
"title": "Re-baseline complete",
|
||||
"message": "Detection quality in this zone has improved.",
|
||||
"improvement": improvementPct,
|
||||
"type": "calibration_complete",
|
||||
"title": "Re-baseline complete",
|
||||
"message": "Detection quality in this zone has improved.",
|
||||
"improvement": improvementPct,
|
||||
"quality_after": req.QualityAfter,
|
||||
"links": req.LinksCalibrated,
|
||||
"links": req.LinksCalibrated,
|
||||
}
|
||||
|
||||
// Add encouraging message based on improvement
|
||||
|
|
@ -377,12 +377,12 @@ func (h *GuidedHandler) handleGetNodeTroubleshoot(w http.ResponseWriter, r *http
|
|||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"mac": mac,
|
||||
"name": nodeName,
|
||||
"role": nodeRole,
|
||||
"offline_minutes": int(offlineDuration),
|
||||
"troubleshooting": steps,
|
||||
"escalation": "If the issue persists after these steps, you may need to reflash the firmware or reset the node to factory defaults.",
|
||||
"mac": mac,
|
||||
"name": nodeName,
|
||||
"role": nodeRole,
|
||||
"offline_minutes": int(offlineDuration),
|
||||
"troubleshooting": steps,
|
||||
"escalation": "If the issue persists after these steps, you may need to reflash the firmware or reset the node to factory defaults.",
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import (
|
|||
|
||||
// IntegrationSettingsHandler manages home automation integration settings.
|
||||
type IntegrationSettingsHandler struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
|
||||
// MQTT configuration (managed via settings table)
|
||||
mqttClient MQTTClient
|
||||
|
|
@ -119,25 +119,25 @@ func (h *IntegrationSettingsHandler) RegisterRoutes(r chi.Router) {
|
|||
|
||||
// integrationSettingsResponse is the response for integration settings.
|
||||
type integrationSettingsResponse struct {
|
||||
MQTT *mqttConfig `json:"mqtt,omitempty"`
|
||||
Webhook *webhookConfig `json:"webhook,omitempty"`
|
||||
MQTT *mqttConfig `json:"mqtt,omitempty"`
|
||||
Webhook *webhookConfig `json:"webhook,omitempty"`
|
||||
}
|
||||
|
||||
// mqttConfig holds MQTT configuration.
|
||||
type mqttConfig struct {
|
||||
Broker string `json:"broker,omitempty"` // e.g., "tcp://homeassistant.local:1883"
|
||||
Username string `json:"username,omitempty"` // MQTT username
|
||||
Password string `json:"password,omitempty"` // MQTT password (write-only, never returned)
|
||||
Username string `json:"username,omitempty"` // MQTT username
|
||||
Password string `json:"password,omitempty"` // MQTT password (write-only, never returned)
|
||||
TLS bool `json:"tls,omitempty"` // Whether to use TLS
|
||||
DiscoveryPrefix string `json:"discovery_prefix,omitempty"` // Home Assistant discovery prefix
|
||||
Connected bool `json:"connected"` // Connection status
|
||||
MothershipID string `json:"mothership_id,omitempty"` // Unique ID
|
||||
Connected bool `json:"connected"` // Connection status
|
||||
MothershipID string `json:"mothership_id,omitempty"` // Unique ID
|
||||
}
|
||||
|
||||
// webhookConfig holds system webhook configuration.
|
||||
type webhookConfig struct {
|
||||
URL string `json:"url,omitempty"` // Webhook URL
|
||||
Enabled bool `json:"enabled"` // Whether webhook is enabled
|
||||
URL string `json:"url,omitempty"` // Webhook URL
|
||||
Enabled bool `json:"enabled"` // Whether webhook is enabled
|
||||
}
|
||||
|
||||
// integrationSettingsRequest is the request body for updating integration settings.
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ import (
|
|||
|
||||
// LocalizationHandler manages self-improving localization API endpoints.
|
||||
type LocalizationHandler struct {
|
||||
groundTruthStore *localization.GroundTruthStore
|
||||
spatialWeightLearner *localization.SpatialWeightLearner
|
||||
weightLearner *localization.WeightLearner
|
||||
weightStore *localization.WeightStore
|
||||
groundTruthStore *localization.GroundTruthStore
|
||||
spatialWeightLearner *localization.SpatialWeightLearner
|
||||
weightLearner *localization.WeightLearner
|
||||
weightStore *localization.WeightStore
|
||||
selfImprovingLocalizer *localization.SelfImprovingLocalizer
|
||||
}
|
||||
|
||||
|
|
@ -30,10 +30,10 @@ func NewLocalizationHandler(
|
|||
sil *localization.SelfImprovingLocalizer,
|
||||
) *LocalizationHandler {
|
||||
return &LocalizationHandler{
|
||||
groundTruthStore: gtStore,
|
||||
spatialWeightLearner: swLearner,
|
||||
weightLearner: wLearner,
|
||||
weightStore: wStore,
|
||||
groundTruthStore: gtStore,
|
||||
spatialWeightLearner: swLearner,
|
||||
weightLearner: wLearner,
|
||||
weightStore: wStore,
|
||||
selfImprovingLocalizer: sil,
|
||||
}
|
||||
}
|
||||
|
|
@ -290,10 +290,10 @@ func (h *LocalizationHandler) getGroundTruthStats(w http.ResponseWriter, r *http
|
|||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"total_samples": total,
|
||||
"today_samples": today,
|
||||
"by_person": byPerson,
|
||||
"zone_counts": zoneCountsStr,
|
||||
"total_samples": total,
|
||||
"today_samples": today,
|
||||
"by_person": byPerson,
|
||||
"zone_counts": zoneCountsStr,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -433,7 +433,7 @@ func (h *LocalizationHandler) getSelfImprovingStatus(w http.ResponseWriter, r *h
|
|||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"learning_progress": progress,
|
||||
"learning_progress": progress,
|
||||
"learned_weights": weights,
|
||||
"improvement_stats": improvementStats,
|
||||
"improvement_history": improvementHistory,
|
||||
|
|
@ -457,7 +457,7 @@ func (h *LocalizationHandler) processLearning(w http.ResponseWriter, r *http.Req
|
|||
h.weightLearner.RecordErrorSnapshot()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "learning_processed",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"status": "learning_processed",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -408,10 +408,10 @@ func TestLocalizationHandler_getGroundTruthSamples(t *testing.T) {
|
|||
// Add some test samples
|
||||
for i := 0; i < 5; i++ {
|
||||
sample := localization.GroundTruthSample{
|
||||
Timestamp: time.Now().Add(-time.Duration(i) * time.Minute),
|
||||
PersonID: "test-person",
|
||||
BLEPosition: localization.Vec3{X: 1.0, Y: 0.0, Z: 1.0},
|
||||
BlobPosition: localization.Vec3{X: 1.0 + float64(i)*0.1, Y: 0.0, Z: 1.0},
|
||||
Timestamp: time.Now().Add(-time.Duration(i) * time.Minute),
|
||||
PersonID: "test-person",
|
||||
BLEPosition: localization.Vec3{X: 1.0, Y: 0.0, Z: 1.0},
|
||||
BlobPosition: localization.Vec3{X: 1.0 + float64(i)*0.1, Y: 0.0, Z: 1.0},
|
||||
PositionError: float64(i) * 0.1,
|
||||
PerLinkDeltas: map[string]float64{"link1": 0.5},
|
||||
PerLinkHealth: map[string]float64{"link1": 0.9},
|
||||
|
|
|
|||
|
|
@ -91,9 +91,9 @@ type notificationSettingsResponse struct {
|
|||
|
||||
// Quiet hours
|
||||
QuietHoursEnabled bool `json:"quiet_hours_enabled"`
|
||||
QuietHoursStart string `json:"quiet_hours_start"` // HH:MM format
|
||||
QuietHoursEnd string `json:"quiet_hours_end"` // HH:MM format
|
||||
QuietHoursDays int `json:"quiet_hours_days"` // Bitmask: 0x7F = all days (Sun=1, Mon=2, ..., Sat=64)
|
||||
QuietHoursStart string `json:"quiet_hours_start"` // HH:MM format
|
||||
QuietHoursEnd string `json:"quiet_hours_end"` // HH:MM format
|
||||
QuietHoursDays int `json:"quiet_hours_days"` // Bitmask: 0x7F = all days (Sun=1, Mon=2, ..., Sat=64)
|
||||
|
||||
// Morning digest
|
||||
MorningDigestEnabled bool `json:"morning_digest_enabled"`
|
||||
|
|
@ -237,13 +237,13 @@ func (h *NotificationSettingsHandler) getSettings() (*notificationSettingsRespon
|
|||
|
||||
response := ¬ificationSettingsResponse{
|
||||
// Set defaults
|
||||
ChannelType: "none",
|
||||
ChannelConfig: make(map[string]interface{}),
|
||||
QuietHoursDays: 0x7F, // All days
|
||||
MorningDigestTime: "07:00",
|
||||
SmartBatchingEnabled: true,
|
||||
SmartBatchingWindow: 30,
|
||||
EventTypes: getDefaultEventTypes(),
|
||||
ChannelType: "none",
|
||||
ChannelConfig: make(map[string]interface{}),
|
||||
QuietHoursDays: 0x7F, // All days
|
||||
MorningDigestTime: "07:00",
|
||||
SmartBatchingEnabled: true,
|
||||
SmartBatchingWindow: 30,
|
||||
EventTypes: getDefaultEventTypes(),
|
||||
}
|
||||
|
||||
// Get channel type
|
||||
|
|
@ -547,4 +547,3 @@ func (e *NotificationValidationError) Error() string {
|
|||
}
|
||||
return e.Reason
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -349,10 +349,10 @@ func TestNotificationSettingsValidation(t *testing.T) {
|
|||
|
||||
t.Run("validateEventTypes - valid types", func(t *testing.T) {
|
||||
validTypes := map[string]bool{
|
||||
"zone_enter": true,
|
||||
"zone_leave": true,
|
||||
"fall_detected": true,
|
||||
"anomaly_alert": true,
|
||||
"zone_enter": true,
|
||||
"zone_leave": true,
|
||||
"fall_detected": true,
|
||||
"anomaly_alert": true,
|
||||
}
|
||||
if err := validateEventTypes(validTypes); err != nil {
|
||||
t.Errorf("Valid event types should pass validation: %v", err)
|
||||
|
|
|
|||
|
|
@ -371,9 +371,9 @@ func (n *NotificationsHandler) handleSetConfig(w http.ResponseWriter, r *http.Re
|
|||
|
||||
// testNotificationRequest is the request body for sending a test notification.
|
||||
type testNotificationRequest struct {
|
||||
ChannelType string `json:"channel_type"` // ntfy, pushover, gotify, webhook, mqtt
|
||||
Title string `json:"title"` // Custom title (optional)
|
||||
Body string `json:"body"` // Custom body (optional)
|
||||
ChannelType string `json:"channel_type"` // ntfy, pushover, gotify, webhook, mqtt
|
||||
Title string `json:"title"` // Custom title (optional)
|
||||
Body string `json:"body"` // Custom body (optional)
|
||||
Data map[string]interface{} `json:"data,omitempty"` // Additional data (optional)
|
||||
}
|
||||
|
||||
|
|
@ -463,13 +463,13 @@ func (n *NotificationsHandler) handlePreview(w http.ResponseWriter, r *http.Requ
|
|||
|
||||
// Define test person
|
||||
person := render.Person{
|
||||
Name: personName,
|
||||
X: 2.0,
|
||||
Y: 1.5,
|
||||
Z: 1.0,
|
||||
Color: "#4488ff",
|
||||
Name: personName,
|
||||
X: 2.0,
|
||||
Y: 1.5,
|
||||
Z: 1.0,
|
||||
Color: "#4488ff",
|
||||
Confidence: 0.85,
|
||||
IsFall: false,
|
||||
IsFall: false,
|
||||
}
|
||||
|
||||
var pngData []byte
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ import (
|
|||
|
||||
// PredictionHandler manages prediction API endpoints.
|
||||
type PredictionHandler struct {
|
||||
predictor *prediction.Predictor
|
||||
history *prediction.HistoryUpdater
|
||||
accuracyTracker *prediction.AccuracyTracker
|
||||
predictor *prediction.Predictor
|
||||
history *prediction.HistoryUpdater
|
||||
accuracyTracker *prediction.AccuracyTracker
|
||||
horizonPredictor *prediction.HorizonPredictor
|
||||
zoneProvider ZoneProvider
|
||||
personProvider PersonProvider
|
||||
zoneProvider ZoneProvider
|
||||
personProvider PersonProvider
|
||||
}
|
||||
|
||||
// ZoneProvider provides zone information.
|
||||
|
|
@ -145,9 +145,9 @@ func (h *PredictionHandler) getStats(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"transition_count": count,
|
||||
"data_age_days": dataAge.Hours() / 24,
|
||||
"minimum_data_age": prediction.MinimumDataAge.Hours() / 24,
|
||||
"transition_count": count,
|
||||
"data_age_days": dataAge.Hours() / 24,
|
||||
"minimum_data_age": prediction.MinimumDataAge.Hours() / 24,
|
||||
"has_minimum_data": dataAge >= prediction.MinimumDataAge,
|
||||
})
|
||||
}
|
||||
|
|
@ -280,9 +280,9 @@ func (h *PredictionHandler) getZonePattern(w http.ResponseWriter, r *http.Reques
|
|||
|
||||
if pattern == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"zone_id": zoneID,
|
||||
"zone_id": zoneID,
|
||||
"hour_of_week": hourOfWeek,
|
||||
"message": "no pattern data for this zone/time",
|
||||
"message": "no pattern data for this zone/time",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ type ReplayHandler struct {
|
|||
worker *replay.Worker
|
||||
sessions map[string]*_replaySession
|
||||
nextID int
|
||||
activeSessionID string // Currently active session for dashboard control
|
||||
activeSessionID string // Currently active session for dashboard control
|
||||
settingsHandler SettingsPersister // For ApplyToLive functionality
|
||||
replayPath string // Path to the replay binary file
|
||||
replayPath string // Path to the replay binary file
|
||||
}
|
||||
|
||||
// SettingsPersister is the interface for persisting replay parameters to live settings.
|
||||
|
|
@ -60,7 +60,7 @@ func NewReplayHandler(store replay.FrameReader) (*ReplayHandler, error) {
|
|||
return &ReplayHandler{
|
||||
worker: worker,
|
||||
sessions: make(map[string]*_replaySession),
|
||||
nextID: 1,
|
||||
nextID: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -141,12 +141,12 @@ func (h *ReplayHandler) RegisterRoutes(r chi.Router) {
|
|||
|
||||
// replayInfo represents the response from GET /api/replay/sessions.
|
||||
type replayInfo struct {
|
||||
HasData bool `json:"has_data"`
|
||||
FileSize int64 `json:"file_size_mb"`
|
||||
WritePos int64 `json:"write_pos"`
|
||||
OldestPos int64 `json:"oldest_pos"`
|
||||
OldestTS int64 `json:"oldest_timestamp_ms"`
|
||||
NewestTS int64 `json:"newest_timestamp_ms"`
|
||||
HasData bool `json:"has_data"`
|
||||
FileSize int64 `json:"file_size_mb"`
|
||||
WritePos int64 `json:"write_pos"`
|
||||
OldestPos int64 `json:"oldest_pos"`
|
||||
OldestTS int64 `json:"oldest_timestamp_ms"`
|
||||
NewestTS int64 `json:"newest_timestamp_ms"`
|
||||
Sessions []*_replaySession `json:"sessions"`
|
||||
}
|
||||
|
||||
|
|
@ -667,15 +667,15 @@ func (h *ReplayHandler) getSessionState(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
// Build response with session state and blobs
|
||||
response := map[string]interface{}{
|
||||
"session_id": sessionID,
|
||||
"current_ms": session.CurrentMS,
|
||||
"from_ms": session.FromMS,
|
||||
"to_ms": session.ToMS,
|
||||
"state": session.State,
|
||||
"speed": session.Speed,
|
||||
"progress": progress,
|
||||
"params": session.Params,
|
||||
"blobs": blobs,
|
||||
"session_id": sessionID,
|
||||
"current_ms": session.CurrentMS,
|
||||
"from_ms": session.FromMS,
|
||||
"to_ms": session.ToMS,
|
||||
"state": session.State,
|
||||
"speed": session.Speed,
|
||||
"progress": progress,
|
||||
"params": session.Params,
|
||||
"blobs": blobs,
|
||||
"timestamp_ms": session.LastBlobTime,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ import (
|
|||
|
||||
// mockRecordingStore is a mock implementation of FrameReader for testing.
|
||||
type mockRecordingStore struct {
|
||||
stats replay.Stats
|
||||
scanFunc func(fn func(recvTimeNS int64, frame []byte) bool) error
|
||||
stats replay.Stats
|
||||
scanFunc func(fn func(recvTimeNS int64, frame []byte) bool) error
|
||||
scanRangeFunc func(fromNS, toNS int64, fn func(recvTimeNS int64, frame []byte) bool) error
|
||||
closed bool
|
||||
closeErr error
|
||||
closed bool
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (m *mockRecordingStore) Stats() replay.Stats {
|
||||
|
|
@ -669,10 +669,10 @@ func TestTune(t *testing.T) {
|
|||
return resp["session_id"].(string)
|
||||
},
|
||||
body: tuneRequest{
|
||||
DeltaRMSThreshold: &deltaThreshold,
|
||||
TauS: &tauS,
|
||||
FresnelDecay: &fresnelDecay,
|
||||
Subcarriers: &subcarriers,
|
||||
DeltaRMSThreshold: &deltaThreshold,
|
||||
TauS: &tauS,
|
||||
FresnelDecay: &fresnelDecay,
|
||||
Subcarriers: &subcarriers,
|
||||
BreathingSensitivity: &breathingSens,
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
|
|
@ -746,9 +746,9 @@ func TestTune(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "malformed JSON",
|
||||
setup: func(h *ReplayHandler) string { return "" },
|
||||
body: tuneRequest{},
|
||||
name: "malformed JSON",
|
||||
setup: func(h *ReplayHandler) string { return "" },
|
||||
body: tuneRequest{},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
check: func(t *testing.T, resp map[string]interface{}) {
|
||||
if _, ok := resp["error"]; !ok {
|
||||
|
|
@ -839,7 +839,7 @@ func TestReplaySessionLifecycle(t *testing.T) {
|
|||
// 2. Tune the session
|
||||
threshold := 0.03
|
||||
tuneBody, _ := json.Marshal(tuneRequest{
|
||||
SessionID: sessionID,
|
||||
SessionID: sessionID,
|
||||
DeltaRMSThreshold: &threshold,
|
||||
})
|
||||
req = httptest.NewRequest("POST", "/api/replay/tune", bytes.NewReader(tuneBody))
|
||||
|
|
|
|||
|
|
@ -55,24 +55,25 @@ type SecurityStatus struct {
|
|||
|
||||
// SystemModeResponse represents the current system mode response.
|
||||
type SystemModeResponse struct {
|
||||
Mode string `json:"mode"` // "home", "away", "sleep"
|
||||
Armed bool `json:"armed"`
|
||||
LearningUntil string `json:"learning_until,omitempty"`
|
||||
AnomalyCount24h int `json:"anomaly_count_24h"`
|
||||
ModelReady bool `json:"model_ready"`
|
||||
LastChange string `json:"last_change,omitempty"`
|
||||
LastChangeBy string `json:"last_change_by,omitempty"`
|
||||
Mode string `json:"mode"` // "home", "away", "sleep"
|
||||
Armed bool `json:"armed"`
|
||||
LearningUntil string `json:"learning_until,omitempty"`
|
||||
AnomalyCount24h int `json:"anomaly_count_24h"`
|
||||
ModelReady bool `json:"model_ready"`
|
||||
LastChange string `json:"last_change,omitempty"`
|
||||
LastChangeBy string `json:"last_change_by,omitempty"`
|
||||
}
|
||||
|
||||
// handleStatus returns the current security mode status.
|
||||
// Response JSON:
|
||||
// {
|
||||
// "armed": true,
|
||||
// "mode": "armed",
|
||||
// "learning_until": "2024-04-15T10:30:00Z", // omitted if model_ready
|
||||
// "anomaly_count_24h": 5,
|
||||
// "model_ready": false
|
||||
// }
|
||||
//
|
||||
// {
|
||||
// "armed": true,
|
||||
// "mode": "armed",
|
||||
// "learning_until": "2024-04-15T10:30:00Z", // omitted if model_ready
|
||||
// "anomaly_count_24h": 5,
|
||||
// "model_ready": false
|
||||
// }
|
||||
func (h *SecurityHandler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
http.Error(w, "detector not available", http.StatusServiceUnavailable)
|
||||
|
|
@ -176,15 +177,16 @@ func (h *SecurityHandler) countAnomalies24h() int {
|
|||
|
||||
// handleGetMode returns the current system mode (home/away/sleep).
|
||||
// Response JSON:
|
||||
// {
|
||||
// "mode": "home",
|
||||
// "armed": false,
|
||||
// "learning_until": "2024-04-15T10:30:00Z", // omitted if model_ready
|
||||
// "anomaly_count_24h": 5,
|
||||
// "model_ready": false,
|
||||
// "last_change": "2024-04-15T10:30:00Z",
|
||||
// "last_change_by": "auto_away"
|
||||
// }
|
||||
//
|
||||
// {
|
||||
// "mode": "home",
|
||||
// "armed": false,
|
||||
// "learning_until": "2024-04-15T10:30:00Z", // omitted if model_ready
|
||||
// "anomaly_count_24h": 5,
|
||||
// "model_ready": false,
|
||||
// "last_change": "2024-04-15T10:30:00Z",
|
||||
// "last_change_by": "auto_away"
|
||||
// }
|
||||
func (h *SecurityHandler) handleGetMode(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
http.Error(w, "detector not available", http.StatusServiceUnavailable)
|
||||
|
|
@ -216,10 +218,12 @@ func (h *SecurityHandler) handleGetMode(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
// handleSetMode sets the system mode (home/away/sleep).
|
||||
// Request body:
|
||||
// {
|
||||
// "mode": "away", // "home", "away", or "sleep"
|
||||
// "reason": "manual" // optional reason for logging
|
||||
// }
|
||||
//
|
||||
// {
|
||||
// "mode": "away", // "home", "away", or "sleep"
|
||||
// "reason": "manual" // optional reason for logging
|
||||
// }
|
||||
//
|
||||
// Response: SystemModeResponse
|
||||
func (h *SecurityHandler) handleSetMode(w http.ResponseWriter, r *http.Request) {
|
||||
if h.detector == nil {
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@ import (
|
|||
|
||||
// mockDetectorProvider is a mock implementation of DetectorProvider for testing.
|
||||
type mockDetectorProvider struct {
|
||||
mode analytics.SecurityMode
|
||||
isActive bool
|
||||
progress float64
|
||||
modelReady bool
|
||||
activeAnomalies []*events.AnomalyEvent
|
||||
history []*events.AnomalyEvent
|
||||
modeChanges []analytics.SecurityMode
|
||||
systemMode events.SystemMode
|
||||
mode analytics.SecurityMode
|
||||
isActive bool
|
||||
progress float64
|
||||
modelReady bool
|
||||
activeAnomalies []*events.AnomalyEvent
|
||||
history []*events.AnomalyEvent
|
||||
modeChanges []analytics.SecurityMode
|
||||
systemMode events.SystemMode
|
||||
}
|
||||
|
||||
func (m *mockDetectorProvider) GetSecurityMode() analytics.SecurityMode {
|
||||
|
|
@ -131,11 +131,11 @@ func TestSecurityHandler_Status(t *testing.T) {
|
|||
}
|
||||
|
||||
mock := &mockDetectorProvider{
|
||||
mode: tt.mode,
|
||||
isActive: tt.isActive,
|
||||
modelReady: tt.modelReady,
|
||||
progress: tt.progress,
|
||||
history: history,
|
||||
mode: tt.mode,
|
||||
isActive: tt.isActive,
|
||||
modelReady: tt.modelReady,
|
||||
progress: tt.progress,
|
||||
history: history,
|
||||
}
|
||||
|
||||
handler := NewSecurityHandler(mock)
|
||||
|
|
@ -369,10 +369,10 @@ func TestSecurityHandler_NilDetector(t *testing.T) {
|
|||
func TestSecurityHandler_CountAnomalies24h(t *testing.T) {
|
||||
now := time.Now()
|
||||
history := []*events.AnomalyEvent{
|
||||
{Timestamp: now.Add(-1 * time.Hour)}, // Within 24h
|
||||
{Timestamp: now.Add(-12 * time.Hour)}, // Within 24h
|
||||
{Timestamp: now.Add(-25 * time.Hour)}, // Outside 24h
|
||||
{Timestamp: now.Add(-48 * time.Hour)}, // Outside 24h
|
||||
{Timestamp: now.Add(-1 * time.Hour)}, // Within 24h
|
||||
{Timestamp: now.Add(-12 * time.Hour)}, // Within 24h
|
||||
{Timestamp: now.Add(-25 * time.Hour)}, // Outside 24h
|
||||
{Timestamp: now.Add(-48 * time.Hour)}, // Outside 24h
|
||||
}
|
||||
|
||||
mock := &mockDetectorProvider{
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import (
|
|||
// SettingsHandler manages application settings.
|
||||
// Settings are stored as key-value pairs in the settings table with JSON-encoded values.
|
||||
type SettingsHandler struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
// cache is an in-memory cache of settings for fast reads
|
||||
cache map[string]interface{}
|
||||
// editTracker tracks repeated edits for troubleshooting hints
|
||||
|
|
@ -223,30 +223,30 @@ func (s *SettingsHandler) Delete(key string) error {
|
|||
// defaultSettings defines the default values for all known settings.
|
||||
// These are returned when a key hasn't been set in the database.
|
||||
var defaultSettings = map[string]interface{}{
|
||||
"fusion_rate_hz": 10.0, // Fusion loop rate in Hz
|
||||
"grid_cell_m": 0.2, // Fresnel grid cell size in meters
|
||||
"delta_rms_threshold": 0.02, // Motion detection threshold
|
||||
"tau_s": 30.0, // EMA baseline time constant in seconds
|
||||
"fresnel_decay": 2.0, // Fresnel zone weight decay rate
|
||||
"n_subcarriers": 16, // Number of subcarriers for NBVI selection
|
||||
"breathing_sensitivity": 0.005, // Breathing detection threshold (radians RMS)
|
||||
"motion_threshold": 0.05, // Smooth deltaRMS threshold for motion gating
|
||||
"dwell_seconds": 30, // Default dwell trigger duration in seconds
|
||||
"vacant_seconds": 300, // Default vacant trigger duration in seconds
|
||||
"max_tracked_blobs": 20, // Maximum number of blobs to track simultaneously
|
||||
"replay_retention_hours": 48, // CSI replay buffer retention in hours
|
||||
"replay_max_mb": 360, // CSI replay buffer max size in MB
|
||||
"security_mode": false, // Security mode enabled state
|
||||
"security_mode_armed_at": nil, // Timestamp when security mode was armed
|
||||
"events_archive_days": 90, // Events archive retention in days
|
||||
"quiet_hours_start": "", // Quiet hours start time (HH:MM format)
|
||||
"quiet_hours_end": "", // Quiet hours end time (HH:MM format)
|
||||
"fusion_rate_hz": 10.0, // Fusion loop rate in Hz
|
||||
"grid_cell_m": 0.2, // Fresnel grid cell size in meters
|
||||
"delta_rms_threshold": 0.02, // Motion detection threshold
|
||||
"tau_s": 30.0, // EMA baseline time constant in seconds
|
||||
"fresnel_decay": 2.0, // Fresnel zone weight decay rate
|
||||
"n_subcarriers": 16, // Number of subcarriers for NBVI selection
|
||||
"breathing_sensitivity": 0.005, // Breathing detection threshold (radians RMS)
|
||||
"motion_threshold": 0.05, // Smooth deltaRMS threshold for motion gating
|
||||
"dwell_seconds": 30, // Default dwell trigger duration in seconds
|
||||
"vacant_seconds": 300, // Default vacant trigger duration in seconds
|
||||
"max_tracked_blobs": 20, // Maximum number of blobs to track simultaneously
|
||||
"replay_retention_hours": 48, // CSI replay buffer retention in hours
|
||||
"replay_max_mb": 360, // CSI replay buffer max size in MB
|
||||
"security_mode": false, // Security mode enabled state
|
||||
"security_mode_armed_at": nil, // Timestamp when security mode was armed
|
||||
"events_archive_days": 90, // Events archive retention in days
|
||||
"quiet_hours_start": "", // Quiet hours start time (HH:MM format)
|
||||
"quiet_hours_end": "", // Quiet hours end time (HH:MM format)
|
||||
// Auto-update settings
|
||||
"auto_update_enabled": false, // Auto-update mode enabled
|
||||
"quiet_window_start": "02:00", // Auto-update quiet window start (HH:MM)
|
||||
"quiet_window_end": "05:00", // Auto-update quiet window end (HH:MM)
|
||||
"canary_duration_min": 10, // Canary monitoring duration in minutes
|
||||
"auto_update_quality_threshold": 0.05, // Quality degradation threshold (0-1)
|
||||
"auto_update_enabled": false, // Auto-update mode enabled
|
||||
"quiet_window_start": "02:00", // Auto-update quiet window start (HH:MM)
|
||||
"quiet_window_end": "05:00", // Auto-update quiet window end (HH:MM)
|
||||
"canary_duration_min": 10, // Canary monitoring duration in minutes
|
||||
"auto_update_quality_threshold": 0.05, // Quality degradation threshold (0-1)
|
||||
}
|
||||
|
||||
// RegisterRoutes registers settings endpoints on the given router.
|
||||
|
|
|
|||
|
|
@ -109,10 +109,10 @@ func TestSettingsHandler(t *testing.T) {
|
|||
method: "POST",
|
||||
path: "/api/settings",
|
||||
body: map[string]interface{}{
|
||||
"fusion_rate_hz": 12.0,
|
||||
"delta_rms_threshold": 0.03,
|
||||
"grid_cell_m": 0.15,
|
||||
"max_tracked_blobs": 30,
|
||||
"fusion_rate_hz": 12.0,
|
||||
"delta_rms_threshold": 0.03,
|
||||
"grid_cell_m": 0.15,
|
||||
"max_tracked_blobs": 30,
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) {
|
||||
|
|
@ -505,26 +505,26 @@ func TestSettingsDelete(t *testing.T) {
|
|||
// TestValidateSettings tests the settings validation.
|
||||
func TestValidateSettings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settings map[string]interface{}
|
||||
wantErr bool
|
||||
errKey string
|
||||
name string
|
||||
settings map[string]interface{}
|
||||
wantErr bool
|
||||
errKey string
|
||||
}{
|
||||
{
|
||||
name: "all valid settings",
|
||||
settings: map[string]interface{}{
|
||||
"fusion_rate_hz": 10.0,
|
||||
"grid_cell_m": 0.2,
|
||||
"delta_rms_threshold": 0.02,
|
||||
"tau_s": 30.0,
|
||||
"fresnel_decay": 2.0,
|
||||
"n_subcarriers": 16,
|
||||
"fusion_rate_hz": 10.0,
|
||||
"grid_cell_m": 0.2,
|
||||
"delta_rms_threshold": 0.02,
|
||||
"tau_s": 30.0,
|
||||
"fresnel_decay": 2.0,
|
||||
"n_subcarriers": 16,
|
||||
"breathing_sensitivity": 0.005,
|
||||
"motion_threshold": 0.05,
|
||||
"dwell_seconds": 30,
|
||||
"vacant_seconds": 300,
|
||||
"max_tracked_blobs": 20,
|
||||
"security_mode": true,
|
||||
"motion_threshold": 0.05,
|
||||
"dwell_seconds": 30,
|
||||
"vacant_seconds": 300,
|
||||
"max_tracked_blobs": 20,
|
||||
"security_mode": true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ import (
|
|||
|
||||
// StatusHandler handles GET /api/status and GET /api/occupancy.
|
||||
type StatusHandler struct {
|
||||
mu sync.RWMutex
|
||||
pm ProcessorManagerProvider
|
||||
zonesMgr ZonesManagerProvider
|
||||
startTime time.Time
|
||||
getNodeCount func() int
|
||||
mu sync.RWMutex
|
||||
pm ProcessorManagerProvider
|
||||
zonesMgr ZonesManagerProvider
|
||||
startTime time.Time
|
||||
getNodeCount func() int
|
||||
}
|
||||
|
||||
// ProcessorManagerProvider provides access to signal processor data.
|
||||
|
|
|
|||
|
|
@ -27,16 +27,16 @@ type TriggersHandler struct {
|
|||
|
||||
// Trigger represents an automation trigger.
|
||||
type Trigger struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Condition string `json:"condition"` // enter, leave, dwell, vacant, count
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Condition string `json:"condition"` // enter, leave, dwell, vacant, count
|
||||
ConditionParams json.RawMessage `json:"condition_params"`
|
||||
TimeConstraint json.RawMessage `json:"time_constraint,omitempty"`
|
||||
Actions json.RawMessage `json:"actions"`
|
||||
LastFired *time.Time `json:"last_fired,omitempty"`
|
||||
Elapsed int `json:"elapsed,omitempty"` // seconds since last fire
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastFired *time.Time `json:"last_fired,omitempty"`
|
||||
Elapsed int `json:"elapsed,omitempty"` // seconds since last fire
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TriggerEngine is the interface to the automation engine.
|
||||
|
|
@ -241,10 +241,10 @@ func (t *TriggersHandler) listTriggers(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type createTriggerRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Condition string `json:"condition"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Condition string `json:"condition"`
|
||||
ConditionParams json.RawMessage `json:"condition_params"`
|
||||
TimeConstraint json.RawMessage `json:"time_constraint,omitempty"`
|
||||
Actions json.RawMessage `json:"actions"`
|
||||
|
|
@ -564,8 +564,8 @@ func (t *TriggersHandler) EvaluateTriggers(blobs []BlobPos) []string {
|
|||
|
||||
// Parse condition params
|
||||
var params struct {
|
||||
DurationS *int `json:"duration_s"`
|
||||
CountThreshold *int `json:"count_threshold"`
|
||||
DurationS *int `json:"duration_s"`
|
||||
CountThreshold *int `json:"count_threshold"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
VolumeID string `json:"volume_id,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,8 +161,8 @@ func TestCreateTrigger(t *testing.T) {
|
|||
wantID: "t1",
|
||||
},
|
||||
{
|
||||
name: "minimal valid trigger",
|
||||
body: `{"id": "t2", "name": "Enter", "condition": "enter"}`,
|
||||
name: "minimal valid trigger",
|
||||
body: `{"id": "t2", "name": "Enter", "condition": "enter"}`,
|
||||
wantCode: http.StatusCreated,
|
||||
wantID: "t2",
|
||||
},
|
||||
|
|
@ -209,8 +209,8 @@ func TestCreateTrigger(t *testing.T) {
|
|||
wantID: "t6",
|
||||
},
|
||||
{
|
||||
name: "explicitly disabled",
|
||||
body: `{"id": "t7", "name": "Off", "condition": "vacant", "enabled": false}`,
|
||||
name: "explicitly disabled",
|
||||
body: `{"id": "t7", "name": "Off", "condition": "vacant", "enabled": false}`,
|
||||
wantCode: http.StatusCreated,
|
||||
wantID: "t7",
|
||||
},
|
||||
|
|
@ -589,34 +589,34 @@ func TestTestTrigger(t *testing.T) {
|
|||
wantKey string
|
||||
}{
|
||||
{
|
||||
name: "test with no engine returns simulated",
|
||||
setup: Trigger{ID: "t1", Name: "Sim", Condition: "dwell", Enabled: true},
|
||||
testID: "t1",
|
||||
engine: nil,
|
||||
name: "test with no engine returns simulated",
|
||||
setup: Trigger{ID: "t1", Name: "Sim", Condition: "dwell", Enabled: true},
|
||||
testID: "t1",
|
||||
engine: nil,
|
||||
wantCode: http.StatusOK,
|
||||
wantKey: "simulated",
|
||||
},
|
||||
{
|
||||
name: "test with engine that succeeds",
|
||||
setup: Trigger{ID: "t1", Name: "Fire", Condition: "enter", Enabled: true},
|
||||
testID: "t1",
|
||||
engine: &mockEngine{err: nil},
|
||||
name: "test with engine that succeeds",
|
||||
setup: Trigger{ID: "t1", Name: "Fire", Condition: "enter", Enabled: true},
|
||||
testID: "t1",
|
||||
engine: &mockEngine{err: nil},
|
||||
wantCode: http.StatusOK,
|
||||
wantKey: "fired",
|
||||
},
|
||||
{
|
||||
name: "test with engine that fails",
|
||||
setup: Trigger{ID: "t1", Name: "Fail", Condition: "leave", Enabled: true},
|
||||
testID: "t1",
|
||||
engine: &mockEngine{err: fmt.Errorf("boom")},
|
||||
name: "test with engine that fails",
|
||||
setup: Trigger{ID: "t1", Name: "Fail", Condition: "leave", Enabled: true},
|
||||
testID: "t1",
|
||||
engine: &mockEngine{err: fmt.Errorf("boom")},
|
||||
wantCode: http.StatusInternalServerError,
|
||||
wantKey: "test fire failed",
|
||||
},
|
||||
{
|
||||
name: "test nonexistent trigger",
|
||||
setup: Trigger{ID: "t1", Name: "Exists", Condition: "enter", Enabled: true},
|
||||
testID: "nonexistent",
|
||||
engine: nil,
|
||||
name: "test nonexistent trigger",
|
||||
setup: Trigger{ID: "t1", Name: "Exists", Condition: "enter", Enabled: true},
|
||||
testID: "nonexistent",
|
||||
engine: nil,
|
||||
wantCode: http.StatusNotFound,
|
||||
wantKey: "trigger not found",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -389,13 +389,13 @@ func (h *VolumeTriggersHandler) createTrigger(w http.ResponseWriter, r *http.Req
|
|||
|
||||
// Validate condition
|
||||
validConditions := map[string]bool{
|
||||
"enter": true,
|
||||
"leave": true,
|
||||
"dwell": true,
|
||||
"vacant": true,
|
||||
"enter": true,
|
||||
"leave": true,
|
||||
"dwell": true,
|
||||
"vacant": true,
|
||||
"count": true,
|
||||
"predicted_enter": true,
|
||||
}
|
||||
"predicted_enter": true,
|
||||
}
|
||||
if !validConditions[req.Condition] {
|
||||
http.Error(w, "condition must be one of: enter, leave, dwell, vacant, count, predicted_enter", http.StatusBadRequest)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ func validBoxShape() volume.ShapeJSON {
|
|||
return volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +237,7 @@ func TestVolumeCreateTriggerAssignsID(t *testing.T) {
|
|||
func TestVolumeGetTrigger(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup bool // whether to seed a trigger
|
||||
setup bool // whether to seed a trigger
|
||||
getID string // empty = use the seeded ID
|
||||
wantCode int
|
||||
wantErr string
|
||||
|
|
@ -424,8 +424,8 @@ func TestVolumeUpdateTrigger(t *testing.T) {
|
|||
func TestVolumeDeleteTrigger(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup int // number of triggers to seed
|
||||
deleteN int // 0 = delete nonexistent, 1 = delete first trigger
|
||||
setup int // number of triggers to seed
|
||||
deleteN int // 0 = delete nonexistent, 1 = delete first trigger
|
||||
wantCode int
|
||||
wantLen int
|
||||
}{
|
||||
|
|
@ -610,10 +610,10 @@ func TestTestTriggerEndpoint(t *testing.T) {
|
|||
Shape: volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
},
|
||||
Condition: "dwell",
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
Actions: []volume.Action{
|
||||
{Type: "webhook", Params: map[string]interface{}{"url": "http://example.com/hook"}},
|
||||
},
|
||||
|
|
@ -687,10 +687,10 @@ func TestTestTrigger_ReturnsErrorOnMissingURL(t *testing.T) {
|
|||
Shape: volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
},
|
||||
Condition: "enter",
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
Actions: []volume.Action{
|
||||
{Type: "webhook", Params: map[string]interface{}{}},
|
||||
},
|
||||
|
|
@ -743,10 +743,10 @@ func TestTestTrigger_4xxInTestDoesNotDisable(t *testing.T) {
|
|||
Shape: volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
},
|
||||
Condition: "enter",
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
Actions: []volume.Action{
|
||||
{Type: "webhook", Params: map[string]interface{}{"url": mockServer.URL}},
|
||||
},
|
||||
|
|
@ -787,10 +787,10 @@ func TestEnableEndpoint(t *testing.T) {
|
|||
Shape: volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
},
|
||||
Condition: "enter",
|
||||
Enabled: false,
|
||||
Enabled: false,
|
||||
}
|
||||
|
||||
id, err := handler.store.Create(trigger)
|
||||
|
|
@ -836,10 +836,10 @@ func TestGetWebhookLogEndpoint(t *testing.T) {
|
|||
Shape: volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
},
|
||||
Condition: "enter",
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
id, err := handler.store.Create(trigger)
|
||||
|
|
@ -899,10 +899,10 @@ func TestWebhookPayloadSchema(t *testing.T) {
|
|||
Shape: volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
},
|
||||
Condition: "dwell",
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
Actions: []volume.Action{
|
||||
{Type: "webhook", Params: map[string]interface{}{"url": mockServer.URL}},
|
||||
},
|
||||
|
|
@ -959,10 +959,10 @@ func Test5xxDoesNotDisableTrigger(t *testing.T) {
|
|||
Shape: volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
},
|
||||
Condition: "enter",
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
Actions: []volume.Action{
|
||||
{Type: "webhook", Params: map[string]interface{}{"url": mockServer.URL}},
|
||||
},
|
||||
|
|
@ -1012,10 +1012,10 @@ func Test4xxDisablesTrigger(t *testing.T) {
|
|||
Shape: volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
},
|
||||
Condition: "enter",
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
Actions: []volume.Action{
|
||||
{Type: "webhook", Params: map[string]interface{}{"url": mockServer.URL}},
|
||||
},
|
||||
|
|
@ -1065,10 +1065,10 @@ func Test2xxResetsErrorCount(t *testing.T) {
|
|||
Shape: volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
},
|
||||
Condition: "enter",
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
Actions: []volume.Action{
|
||||
{Type: "webhook", Params: map[string]interface{}{"url": mockServer.URL}},
|
||||
},
|
||||
|
|
@ -1129,10 +1129,10 @@ func TestTimeoutDoesNotDisable(t *testing.T) {
|
|||
Shape: volume.ShapeJSON{
|
||||
Type: volume.ShapeBox,
|
||||
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
|
||||
},
|
||||
Condition: "enter",
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
Actions: []volume.Action{
|
||||
{Type: "webhook", Params: map[string]interface{}{"url": "http://" + listener.Addr().String()}},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ type BlobIdentityProvider interface {
|
|||
// Changes to zones and portals are immediately broadcast to dashboard clients
|
||||
// via the ZoneChangeBroadcaster, and also reflected in the next delta tick.
|
||||
type ZonesHandler struct {
|
||||
mu sync.RWMutex
|
||||
mgr *zones.Manager
|
||||
bc dashboard.ZoneChangeBroadcaster
|
||||
ident BlobIdentityProvider
|
||||
mu sync.RWMutex
|
||||
mgr *zones.Manager
|
||||
bc dashboard.ZoneChangeBroadcaster
|
||||
ident BlobIdentityProvider
|
||||
}
|
||||
|
||||
// zoneWithOcc extends a zone with current occupancy and people list for API responses.
|
||||
|
|
|
|||
|
|
@ -214,11 +214,11 @@ func TestCreateZoneInvalid(t *testing.T) {
|
|||
body: ``,
|
||||
wantMsg: "invalid request body",
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
body: `{"id":"z1","x":0,"y":0,"z":0,"max_x":1,"max_y":1,"max_z":1}`,
|
||||
wantMsg: "name is required",
|
||||
},
|
||||
{
|
||||
name: "missing name",
|
||||
body: `{"id":"z1","x":0,"y":0,"z":0,"max_x":1,"max_y":1,"max_z":1}`,
|
||||
wantMsg: "name is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -256,21 +256,21 @@ func TestUpdateZone(t *testing.T) {
|
|||
wantName string
|
||||
}{
|
||||
{
|
||||
name: "update zone name",
|
||||
setup: zones.Zone{ID: "z1", Name: "Kitchen", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5},
|
||||
update: zones.Zone{ID: "z1", Name: "Big Kitchen", MinX: 0, MinY: 0, MinZ: 0, MaxX: 6, MaxY: 5, MaxZ: 3},
|
||||
name: "update zone name",
|
||||
setup: zones.Zone{ID: "z1", Name: "Kitchen", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5},
|
||||
update: zones.Zone{ID: "z1", Name: "Big Kitchen", MinX: 0, MinY: 0, MinZ: 0, MaxX: 6, MaxY: 5, MaxZ: 3},
|
||||
wantName: "Big Kitchen",
|
||||
},
|
||||
{
|
||||
name: "update zone type to bedroom",
|
||||
setup: zones.Zone{ID: "z1", Name: "Room", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5},
|
||||
update: zones.Zone{ID: "z1", Name: "Room", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5, ZoneType: zones.ZoneTypeBedroom},
|
||||
name: "update zone type to bedroom",
|
||||
setup: zones.Zone{ID: "z1", Name: "Room", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5},
|
||||
update: zones.Zone{ID: "z1", Name: "Room", MinX: 0, MinY: 0, MinZ: 0, MaxX: 4, MaxY: 3, MaxZ: 2.5, ZoneType: zones.ZoneTypeBedroom},
|
||||
wantName: "Room",
|
||||
},
|
||||
{
|
||||
name: "update zone bounds",
|
||||
setup: zones.Zone{ID: "z1", Name: "Box", MinX: 0, MinY: 0, MinZ: 0, MaxX: 1, MaxY: 1, MaxZ: 1},
|
||||
update: zones.Zone{ID: "z1", Name: "Box", MinX: 2, MinY: 3, MinZ: 1, MaxX: 10, MaxY: 8, MaxZ: 4},
|
||||
name: "update zone bounds",
|
||||
setup: zones.Zone{ID: "z1", Name: "Box", MinX: 0, MinY: 0, MinZ: 0, MaxX: 1, MaxY: 1, MaxZ: 1},
|
||||
update: zones.Zone{ID: "z1", Name: "Box", MinX: 2, MinY: 3, MinZ: 1, MaxX: 10, MaxY: 8, MaxZ: 4},
|
||||
wantName: "Box",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ import (
|
|||
|
||||
// Handler handles authentication endpoints.
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
secretKey []byte // for session token signing
|
||||
db *sql.DB
|
||||
secretKey []byte // for session token signing
|
||||
mothershipID string // cached mothership ID
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -581,10 +581,10 @@ func TestInstallSecret_NodeTokenDerivation(t *testing.T) {
|
|||
defer h.Close() //nolint:errcheck
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mac string
|
||||
mac2 string
|
||||
same bool // whether mac and mac2 should produce the same token
|
||||
name string
|
||||
mac string
|
||||
mac2 string
|
||||
same bool // whether mac and mac2 should produce the same token
|
||||
}{
|
||||
{"same MAC always produces same token", "AA:BB:CC:DD:EE:FF", "AA:BB:CC:DD:EE:FF", true},
|
||||
{"different MACs produce different tokens", "AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", false},
|
||||
|
|
|
|||
|
|
@ -265,11 +265,11 @@ func TestTimeWindowCondition(t *testing.T) {
|
|||
minute int
|
||||
expected bool
|
||||
}{
|
||||
{8, 0, true}, // 08:00 - within 07:00-18:00
|
||||
{12, 0, true}, // 12:00 - within 07:00-18:00
|
||||
{17, 59, true}, // 17:59 - within 07:00-18:00
|
||||
{6, 59, false}, // 06:59 - outside 07:00-18:00
|
||||
{18, 1, false}, // 18:01 - outside 07:00-18:00
|
||||
{8, 0, true}, // 08:00 - within 07:00-18:00
|
||||
{12, 0, true}, // 12:00 - within 07:00-18:00
|
||||
{17, 59, true}, // 17:59 - within 07:00-18:00
|
||||
{6, 59, false}, // 06:59 - outside 07:00-18:00
|
||||
{18, 1, false}, // 18:01 - outside 07:00-18:00
|
||||
}
|
||||
|
||||
for _, tc := range dayTestCases {
|
||||
|
|
@ -448,9 +448,9 @@ func TestWebhookDispatch(t *testing.T) {
|
|||
})
|
||||
|
||||
engine.ProcessEvent(Event{
|
||||
Type: TriggerZoneEnter,
|
||||
ZoneID: "test",
|
||||
ZoneName: "Test Zone",
|
||||
Type: TriggerZoneEnter,
|
||||
ZoneID: "test",
|
||||
ZoneName: "Test Zone",
|
||||
PersonName: "Alice",
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
|
@ -503,8 +503,8 @@ func TestWebhookRetry(t *testing.T) {
|
|||
}
|
||||
|
||||
engine.ProcessEvent(Event{
|
||||
Type: TriggerZoneEnter,
|
||||
ZoneID: "test",
|
||||
Type: TriggerZoneEnter,
|
||||
ZoneID: "test",
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
|
|
@ -544,8 +544,8 @@ func TestMQTTPublish(t *testing.T) {
|
|||
}
|
||||
|
||||
engine.ProcessEvent(Event{
|
||||
Type: TriggerZoneEnter,
|
||||
ZoneID: "test",
|
||||
Type: TriggerZoneEnter,
|
||||
ZoneID: "test",
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
|
|
@ -639,8 +639,8 @@ func TestFireCountIncrement(t *testing.T) {
|
|||
// Trigger 3 times
|
||||
for i := 0; i < 3; i++ {
|
||||
engine.ProcessEvent(Event{
|
||||
Type: TriggerZoneEnter,
|
||||
ZoneID: "test",
|
||||
Type: TriggerZoneEnter,
|
||||
ZoneID: "test",
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
|
@ -663,22 +663,22 @@ func TestTriggerVolumeContainment(t *testing.T) {
|
|||
|
||||
// Box volume
|
||||
boxVolume := TriggerVolume{
|
||||
ID: "test-box",
|
||||
Type: "box",
|
||||
MinX: 1.0, MinY: 0.0, MinZ: 1.0,
|
||||
MaxX: 3.0, MaxY: 2.0, MaxZ: 3.0,
|
||||
ID: "test-box",
|
||||
Type: "box",
|
||||
MinX: 1.0, MinY: 0.0, MinZ: 1.0,
|
||||
MaxX: 3.0, MaxY: 2.0, MaxZ: 3.0,
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
x, y, z float64
|
||||
expected bool
|
||||
}{
|
||||
{2.0, 1.0, 2.0, true}, // Center
|
||||
{1.0, 0.0, 1.0, true}, // Corner
|
||||
{3.0, 2.0, 3.0, true}, // Opposite corner
|
||||
{0.0, 1.0, 2.0, false}, // Outside X
|
||||
{2.0, 3.0, 2.0, false}, // Outside Y
|
||||
{2.0, 1.0, 4.0, false}, // Outside Z
|
||||
{2.0, 1.0, 2.0, true}, // Center
|
||||
{1.0, 0.0, 1.0, true}, // Corner
|
||||
{3.0, 2.0, 3.0, true}, // Opposite corner
|
||||
{0.0, 1.0, 2.0, false}, // Outside X
|
||||
{2.0, 3.0, 2.0, false}, // Outside Y
|
||||
{2.0, 1.0, 4.0, false}, // Outside Z
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
|
@ -691,20 +691,20 @@ func TestTriggerVolumeContainment(t *testing.T) {
|
|||
|
||||
// Sphere volume
|
||||
sphereVolume := TriggerVolume{
|
||||
ID: "test-sphere",
|
||||
Type: "sphere",
|
||||
CenterX: 2.0, CenterY: 2.0, CenterZ: 2.0,
|
||||
Radius: 1.0,
|
||||
ID: "test-sphere",
|
||||
Type: "sphere",
|
||||
CenterX: 2.0, CenterY: 2.0, CenterZ: 2.0,
|
||||
Radius: 1.0,
|
||||
}
|
||||
|
||||
sphereCases := []struct {
|
||||
x, y, z float64
|
||||
expected bool
|
||||
}{
|
||||
{2.0, 2.0, 2.0, true}, // Center
|
||||
{2.0, 2.0, 3.0, true}, // On surface
|
||||
{2.0, 2.0, 3.1, false}, // Just outside
|
||||
{1.0, 1.0, 1.0, false}, // Corner (distance > 1)
|
||||
{2.0, 2.0, 2.0, true}, // Center
|
||||
{2.0, 2.0, 3.0, true}, // On surface
|
||||
{2.0, 2.0, 3.1, false}, // Just outside
|
||||
{1.0, 1.0, 1.0, false}, // Corner (distance > 1)
|
||||
}
|
||||
|
||||
for _, tc := range sphereCases {
|
||||
|
|
@ -717,9 +717,9 @@ func TestTriggerVolumeContainment(t *testing.T) {
|
|||
|
||||
// Cylinder volume
|
||||
cylinderVolume := TriggerVolume{
|
||||
ID: "test-cylinder",
|
||||
Type: "cylinder",
|
||||
BaseX: 2.0, BaseZ: 2.0,
|
||||
ID: "test-cylinder",
|
||||
Type: "cylinder",
|
||||
BaseX: 2.0, BaseZ: 2.0,
|
||||
BaseRadius: 1.0,
|
||||
MinHeight: 0.0, MaxHeight: 2.0,
|
||||
}
|
||||
|
|
@ -728,11 +728,11 @@ func TestTriggerVolumeContainment(t *testing.T) {
|
|||
x, y, z float64
|
||||
expected bool
|
||||
}{
|
||||
{2.0, 1.0, 2.0, true}, // Center
|
||||
{2.0, 2.5, 2.0, false}, // Above height
|
||||
{3.0, 1.0, 2.0, true}, // On edge
|
||||
{3.5, 1.0, 2.0, false}, // Outside radius
|
||||
{2.0, 1.0, 3.0, true}, // On edge (dist=1.0 from center in Z)
|
||||
{2.0, 1.0, 2.0, true}, // Center
|
||||
{2.0, 2.5, 2.0, false}, // Above height
|
||||
{3.0, 1.0, 2.0, true}, // On edge
|
||||
{3.5, 1.0, 2.0, false}, // Outside radius
|
||||
{2.0, 1.0, 3.0, true}, // On edge (dist=1.0 from center in Z)
|
||||
}
|
||||
|
||||
for _, tc := range cylinderCases {
|
||||
|
|
@ -854,7 +854,7 @@ func TestTriggerVolumeCRUD(t *testing.T) {
|
|||
Type: "box",
|
||||
Enabled: true,
|
||||
MinX: 0, MinY: 0, MinZ: 0,
|
||||
MaxX: 1, MaxY: 1, MaxZ: 1,
|
||||
MaxX: 1, MaxY: 1, MaxZ: 1,
|
||||
}
|
||||
if err := engine.CreateTriggerVolume(v); err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -1006,8 +1006,8 @@ func TestZoneOccupancyCondition(t *testing.T) {
|
|||
triggered = true
|
||||
})
|
||||
engine.ProcessEvent(Event{
|
||||
Type: TriggerZoneEnter,
|
||||
ZoneID: "kitchen",
|
||||
Type: TriggerZoneEnter,
|
||||
ZoneID: "kitchen",
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ type NodeConnectedGetter interface {
|
|||
|
||||
// nodeProviderAdapter adapts fleet.Registry and fleet.Manager to implement ota.NodeProvider.
|
||||
type nodeProviderAdapter struct {
|
||||
registry *fleet.Registry
|
||||
weather *fleet.LinkWeatherDiagnostics
|
||||
registry *fleet.Registry
|
||||
weather *fleet.LinkWeatherDiagnostics
|
||||
connGetter NodeConnectedGetter
|
||||
}
|
||||
|
||||
|
|
@ -68,8 +68,8 @@ func NewNodeProvider(registry *fleet.Registry, weather *fleet.LinkWeatherDiagnos
|
|||
// NewNodeProviderWithConnected creates an ota.NodeProvider with a connected nodes getter.
|
||||
func NewNodeProviderWithConnected(registry *fleet.Registry, weather *fleet.LinkWeatherDiagnostics, connGetter NodeConnectedGetter) ota.NodeProvider {
|
||||
return &nodeProviderAdapter{
|
||||
registry: registry,
|
||||
weather: weather,
|
||||
registry: registry,
|
||||
weather: weather,
|
||||
connGetter: connGetter,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ func (h *Handler) listDevices(w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Set("X-Privacy-Notice", "Phones may appear multiple times due to address rotation. Wearables and AirTags have stable addresses.")
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"devices": devices,
|
||||
"devices": devices,
|
||||
"privacy_notice": "Phones may appear multiple times due to address rotation. Wearables and AirTags have stable addresses.",
|
||||
})
|
||||
}
|
||||
|
|
@ -531,7 +531,7 @@ func (h *Handler) mergeDevices(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"merged": device,
|
||||
"merged": device,
|
||||
"message": "Devices merged successfully. " + req.MAC2 + " has been removed.",
|
||||
})
|
||||
}
|
||||
|
|
@ -737,8 +737,8 @@ func (h *Handler) getDeviceAliases(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
type splitDeviceRequest struct {
|
||||
CanonicalAddr string `json:"canonical_addr"` // The canonical device address
|
||||
AliasAddr string `json:"alias_addr"` // The alias to split off
|
||||
NewPersonID string `json:"new_person_id"` // Optional: assign to different person
|
||||
AliasAddr string `json:"alias_addr"` // The alias to split off
|
||||
NewPersonID string `json:"new_person_id"` // Optional: assign to different person
|
||||
}
|
||||
|
||||
// splitDevice splits an alias from its canonical device, creating a separate device entry.
|
||||
|
|
|
|||
|
|
@ -10,16 +10,16 @@ import (
|
|||
|
||||
// Triangulation parameters per specification
|
||||
const (
|
||||
RefDistance = 1.0 // d0 = 1.0m reference distance
|
||||
RefRSSI = -65 // RSSI at 1m in typical indoor environment (dBm)
|
||||
PathLossExp = 2.5 // Indoor path loss exponent (typical range 2.0-3.5)
|
||||
RSSINoiseSigma = 5.0 // RSSI noise sigma in dBm
|
||||
RefDistance = 1.0 // d0 = 1.0m reference distance
|
||||
RefRSSI = -65 // RSSI at 1m in typical indoor environment (dBm)
|
||||
PathLossExp = 2.5 // Indoor path loss exponent (typical range 2.0-3.5)
|
||||
RSSINoiseSigma = 5.0 // RSSI noise sigma in dBm
|
||||
)
|
||||
|
||||
// Matching thresholds per specification
|
||||
const (
|
||||
MaxBLEBlobDistance = 2.0 // Maximum distance for BLE-to-blob matching (metres)
|
||||
MinMatchConfidence = 0.6 // Minimum confidence for identity assignment
|
||||
MaxBLEBlobDistance = 2.0 // Maximum distance for BLE-to-blob matching (metres)
|
||||
MinMatchConfidence = 0.6 // Minimum confidence for identity assignment
|
||||
IdentityPersistence = 5 * time.Minute
|
||||
ObservationWindow = 5 * time.Second
|
||||
ObservationWindowLong = 15 * time.Second
|
||||
|
|
@ -62,15 +62,15 @@ type TriangulatedDevice struct {
|
|||
|
||||
// IdentityMatcher matches BLE devices to CSI blobs using RSSI triangulation.
|
||||
type IdentityMatcher struct {
|
||||
registry *Registry
|
||||
rssiCache *RSSICache
|
||||
nodePos NodePositionAccessor
|
||||
registry *Registry
|
||||
rssiCache *RSSICache
|
||||
nodePos NodePositionAccessor
|
||||
rotationDetector *RotationDetector // For address rotation detection
|
||||
|
||||
mu sync.RWMutex
|
||||
matches map[int]*IdentityMatch // blobID -> match
|
||||
matches map[int]*IdentityMatch // blobID -> match
|
||||
bleOnlyTracks map[string]*IdentityMatch // personID -> BLE-only placeholder
|
||||
persistentIdent map[int]*IdentityMatch // blobID -> persisted identity (for 5-min persistence)
|
||||
persistentIdent map[int]*IdentityMatch // blobID -> persisted identity (for 5-min persistence)
|
||||
lastBLEUpdate time.Time
|
||||
cachedDevices []*TriangulatedDevice
|
||||
|
||||
|
|
@ -102,9 +102,9 @@ func NewIdentityMatcher(registry *Registry, rssiCache *RSSICache, nodePos NodePo
|
|||
// UpdateBlobs processes new blob positions and matches them to BLE devices.
|
||||
// This implements the full BLE-to-blob matching algorithm per specification.
|
||||
func (m *IdentityMatcher) UpdateBlobs(blobs []struct {
|
||||
ID int
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
Weight float64
|
||||
Weight float64
|
||||
}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
|
@ -171,7 +171,7 @@ func (m *IdentityMatcher) triangulateAllDevices(now time.Time) []*TriangulatedDe
|
|||
if processed[dev.Addr] {
|
||||
// Update alias last_seen timestamp if this is an alias
|
||||
if addr != dev.Addr {
|
||||
_ = m.registry.UpdateAliasLastSeen(addr); //nolint:errcheck // best-effort
|
||||
_ = m.registry.UpdateAliasLastSeen(addr) //nolint:errcheck // best-effort
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ func (m *IdentityMatcher) triangulateAllDevices(now time.Time) []*TriangulatedDe
|
|||
|
||||
// Update alias last_seen timestamp
|
||||
if addr != dev.Addr {
|
||||
_ = m.registry.UpdateAliasLastSeen(addr); //nolint:errcheck // best-effort
|
||||
_ = m.registry.UpdateAliasLastSeen(addr) //nolint:errcheck // best-effort
|
||||
}
|
||||
|
||||
// Find most recent observation age
|
||||
|
|
@ -230,8 +230,8 @@ func (m *IdentityMatcher) triangulate(readings []*RSSIObservation) (Position, fl
|
|||
|
||||
// Convert RSSI readings to distance estimates with node positions
|
||||
type nodeReading struct {
|
||||
x, y, z float64
|
||||
rssi int
|
||||
x, y, z float64
|
||||
rssi int
|
||||
distance float64
|
||||
weight float64
|
||||
}
|
||||
|
|
@ -365,9 +365,9 @@ func rssiToDistance(rssi int) float64 {
|
|||
|
||||
// assignBLEToBlobs assigns triangulated BLE devices to the nearest CSI blobs.
|
||||
func (m *IdentityMatcher) assignBLEToBlobs(devices []*TriangulatedDevice, blobs []struct {
|
||||
ID int
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
Weight float64
|
||||
Weight float64
|
||||
}, now time.Time) {
|
||||
// Track which blobs have been assigned
|
||||
assignedBlobs := make(map[int]bool)
|
||||
|
|
@ -387,9 +387,9 @@ func (m *IdentityMatcher) assignBLEToBlobs(devices []*TriangulatedDevice, blobs
|
|||
|
||||
// Find nearest blob within 2m (using horizontal plane only)
|
||||
var bestBlob *struct {
|
||||
ID int
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
Weight float64
|
||||
Weight float64
|
||||
}
|
||||
bestDist := MaxBLEBlobDistance
|
||||
|
||||
|
|
@ -506,9 +506,9 @@ func computeMatchConfidence(td *TriangulatedDevice, blobDist float64) float64 {
|
|||
|
||||
// createBLEOnlyTracks creates placeholder tracks for BLE devices without nearby CSI blobs.
|
||||
func (m *IdentityMatcher) createBLEOnlyTracks(devices []*TriangulatedDevice, blobs []struct {
|
||||
ID int
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
Weight float64
|
||||
Weight float64
|
||||
}, now time.Time) {
|
||||
// Track which persons already have blob assignments
|
||||
hasBlobAssignment := make(map[string]bool)
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ func (m *mockNodePositionAccessor) GetNodePosition(mac string) (x, y, z float64,
|
|||
|
||||
func TestRSSIToDistance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rssi int
|
||||
expected float64
|
||||
name string
|
||||
rssi int
|
||||
expected float64
|
||||
tolerance float64
|
||||
}{
|
||||
{
|
||||
|
|
@ -71,9 +71,9 @@ func TestTriangulationWithThreeNodes(t *testing.T) {
|
|||
// Setup mock node positions (equilateral triangle at known positions)
|
||||
mockNodes := &mockNodePositionAccessor{
|
||||
positions: map[string][3]float64{
|
||||
"node:00:01": {0.0, 1.5, 0.0}, // Origin
|
||||
"node:00:02": {4.0, 1.5, 0.0}, // 4m along X
|
||||
"node:00:03": {2.0, 1.5, 3.46}, // ~3.46m along Z (equilateral)
|
||||
"node:00:01": {0.0, 1.5, 0.0}, // Origin
|
||||
"node:00:02": {4.0, 1.5, 0.0}, // 4m along X
|
||||
"node:00:03": {2.0, 1.5, 3.46}, // ~3.46m along Z (equilateral)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -271,9 +271,9 @@ func TestConfidenceGate(t *testing.T) {
|
|||
cache.AddWithTime("aa:bb:cc:dd:ee:01", "node:00:01", -65, now)
|
||||
|
||||
blobs := []struct {
|
||||
ID int
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
Weight float64
|
||||
Weight float64
|
||||
}{
|
||||
{ID: 1, X: 1.5, Y: 1.5, Z: 0.0, Weight: 0.9}, // 1.5m from node
|
||||
}
|
||||
|
|
@ -373,9 +373,9 @@ func TestBLEOnlyPlaceholderTrack(t *testing.T) {
|
|||
|
||||
// Blob is far away (> 2m from triangulated position)
|
||||
blobs := []struct {
|
||||
ID int
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
Weight float64
|
||||
Weight float64
|
||||
}{
|
||||
{ID: 1, X: 10.0, Y: 1.5, Z: 10.0, Weight: 0.9}, // Far from BLE position
|
||||
}
|
||||
|
|
@ -545,11 +545,11 @@ func TestIdentityHandoffOnMACRotation(t *testing.T) {
|
|||
|
||||
func TestComputeMatchConfidence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
td *TriangulatedDevice
|
||||
blobDist float64
|
||||
minConf float64
|
||||
maxConf float64
|
||||
name string
|
||||
td *TriangulatedDevice
|
||||
blobDist float64
|
||||
minConf float64
|
||||
maxConf float64
|
||||
}{
|
||||
{
|
||||
name: "High confidence: recent observation, 3+ nodes, low residual, close blob",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ var ManufacturerInfo = map[int]struct {
|
|||
Name string
|
||||
Type DeviceType
|
||||
}{
|
||||
0x004C: {"Apple", DeviceTypeApplePhone}, // Apple - iPhone, iPad, AirPods, Apple Watch
|
||||
0x004C: {"Apple", DeviceTypeApplePhone}, // Apple - iPhone, iPad, AirPods, Apple Watch
|
||||
0x0006: {"Microsoft", DeviceTypeMicrosoft}, // Microsoft - Windows devices
|
||||
0x0075: {"Samsung", DeviceTypeSamsung}, // Samsung - phones/tablets
|
||||
0x009E: {"Fitbit", DeviceTypeFitbit}, // Fitbit - fitness trackers
|
||||
|
|
@ -49,20 +49,20 @@ var ManufacturerInfo = map[int]struct {
|
|||
|
||||
// DeviceRecord represents a registered BLE device.
|
||||
type DeviceRecord struct {
|
||||
Addr string `json:"mac"` // MAC address
|
||||
Name string `json:"name"` // User-assigned name (e.g., "Alice's Phone")
|
||||
Label string `json:"label"` // Short label for display
|
||||
Manufacturer string `json:"manufacturer"` // Auto-detected manufacturer name
|
||||
DeviceType DeviceType `json:"device_type"` // Auto-detected or manual type
|
||||
DeviceName string `json:"device_name"` // Name from advertising (e.g., "iPhone")
|
||||
MfrID int `json:"mfr_id"` // Manufacturer ID from advertising
|
||||
MfrDataHex string `json:"mfr_data_hex"` // Raw manufacturer data (hex)
|
||||
PersonID string `json:"person_id"` // FK to people.id
|
||||
PersonName string `json:"person_name"` // Person name (joined from people)
|
||||
PersonColor string `json:"person_color"` // Person's color (joined from people)
|
||||
RSSIMin int `json:"rssi_min"` // Min RSSI observed
|
||||
RSSIMax int `json:"rssi_max"` // Max RSSI observed
|
||||
RSSIAvg int `json:"rssi_avg"` // Average RSSI
|
||||
Addr string `json:"mac"` // MAC address
|
||||
Name string `json:"name"` // User-assigned name (e.g., "Alice's Phone")
|
||||
Label string `json:"label"` // Short label for display
|
||||
Manufacturer string `json:"manufacturer"` // Auto-detected manufacturer name
|
||||
DeviceType DeviceType `json:"device_type"` // Auto-detected or manual type
|
||||
DeviceName string `json:"device_name"` // Name from advertising (e.g., "iPhone")
|
||||
MfrID int `json:"mfr_id"` // Manufacturer ID from advertising
|
||||
MfrDataHex string `json:"mfr_data_hex"` // Raw manufacturer data (hex)
|
||||
PersonID string `json:"person_id"` // FK to people.id
|
||||
PersonName string `json:"person_name"` // Person name (joined from people)
|
||||
PersonColor string `json:"person_color"` // Person's color (joined from people)
|
||||
RSSIMin int `json:"rssi_min"` // Min RSSI observed
|
||||
RSSIMax int `json:"rssi_max"` // Max RSSI observed
|
||||
RSSIAvg int `json:"rssi_avg"` // Average RSSI
|
||||
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
LastSeenNode string `json:"last_seen_node"` // MAC of node that last saw this device
|
||||
|
|
@ -74,9 +74,9 @@ type DeviceRecord struct {
|
|||
|
||||
// Person represents a named person in the system.
|
||||
type Person struct {
|
||||
ID string `json:"id"` // UUID
|
||||
Name string `json:"name"` // Display name
|
||||
Color string `json:"color"` // Hex color for dashboard
|
||||
ID string `json:"id"` // UUID
|
||||
Name string `json:"name"` // Display name
|
||||
Color string `json:"color"` // Hex color for dashboard
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
|
|
@ -702,8 +702,8 @@ func (r *Registry) GetDeviceSightingHistory(mac string, limit int) ([]SightingHi
|
|||
// SightingHistoryEntry represents a single sighting event in the device history.
|
||||
type SightingHistoryEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
RSSIdBm int `json:"rssi_dbm"`
|
||||
NodeMAC string `json:"node_mac"`
|
||||
RSSIdBm int `json:"rssi_dbm"`
|
||||
NodeMAC string `json:"node_mac"`
|
||||
}
|
||||
|
||||
// UpdateLabel updates the user-assigned label for a device.
|
||||
|
|
@ -1238,10 +1238,10 @@ func generateUUID() string {
|
|||
|
||||
// BLEDeviceAlias represents an address alias for a device.
|
||||
type BLEDeviceAlias struct {
|
||||
Addr string `json:"addr"` // The rotated address
|
||||
CanonicalAddr string `json:"canonical_addr"` // The primary/canonical address
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Addr string `json:"addr"` // The rotated address
|
||||
CanonicalAddr string `json:"canonical_addr"` // The primary/canonical address
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
}
|
||||
|
||||
// AddAlias adds a new address alias for a canonical device address.
|
||||
|
|
@ -1438,20 +1438,20 @@ func (r *Registry) GetCurrentDevices() []map[string]interface{} {
|
|||
"last_seen": d.LastSeenAt.UnixMilli(),
|
||||
"blob_id": nil,
|
||||
"manufacturer": d.Manufacturer,
|
||||
"device_type": d.DeviceType,
|
||||
"device_name": d.DeviceName,
|
||||
"mfr_id": d.MfrID,
|
||||
"person_id": d.PersonID,
|
||||
"person_name": d.PersonName,
|
||||
"rssi_min": d.RSSIMin,
|
||||
"rssi_max": d.RSSIMax,
|
||||
"rssi_avg": d.RSSIAvg,
|
||||
"rssi_count": r.GetDeviceRSSICount(d.Addr),
|
||||
"first_seen_at": d.FirstSeenAt.UnixMilli(),
|
||||
"last_seen_at": d.LastSeenAt.UnixMilli(),
|
||||
"device_type": d.DeviceType,
|
||||
"device_name": d.DeviceName,
|
||||
"mfr_id": d.MfrID,
|
||||
"person_id": d.PersonID,
|
||||
"person_name": d.PersonName,
|
||||
"rssi_min": d.RSSIMin,
|
||||
"rssi_max": d.RSSIMax,
|
||||
"rssi_avg": d.RSSIAvg,
|
||||
"rssi_count": r.GetDeviceRSSICount(d.Addr),
|
||||
"first_seen_at": d.FirstSeenAt.UnixMilli(),
|
||||
"last_seen_at": d.LastSeenAt.UnixMilli(),
|
||||
"last_seen_node": d.LastSeenNode,
|
||||
"is_wearable": d.IsWearable,
|
||||
"enabled": d.Enabled,
|
||||
"is_wearable": d.IsWearable,
|
||||
"enabled": d.Enabled,
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -13,17 +13,17 @@ import (
|
|||
// Rotation constants per specification
|
||||
const (
|
||||
// Rotation detection windows
|
||||
RotationTimeWindow = 90 * time.Second // Time window to look for rotation patterns
|
||||
RotationRSSIThreshold = 10 // RSSI similarity threshold (dBm)
|
||||
RotationMinScore = 0.7 // Minimum score to consider a rotation match
|
||||
RotationConfirmCount = 3 // Consecutive confirmations needed to merge
|
||||
RotationGracePeriod = 5 * time.Minute // Grace period before clearing identity
|
||||
RotationStaleThreshold = 5 * time.Minute // Time after which an alias is considered stale
|
||||
RotationTimeWindow = 90 * time.Second // Time window to look for rotation patterns
|
||||
RotationRSSIThreshold = 10 // RSSI similarity threshold (dBm)
|
||||
RotationMinScore = 0.7 // Minimum score to consider a rotation match
|
||||
RotationConfirmCount = 3 // Consecutive confirmations needed to merge
|
||||
RotationGracePeriod = 5 * time.Minute // Grace period before clearing identity
|
||||
RotationStaleThreshold = 5 * time.Minute // Time after which an alias is considered stale
|
||||
|
||||
// Scoring weights for rotation detection
|
||||
WeightManufacturerMatch = 0.50 // Manufacturer data fingerprint
|
||||
WeightRSSIProximity = 0.35 // Time + RSSI proximity
|
||||
WeightTimeGap = 0.15 // Time gap factor
|
||||
WeightManufacturerMatch = 0.50 // Manufacturer data fingerprint
|
||||
WeightRSSIProximity = 0.35 // Time + RSSI proximity
|
||||
WeightTimeGap = 0.15 // Time gap factor
|
||||
)
|
||||
|
||||
// RotationCandidate represents a possible address rotation match.
|
||||
|
|
@ -39,25 +39,25 @@ type RotationCandidate struct {
|
|||
|
||||
// RotationDetector implements BLE address rotation detection heuristics.
|
||||
type RotationDetector struct {
|
||||
registry *Registry
|
||||
registry *Registry
|
||||
rssiCache *RSSICache
|
||||
|
||||
mu sync.RWMutex
|
||||
candidates map[string]*RotationCandidate // canonical_addr -> candidate
|
||||
rotationHistory map[string][]string // canonical_addr -> list of rotated addresses
|
||||
lastCheck time.Time
|
||||
gracePeriodExpiries map[string]time.Time // canonical_addr -> identity expiry
|
||||
mu sync.RWMutex
|
||||
candidates map[string]*RotationCandidate // canonical_addr -> candidate
|
||||
rotationHistory map[string][]string // canonical_addr -> list of rotated addresses
|
||||
lastCheck time.Time
|
||||
gracePeriodExpiries map[string]time.Time // canonical_addr -> identity expiry
|
||||
}
|
||||
|
||||
// NewRotationDetector creates a new rotation detector.
|
||||
func NewRotationDetector(registry *Registry, rssiCache *RSSICache) *RotationDetector {
|
||||
return &RotationDetector{
|
||||
registry: registry,
|
||||
rssiCache: rssiCache,
|
||||
candidates: make(map[string]*RotationCandidate),
|
||||
rotationHistory: make(map[string][]string),
|
||||
gracePeriodExpiries: make(map[string]time.Time),
|
||||
lastCheck: time.Now(),
|
||||
registry: registry,
|
||||
rssiCache: rssiCache,
|
||||
candidates: make(map[string]*RotationCandidate),
|
||||
rotationHistory: make(map[string][]string),
|
||||
gracePeriodExpiries: make(map[string]time.Time),
|
||||
lastCheck: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -274,7 +274,7 @@ func (r *RotationDetector) compareRSSIProximity(oldReadings, newReadings []*RSSI
|
|||
timeScore = 0.1
|
||||
} else {
|
||||
// Linear decay from 1.0 to 0.5 over the window
|
||||
timeScore = 1.0 - (0.5*float64(timeGap)/float64(RotationTimeWindow))
|
||||
timeScore = 1.0 - (0.5 * float64(timeGap) / float64(RotationTimeWindow))
|
||||
}
|
||||
|
||||
// Check for same-node observations (strongest signal)
|
||||
|
|
|
|||
|
|
@ -114,11 +114,11 @@ func TestCalculateTimeGapScore(t *testing.T) {
|
|||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
name string
|
||||
oldReadings []*RSSIObservation
|
||||
newReadings []*RSSIObservation
|
||||
minScore float64
|
||||
maxScore float64
|
||||
minScore float64
|
||||
maxScore float64
|
||||
}{
|
||||
{
|
||||
name: "immediate appearance (ideal)",
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ func NewGenerator(dbPath string) (*Generator, error) {
|
|||
// Unwrap if it's JSON
|
||||
if strings.HasPrefix(weatherURL, `"`) {
|
||||
var url string
|
||||
_ = json.Unmarshal([]byte(weatherURL), &url); //nolint:errcheck
|
||||
_ = json.Unmarshal([]byte(weatherURL), &url) //nolint:errcheck
|
||||
weatherURL = url
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,13 +26,13 @@ type Hub struct {
|
|||
ingestionState IngestionState
|
||||
|
||||
// Additional state providers
|
||||
bleState BLEState
|
||||
triggerState TriggerState
|
||||
systemHealth SystemHealthProvider
|
||||
zoneState ZoneStateProvider
|
||||
eventStore EventStore
|
||||
securityState SecurityStateProvider
|
||||
sleepState SleepStateProvider
|
||||
bleState BLEState
|
||||
triggerState TriggerState
|
||||
systemHealth SystemHealthProvider
|
||||
zoneState ZoneStateProvider
|
||||
eventStore EventStore
|
||||
securityState SecurityStateProvider
|
||||
sleepState SleepStateProvider
|
||||
briefingProvider BriefingProvider
|
||||
|
||||
// Pending events buffer — events accumulated between 10 Hz delta ticks.
|
||||
|
|
@ -57,17 +57,17 @@ type Hub struct {
|
|||
// snapshotCache holds serialised JSON bytes for each snapshot field,
|
||||
// allowing cheap byte-level comparison when computing deltas.
|
||||
type snapshotCache struct {
|
||||
blobsJSON []byte
|
||||
nodesJSON []byte
|
||||
zonesJSON []byte
|
||||
portalsJSON []byte
|
||||
linksJSON []byte
|
||||
bleJSON []byte
|
||||
triggersJSON []byte
|
||||
motionStatesJSON []byte
|
||||
securityJSON []byte
|
||||
confidence int
|
||||
timestampMs int64
|
||||
blobsJSON []byte
|
||||
nodesJSON []byte
|
||||
zonesJSON []byte
|
||||
portalsJSON []byte
|
||||
linksJSON []byte
|
||||
bleJSON []byte
|
||||
triggersJSON []byte
|
||||
motionStatesJSON []byte
|
||||
securityJSON []byte
|
||||
confidence int
|
||||
timestampMs int64
|
||||
}
|
||||
|
||||
// ZoneStateProvider is an interface to query zone data for the dashboard snapshot.
|
||||
|
|
@ -80,46 +80,46 @@ type ZoneStateProvider interface {
|
|||
|
||||
// PortalSnapshot is the wire format for a portal in the dashboard snapshot.
|
||||
type PortalSnapshot struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ZoneA string `json:"zone_a"`
|
||||
ZoneB string `json:"zone_b"`
|
||||
P1X float64 `json:"p1_x"`
|
||||
P1Y float64 `json:"p1_y"`
|
||||
P1Z float64 `json:"p1_z"`
|
||||
P2X float64 `json:"p2_x"`
|
||||
P2Y float64 `json:"p2_y"`
|
||||
P2Z float64 `json:"p2_z"`
|
||||
P3X float64 `json:"p3_x"`
|
||||
P3Y float64 `json:"p3_y"`
|
||||
P3Z float64 `json:"p3_z"`
|
||||
NX float64 `json:"n_x"`
|
||||
NY float64 `json:"n_y"`
|
||||
NZ float64 `json:"n_z"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ZoneA string `json:"zone_a"`
|
||||
ZoneB string `json:"zone_b"`
|
||||
P1X float64 `json:"p1_x"`
|
||||
P1Y float64 `json:"p1_y"`
|
||||
P1Z float64 `json:"p1_z"`
|
||||
P2X float64 `json:"p2_x"`
|
||||
P2Y float64 `json:"p2_y"`
|
||||
P2Z float64 `json:"p2_z"`
|
||||
P3X float64 `json:"p3_x"`
|
||||
P3Y float64 `json:"p3_y"`
|
||||
P3Z float64 `json:"p3_z"`
|
||||
NX float64 `json:"n_x"`
|
||||
NY float64 `json:"n_y"`
|
||||
NZ float64 `json:"n_z"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// ZoneSnapshot is the wire format for a zone in the dashboard snapshot.
|
||||
type ZoneSnapshot struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
People []string `json:"people"`
|
||||
MinX float64 `json:"x"`
|
||||
MinY float64 `json:"y"`
|
||||
MinZ float64 `json:"z"`
|
||||
SizeX float64 `json:"w"`
|
||||
SizeY float64 `json:"d"`
|
||||
SizeZ float64 `json:"h"`
|
||||
OccStatus string `json:"occ_status,omitempty"` // "uncertain" or "reconciled"
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
People []string `json:"people"`
|
||||
MinX float64 `json:"x"`
|
||||
MinY float64 `json:"y"`
|
||||
MinZ float64 `json:"z"`
|
||||
SizeX float64 `json:"w"`
|
||||
SizeY float64 `json:"d"`
|
||||
SizeZ float64 `json:"h"`
|
||||
OccStatus string `json:"occ_status,omitempty"` // "uncertain" or "reconciled"
|
||||
}
|
||||
|
||||
// ZoneOccupancySnapshot provides occupancy counts for zones.
|
||||
type ZoneOccupancySnapshot struct {
|
||||
Count int `json:"count"`
|
||||
BlobIDs []int `json:"blob_ids"`
|
||||
Count int `json:"count"`
|
||||
BlobIDs []int `json:"blob_ids"`
|
||||
}
|
||||
|
||||
// IngestionState is an interface to query node/link/motion state from ingestion
|
||||
|
|
@ -915,16 +915,16 @@ func (h *Hub) broadcastBLEScan() {
|
|||
// This implements the fleet.FleetChangeBroadcaster interface.
|
||||
func (h *Hub) BroadcastFleetChange(event fleet.FleetChangeEvent) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "fleet_change",
|
||||
"timestamp": event.Timestamp.UnixMilli(),
|
||||
"trigger_reason": event.TriggerReason,
|
||||
"mean_gdop_before": event.MeanGDOPBefore,
|
||||
"mean_gdop_after": event.MeanGDOPAfter,
|
||||
"coverage_before": event.CoverageBefore,
|
||||
"coverage_after": event.CoverageAfter,
|
||||
"coverage_delta": event.CoverageDelta,
|
||||
"is_degradation": event.IsDegradation,
|
||||
"role_assignments": event.RoleAssignments,
|
||||
"type": "fleet_change",
|
||||
"timestamp": event.Timestamp.UnixMilli(),
|
||||
"trigger_reason": event.TriggerReason,
|
||||
"mean_gdop_before": event.MeanGDOPBefore,
|
||||
"mean_gdop_after": event.MeanGDOPAfter,
|
||||
"coverage_before": event.CoverageBefore,
|
||||
"coverage_after": event.CoverageAfter,
|
||||
"coverage_delta": event.CoverageDelta,
|
||||
"is_degradation": event.IsDegradation,
|
||||
"role_assignments": event.RoleAssignments,
|
||||
}
|
||||
|
||||
if event.OfflineMAC != "" {
|
||||
|
|
@ -993,12 +993,12 @@ func (h *Hub) BroadcastFleetHealth(nodes []fleet.NodeRecord, roles map[string]st
|
|||
// BroadcastFleetHistory broadcasts optimisation history to dashboard.
|
||||
func (h *Hub) BroadcastFleetHistory(history []fleet.OptimisationHistoryRecord) {
|
||||
type historyEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp int64 `json:"timestamp_ms"`
|
||||
TriggerReason string `json:"trigger_reason"`
|
||||
MeanGDOPBefore float64 `json:"mean_gdop_before"`
|
||||
MeanGDOPAfter float64 `json:"mean_gdop_after"`
|
||||
CoverageDelta float64 `json:"coverage_delta"`
|
||||
ID int64 `json:"id"`
|
||||
Timestamp int64 `json:"timestamp_ms"`
|
||||
TriggerReason string `json:"trigger_reason"`
|
||||
MeanGDOPBefore float64 `json:"mean_gdop_before"`
|
||||
MeanGDOPAfter float64 `json:"mean_gdop_after"`
|
||||
CoverageDelta float64 `json:"coverage_delta"`
|
||||
}
|
||||
|
||||
wireHistory := make([]historyEntry, len(history))
|
||||
|
|
@ -1131,11 +1131,11 @@ func (h *Hub) BroadcastSystemHealth(uptimeS int64, nodeCount, beadCount, goRouti
|
|||
msg := map[string]interface{}{
|
||||
"type": "system_health",
|
||||
"health": map[string]interface{}{
|
||||
"uptime_s": uptimeS,
|
||||
"node_count": nodeCount,
|
||||
"bead_count": beadCount,
|
||||
"go_routines": goRoutines,
|
||||
"mem_mb": memMB,
|
||||
"uptime_s": uptimeS,
|
||||
"node_count": nodeCount,
|
||||
"bead_count": beadCount,
|
||||
"go_routines": goRoutines,
|
||||
"mem_mb": memMB,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
|
|
@ -1149,14 +1149,14 @@ func (h *Hub) BroadcastEventFromDB(id int64, timestamp int64, eventType, zone, p
|
|||
msg := map[string]interface{}{
|
||||
"type": "event",
|
||||
"event": map[string]interface{}{
|
||||
"id": id,
|
||||
"ts": timestamp,
|
||||
"kind": eventType,
|
||||
"zone": zone,
|
||||
"blob_id": blobID,
|
||||
"person_name": person,
|
||||
"detail_json": detailJSON,
|
||||
"severity": severity,
|
||||
"id": id,
|
||||
"ts": timestamp,
|
||||
"kind": eventType,
|
||||
"zone": zone,
|
||||
"blob_id": blobID,
|
||||
"person_name": person,
|
||||
"detail_json": detailJSON,
|
||||
"severity": severity,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
|
|
@ -1168,9 +1168,9 @@ func (h *Hub) BroadcastEventFromDB(id int64, timestamp int64, eventType, zone, p
|
|||
// reflects the new state. action is "created", "updated", or "deleted".
|
||||
func (h *Hub) BroadcastZoneChange(action string, zone ZoneSnapshot) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "zone_change",
|
||||
"type": "zone_change",
|
||||
"action": action,
|
||||
"zone": zone,
|
||||
"zone": zone,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
|
|
@ -1187,7 +1187,7 @@ func (h *Hub) BroadcastZoneChange(action string, zone ZoneSnapshot) {
|
|||
func (h *Hub) BroadcastPortalChange(action string, portal PortalSnapshot) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "portal_change",
|
||||
"action": action,
|
||||
"action": action,
|
||||
"portal": portal,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
|
|
@ -1217,8 +1217,8 @@ func (h *Hub) BroadcastLoadState(level int, label string) {
|
|||
// completed sleep session exists.
|
||||
func (h *Hub) BroadcastMorningSummary(summary map[string]interface{}) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "morning_summary",
|
||||
"sleep": summary,
|
||||
"type": "morning_summary",
|
||||
"sleep": summary,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
|
|
@ -1313,10 +1313,10 @@ func (h *Hub) BroadcastReplayBlobs(blobs []replay.BlobUpdate, timestampMS int64)
|
|||
// detection quality drops below 60% for over 24 hours.
|
||||
func (h *Hub) BroadcastQualityDrop(zoneID int, zoneName string, quality float64) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "quality_drop",
|
||||
"zone_id": zoneID,
|
||||
"zone_name": zoneName,
|
||||
"quality": quality,
|
||||
"type": "quality_drop",
|
||||
"zone_id": zoneID,
|
||||
"zone_name": zoneName,
|
||||
"quality": quality,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
|
|
@ -1327,8 +1327,8 @@ func (h *Hub) BroadcastQualityDrop(zoneID int, zoneName string, quality float64)
|
|||
// settings key is edited 3+ times within 60 minutes.
|
||||
func (h *Hub) BroadcastRepeatedEdit(key string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "repeated_edit",
|
||||
"key": key,
|
||||
"type": "repeated_edit",
|
||||
"key": key,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
|
|
@ -1387,12 +1387,12 @@ func (h *Hub) BroadcastZoneOccupancy(occupancy map[string]ZoneOccupancySnapshot)
|
|||
// Fired when a blob crosses a portal from one zone to another.
|
||||
func (h *Hub) BroadcastZoneTransition(portalID string, personLabel string, fromZone, toZone string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "zone_transition",
|
||||
"portal_id": portalID,
|
||||
"person": personLabel,
|
||||
"from_zone": fromZone,
|
||||
"to_zone": toZone,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"type": "zone_transition",
|
||||
"portal_id": portalID,
|
||||
"person": personLabel,
|
||||
"from_zone": fromZone,
|
||||
"to_zone": toZone,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
|
|
@ -1402,12 +1402,12 @@ func (h *Hub) BroadcastZoneTransition(portalID string, personLabel string, fromZ
|
|||
// Called when OTA state changes (pending, downloading, rebooting, verified, failed, rollback).
|
||||
func (h *Hub) BroadcastOTAProgress(mac string, state string, progressPct uint8, expectedVersion, previousVersion string, errorMsg string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "ota_progress",
|
||||
"mac": mac,
|
||||
"state": state,
|
||||
"progress_pct": progressPct,
|
||||
"expected_version": expectedVersion,
|
||||
"previous_version": previousVersion,
|
||||
"type": "ota_progress",
|
||||
"mac": mac,
|
||||
"state": state,
|
||||
"progress_pct": progressPct,
|
||||
"expected_version": expectedVersion,
|
||||
"previous_version": previousVersion,
|
||||
}
|
||||
if errorMsg != "" {
|
||||
msg["error"] = errorMsg
|
||||
|
|
|
|||
|
|
@ -397,12 +397,12 @@ func TestHub_BroadcastAlert(t *testing.T) {
|
|||
|
||||
func TestHub_BroadcastEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventID string
|
||||
kind string
|
||||
zone string
|
||||
blobID int
|
||||
personName string
|
||||
name string
|
||||
eventID string
|
||||
kind string
|
||||
zone string
|
||||
blobID int
|
||||
personName string
|
||||
}{
|
||||
{
|
||||
name: "zone entry with person",
|
||||
|
|
@ -584,46 +584,46 @@ func TestHub_BroadcastBLEScan(t *testing.T) {
|
|||
|
||||
func TestHub_BroadcastEventFromDB(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id int64
|
||||
timestamp int64
|
||||
eventType string
|
||||
zone string
|
||||
person string
|
||||
blobID int
|
||||
detailJSON string
|
||||
severity string
|
||||
name string
|
||||
id int64
|
||||
timestamp int64
|
||||
eventType string
|
||||
zone string
|
||||
person string
|
||||
blobID int
|
||||
detailJSON string
|
||||
severity string
|
||||
}{
|
||||
{
|
||||
name: "zone entry with person and detail",
|
||||
id: 42,
|
||||
timestamp: 1711234567890,
|
||||
eventType: "zone_entry",
|
||||
zone: "Kitchen",
|
||||
person: "Alice",
|
||||
blobID: 2,
|
||||
name: "zone entry with person and detail",
|
||||
id: 42,
|
||||
timestamp: 1711234567890,
|
||||
eventType: "zone_entry",
|
||||
zone: "Kitchen",
|
||||
person: "Alice",
|
||||
blobID: 2,
|
||||
detailJSON: `{"direction":"north"}`,
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
name: "zone exit without person",
|
||||
id: 43,
|
||||
timestamp: 1711234567891,
|
||||
eventType: "zone_exit",
|
||||
zone: "Kitchen",
|
||||
person: "",
|
||||
blobID: 3,
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
name: "zone exit without person",
|
||||
id: 43,
|
||||
timestamp: 1711234567891,
|
||||
eventType: "zone_exit",
|
||||
zone: "Kitchen",
|
||||
person: "",
|
||||
blobID: 3,
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
name: "portal crossing",
|
||||
id: 44,
|
||||
timestamp: 1711234567892,
|
||||
eventType: "portal_crossing",
|
||||
zone: "Hallway",
|
||||
person: "Bob",
|
||||
blobID: 1,
|
||||
severity: "info",
|
||||
name: "portal crossing",
|
||||
id: 44,
|
||||
timestamp: 1711234567892,
|
||||
eventType: "portal_crossing",
|
||||
zone: "Hallway",
|
||||
person: "Bob",
|
||||
blobID: 1,
|
||||
severity: "info",
|
||||
},
|
||||
{
|
||||
name: "anomaly alert",
|
||||
|
|
@ -637,14 +637,14 @@ func TestHub_BroadcastEventFromDB(t *testing.T) {
|
|||
severity: "warning",
|
||||
},
|
||||
{
|
||||
name: "minimal event",
|
||||
id: 46,
|
||||
timestamp: 1711234567894,
|
||||
eventType: "system",
|
||||
zone: "",
|
||||
person: "",
|
||||
blobID: 0,
|
||||
severity: "info",
|
||||
name: "minimal event",
|
||||
id: 46,
|
||||
timestamp: 1711234567894,
|
||||
eventType: "system",
|
||||
zone: "",
|
||||
person: "",
|
||||
blobID: 0,
|
||||
severity: "info",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -729,12 +729,12 @@ func TestHub_BroadcastEventFromDB(t *testing.T) {
|
|||
|
||||
func TestHub_BroadcastSystemHealth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uptimeS int64
|
||||
nodeCount int
|
||||
beadCount int
|
||||
goRoutines int
|
||||
memMB float64
|
||||
name string
|
||||
uptimeS int64
|
||||
nodeCount int
|
||||
beadCount int
|
||||
goRoutines int
|
||||
memMB float64
|
||||
}{
|
||||
{
|
||||
name: "fresh start",
|
||||
|
|
@ -878,32 +878,32 @@ func TestHub_DeltaOmitsTypeField(t *testing.T) {
|
|||
|
||||
func TestHub_BroadcastTriggerState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
triggerID string
|
||||
name string
|
||||
triggerID string
|
||||
triggerName string
|
||||
lastFired time.Time
|
||||
enabled bool
|
||||
lastFired time.Time
|
||||
enabled bool
|
||||
}{
|
||||
{
|
||||
name: "enabled trigger with last fired",
|
||||
triggerID: "trigger-1",
|
||||
name: "enabled trigger with last fired",
|
||||
triggerID: "trigger-1",
|
||||
triggerName: "Couch Dwell",
|
||||
lastFired: time.Date(2026, 4, 7, 14, 32, 5, 0, time.UTC),
|
||||
enabled: true,
|
||||
lastFired: time.Date(2026, 4, 7, 14, 32, 5, 0, time.UTC),
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "disabled trigger never fired",
|
||||
triggerID: "trigger-2",
|
||||
name: "disabled trigger never fired",
|
||||
triggerID: "trigger-2",
|
||||
triggerName: "Hallway Motion",
|
||||
lastFired: time.Time{},
|
||||
enabled: false,
|
||||
lastFired: time.Time{},
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
name: "enabled trigger never fired",
|
||||
triggerID: "trigger-3",
|
||||
name: "enabled trigger never fired",
|
||||
triggerID: "trigger-3",
|
||||
triggerName: "Kitchen Entry",
|
||||
lastFired: time.Time{},
|
||||
enabled: true,
|
||||
lastFired: time.Time{},
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ import (
|
|||
// OpenDB initializes the database with full startup sequence.
|
||||
// It runs migrations, creates backups, and returns a ready-to-use database connection.
|
||||
// The startup sequence is:
|
||||
// 1. Data directory: verify /data is writable; acquire flock() lock
|
||||
// 2. SQLite: open database with WAL mode, busy_timeout=5000
|
||||
// 3. Schema migration: apply pending migrations with backup
|
||||
// 4. Config & secrets: load/generate install secret
|
||||
// 1. Data directory: verify /data is writable; acquire flock() lock
|
||||
// 2. SQLite: open database with WAL mode, busy_timeout=5000
|
||||
// 3. Schema migration: apply pending migrations with backup
|
||||
// 4. Config & secrets: load/generate install secret
|
||||
//
|
||||
// The parentCtx should be the startup timeout context from main so that all
|
||||
// phases share the same 30-second deadline. If parentCtx is nil, a fresh
|
||||
|
|
@ -75,7 +75,7 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error)
|
|||
db.SetMaxOpenConns(1) // SQLite is single-writer
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close() //nolint:errcheck
|
||||
db.Close() //nolint:errcheck
|
||||
lockFile.Close() //nolint:errcheck
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
|
@ -108,7 +108,7 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error)
|
|||
BackupRetention: 90 * 24 * time.Hour,
|
||||
})
|
||||
if err != nil {
|
||||
db.Close() //nolint:errcheck
|
||||
db.Close() //nolint:errcheck
|
||||
lockFile.Close() //nolint:errcheck
|
||||
return nil, fmt.Errorf("create migrator: %w", err)
|
||||
}
|
||||
|
|
@ -116,7 +116,7 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error)
|
|||
|
||||
current, err := migrator.CurrentVersion(ctx)
|
||||
if err != nil {
|
||||
db.Close() //nolint:errcheck
|
||||
db.Close() //nolint:errcheck
|
||||
lockFile.Close() //nolint:errcheck
|
||||
return nil, fmt.Errorf("get current version: %w", err)
|
||||
}
|
||||
|
|
@ -128,7 +128,7 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error)
|
|||
}
|
||||
|
||||
if err := migrator.Migrate(ctx); err != nil {
|
||||
db.Close() //nolint:errcheck
|
||||
db.Close() //nolint:errcheck
|
||||
lockFile.Close() //nolint:errcheck
|
||||
return nil, fmt.Errorf("apply migrations: %w", err)
|
||||
}
|
||||
|
|
@ -147,7 +147,7 @@ func OpenDB(parentCtx context.Context, dataDir, dbName string) (*sql.DB, error)
|
|||
startup.CheckTimeout(ctx)
|
||||
done = startup.Phase(4, "Config & secrets")
|
||||
if err := ensureInstallSecret(ctx, db); err != nil {
|
||||
db.Close() //nolint:errcheck
|
||||
db.Close() //nolint:errcheck
|
||||
lockFile.Close() //nolint:errcheck
|
||||
return nil, fmt.Errorf("ensure install secret: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -386,10 +386,10 @@ func TestPreMigrationBackup(t *testing.T) {
|
|||
// TestCurrentVersion tests getting the current schema version.
|
||||
func TestCurrentVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(*sql.DB, *testing.T) error
|
||||
wantVersion int
|
||||
wantErr bool
|
||||
name string
|
||||
setupFunc func(*sql.DB, *testing.T) error
|
||||
wantVersion int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "no migrations table",
|
||||
|
|
|
|||
|
|
@ -16,23 +16,23 @@ import (
|
|||
type DiagnosisSeverity string
|
||||
|
||||
const (
|
||||
SeverityINFO DiagnosisSeverity = "INFO" // Informational, no action needed
|
||||
SeverityWARNING DiagnosisSeverity = "WARNING" // Attention recommended
|
||||
SeverityINFO DiagnosisSeverity = "INFO" // Informational, no action needed
|
||||
SeverityWARNING DiagnosisSeverity = "WARNING" // Attention recommended
|
||||
SeverityACTIONABLE DiagnosisSeverity = "ACTIONABLE" // Specific action required
|
||||
)
|
||||
|
||||
// Diagnosis represents a diagnostic finding for a link
|
||||
type Diagnosis struct {
|
||||
LinkID string
|
||||
RuleID string // Identifies which rule fired
|
||||
Severity DiagnosisSeverity // INFO, WARNING, ACTIONABLE
|
||||
Title string // Human-readable headline
|
||||
Detail string // Explanation in plain language
|
||||
Advice string // Specific actionable steps
|
||||
RepositioningTarget *Vec3 // 3D position to move node, or nil
|
||||
RepositioningNodeMAC string // Which node to move
|
||||
ConfidenceScore float64 // How confident the engine is (0-1)
|
||||
Timestamp time.Time
|
||||
LinkID string
|
||||
RuleID string // Identifies which rule fired
|
||||
Severity DiagnosisSeverity // INFO, WARNING, ACTIONABLE
|
||||
Title string // Human-readable headline
|
||||
Detail string // Explanation in plain language
|
||||
Advice string // Specific actionable steps
|
||||
RepositioningTarget *Vec3 // 3D position to move node, or nil
|
||||
RepositioningNodeMAC string // Which node to move
|
||||
ConfidenceScore float64 // How confident the engine is (0-1)
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// Vec3 represents a 3D position
|
||||
|
|
@ -44,21 +44,21 @@ type Vec3 struct {
|
|||
|
||||
// LinkHealthSnapshot represents a health snapshot for diagnostic analysis
|
||||
type LinkHealthSnapshot struct {
|
||||
Timestamp time.Time
|
||||
SNR float64
|
||||
PhaseStability float64
|
||||
PacketRate float64
|
||||
DriftRate float64
|
||||
CompositeScore float64
|
||||
Timestamp time.Time
|
||||
SNR float64
|
||||
PhaseStability float64
|
||||
PacketRate float64
|
||||
DriftRate float64
|
||||
CompositeScore float64
|
||||
DeltaRMSVariance float64 // For periodic interference detection
|
||||
IsQuietPeriod bool // True if no motion detected
|
||||
IsQuietPeriod bool // True if no motion detected
|
||||
}
|
||||
|
||||
// FeedbackEvent represents user-reported false negative/positive for Rule 4
|
||||
type FeedbackEvent struct {
|
||||
LinkID string
|
||||
EventType string // "false_negative" or "false_positive"
|
||||
Position Vec3 // Where the event occurred
|
||||
EventType string // "false_negative" or "false_positive"
|
||||
Position Vec3 // Where the event occurred
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
|
|
@ -324,15 +324,15 @@ func (de *DiagnosticEngine) checkEnvironmentalChange(linkID string, history []Li
|
|||
confidence := 0.85
|
||||
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "environmental_change",
|
||||
Severity: SeverityINFO,
|
||||
Title: "Environmental change detected",
|
||||
Detail: "Multiple sensing links are showing simultaneous baseline shifts. This typically indicates a temperature change, or a large object was moved in the space. The system is adapting automatically.",
|
||||
Advice: "No action needed. The baseline will re-stabilise within 30 minutes.",
|
||||
LinkID: linkID,
|
||||
RuleID: "environmental_change",
|
||||
Severity: SeverityINFO,
|
||||
Title: "Environmental change detected",
|
||||
Detail: "Multiple sensing links are showing simultaneous baseline shifts. This typically indicates a temperature change, or a large object was moved in the space. The system is adapting automatically.",
|
||||
Advice: "No action needed. The baseline will re-stabilise within 30 minutes.",
|
||||
RepositioningTarget: nil,
|
||||
ConfidenceScore: confidence,
|
||||
Timestamp: time.Now(),
|
||||
ConfidenceScore: confidence,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -391,16 +391,16 @@ func (de *DiagnosticEngine) checkWiFiCongestion(linkID string, history []LinkHea
|
|||
nodeBMAC := extractNodeBMAC(linkID)
|
||||
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "wifi_congestion_distance",
|
||||
Severity: SeverityACTIONABLE,
|
||||
Title: "Node has low signal rate",
|
||||
Detail: formatWiFiDetail(nodeBMAC, avgPacketRate),
|
||||
Advice: formatWiFiAdvice(nodeBMAC),
|
||||
RepositioningTarget: nil,
|
||||
LinkID: linkID,
|
||||
RuleID: "wifi_congestion_distance",
|
||||
Severity: SeverityACTIONABLE,
|
||||
Title: "Node has low signal rate",
|
||||
Detail: formatWiFiDetail(nodeBMAC, avgPacketRate),
|
||||
Advice: formatWiFiAdvice(nodeBMAC),
|
||||
RepositioningTarget: nil,
|
||||
RepositioningNodeMAC: nodeBMAC,
|
||||
ConfidenceScore: 0.75,
|
||||
Timestamp: time.Now(),
|
||||
ConfidenceScore: 0.75,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -461,16 +461,16 @@ func (de *DiagnosticEngine) checkMetalInterference(linkID string, history []Link
|
|||
nodeAMAC := extractNodeAMAC(linkID)
|
||||
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "metal_interference",
|
||||
Severity: SeverityACTIONABLE,
|
||||
Title: formatMetalTitle(nodeAMAC),
|
||||
Detail: formatMetalDetail(linkID),
|
||||
Advice: formatMetalAdvice(nodeAMAC),
|
||||
RepositioningTarget: nil,
|
||||
LinkID: linkID,
|
||||
RuleID: "metal_interference",
|
||||
Severity: SeverityACTIONABLE,
|
||||
Title: formatMetalTitle(nodeAMAC),
|
||||
Detail: formatMetalDetail(linkID),
|
||||
Advice: formatMetalAdvice(nodeAMAC),
|
||||
RepositioningTarget: nil,
|
||||
RepositioningNodeMAC: nodeAMAC,
|
||||
ConfidenceScore: 0.80,
|
||||
Timestamp: time.Now(),
|
||||
ConfidenceScore: 0.80,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -538,16 +538,16 @@ func (de *DiagnosticEngine) checkFresnelBlockage(linkID string, history []LinkHe
|
|||
advice := formatFresnelAdvice(nodeBMAC, &blockedZone, target, improvement)
|
||||
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "fresnel_blockage",
|
||||
Severity: SeverityACTIONABLE,
|
||||
Title: "Coverage gap detected - possible obstruction",
|
||||
Detail: detail,
|
||||
Advice: advice,
|
||||
RepositioningTarget: target,
|
||||
LinkID: linkID,
|
||||
RuleID: "fresnel_blockage",
|
||||
Severity: SeverityACTIONABLE,
|
||||
Title: "Coverage gap detected - possible obstruction",
|
||||
Detail: detail,
|
||||
Advice: advice,
|
||||
RepositioningTarget: target,
|
||||
RepositioningNodeMAC: targetNodeMAC,
|
||||
ConfidenceScore: 0.75,
|
||||
Timestamp: time.Now(),
|
||||
ConfidenceScore: 0.75,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -582,16 +582,16 @@ func (de *DiagnosticEngine) checkFresnelBlockageHeuristic(linkID string, history
|
|||
if avgActivity < 0.5 && avgActivity < avgQuiet-0.2 {
|
||||
nodeBMAC := extractNodeBMAC(linkID)
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "fresnel_blockage_heuristic",
|
||||
Severity: SeverityWARNING,
|
||||
Title: "Possible coverage gap detected",
|
||||
Detail: "Detection quality degrades during movement periods. This may indicate an obstruction in the sensing zone.",
|
||||
Advice: "Submit feedback using the app when detection misses occur. This will help identify the exact location of the coverage gap.",
|
||||
RepositioningTarget: nil,
|
||||
LinkID: linkID,
|
||||
RuleID: "fresnel_blockage_heuristic",
|
||||
Severity: SeverityWARNING,
|
||||
Title: "Possible coverage gap detected",
|
||||
Detail: "Detection quality degrades during movement periods. This may indicate an obstruction in the sensing zone.",
|
||||
Advice: "Submit feedback using the app when detection misses occur. This will help identify the exact location of the coverage gap.",
|
||||
RepositioningTarget: nil,
|
||||
RepositioningNodeMAC: nodeBMAC,
|
||||
ConfidenceScore: 0.60, // Lower confidence for heuristic
|
||||
Timestamp: time.Now(),
|
||||
ConfidenceScore: 0.60, // Lower confidence for heuristic
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -639,15 +639,15 @@ func (de *DiagnosticEngine) checkPeriodicInterference(linkID string, history []L
|
|||
nodeBMAC := extractNodeBMAC(linkID)
|
||||
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "periodic_interference",
|
||||
Severity: SeverityWARNING,
|
||||
Title: "Periodic interference detected",
|
||||
Detail: formatInterferenceDetail(nodeAMAC, nodeBMAC, int(eventsPerHour)),
|
||||
Advice: formatInterferenceAdvice(nodeAMAC, nodeBMAC),
|
||||
LinkID: linkID,
|
||||
RuleID: "periodic_interference",
|
||||
Severity: SeverityWARNING,
|
||||
Title: "Periodic interference detected",
|
||||
Detail: formatInterferenceDetail(nodeAMAC, nodeBMAC, int(eventsPerHour)),
|
||||
Advice: formatInterferenceAdvice(nodeAMAC, nodeBMAC),
|
||||
RepositioningTarget: nil,
|
||||
ConfidenceScore: 0.70,
|
||||
Timestamp: time.Now(),
|
||||
ConfidenceScore: 0.70,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1036,14 +1036,14 @@ func (de *DiagnosticEngine) GetDiagnosticFor(linkID string, timestamp time.Time)
|
|||
if len(history) < de.config.MinSamples {
|
||||
// Not enough data - return a generic diagnosis
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "insufficient_data",
|
||||
Severity: SeverityINFO,
|
||||
Title: "Not enough data for diagnosis",
|
||||
Detail: "This link hasn't been active long enough to analyze.",
|
||||
Advice: "Continue normal operation. Diagnostics will be available after more data is collected.",
|
||||
ConfidenceScore: 0.0,
|
||||
Timestamp: time.Now(),
|
||||
LinkID: linkID,
|
||||
RuleID: "insufficient_data",
|
||||
Severity: SeverityINFO,
|
||||
Title: "Not enough data for diagnosis",
|
||||
Detail: "This link hasn't been active long enough to analyze.",
|
||||
Advice: "Continue normal operation. Diagnostics will be available after more data is collected.",
|
||||
ConfidenceScore: 0.0,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1072,14 +1072,14 @@ func (de *DiagnosticEngine) GetDiagnosticFor(linkID string, timestamp time.Time)
|
|||
// Rule: Check for environmental change indicators
|
||||
if closestSnapshot.DriftRate > 0.05 {
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "environmental_change",
|
||||
Severity: SeverityINFO,
|
||||
Title: "Possible environmental change",
|
||||
Detail: "The baseline for this link has drifted significantly. This could be caused by temperature changes, furniture movement, or new obstructions.",
|
||||
Advice: "The system is adapting automatically. No action needed unless this persists.",
|
||||
LinkID: linkID,
|
||||
RuleID: "environmental_change",
|
||||
Severity: SeverityINFO,
|
||||
Title: "Possible environmental change",
|
||||
Detail: "The baseline for this link has drifted significantly. This could be caused by temperature changes, furniture movement, or new obstructions.",
|
||||
Advice: "The system is adapting automatically. No action needed unless this persists.",
|
||||
ConfidenceScore: 0.70,
|
||||
Timestamp: time.Now(),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1087,15 +1087,15 @@ func (de *DiagnosticEngine) GetDiagnosticFor(linkID string, timestamp time.Time)
|
|||
if closestSnapshot.PacketRate < 16.0 { // Less than 80% of expected 20 Hz
|
||||
nodeBMAC := extractNodeBMAC(linkID)
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "wifi_congestion",
|
||||
Severity: SeverityACTIONABLE,
|
||||
Title: "Possible WiFi congestion",
|
||||
Detail: formatWiFiDetail(nodeBMAC, closestSnapshot.PacketRate),
|
||||
Advice: formatWiFiAdvice(nodeBMAC),
|
||||
LinkID: linkID,
|
||||
RuleID: "wifi_congestion",
|
||||
Severity: SeverityACTIONABLE,
|
||||
Title: "Possible WiFi congestion",
|
||||
Detail: formatWiFiDetail(nodeBMAC, closestSnapshot.PacketRate),
|
||||
Advice: formatWiFiAdvice(nodeBMAC),
|
||||
RepositioningNodeMAC: nodeBMAC,
|
||||
ConfidenceScore: 0.75,
|
||||
Timestamp: time.Now(),
|
||||
ConfidenceScore: 0.75,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1103,15 +1103,15 @@ func (de *DiagnosticEngine) GetDiagnosticFor(linkID string, timestamp time.Time)
|
|||
if closestSnapshot.PhaseStability > 0.6 {
|
||||
nodeAMAC := extractNodeAMAC(linkID)
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "metal_interference",
|
||||
Severity: SeverityACTIONABLE,
|
||||
Title: formatMetalTitle(nodeAMAC),
|
||||
Detail: formatMetalDetail(linkID),
|
||||
Advice: formatMetalAdvice(nodeAMAC),
|
||||
LinkID: linkID,
|
||||
RuleID: "metal_interference",
|
||||
Severity: SeverityACTIONABLE,
|
||||
Title: formatMetalTitle(nodeAMAC),
|
||||
Detail: formatMetalDetail(linkID),
|
||||
Advice: formatMetalAdvice(nodeAMAC),
|
||||
RepositioningNodeMAC: nodeAMAC,
|
||||
ConfidenceScore: 0.80,
|
||||
Timestamp: time.Now(),
|
||||
ConfidenceScore: 0.80,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1120,27 +1120,27 @@ func (de *DiagnosticEngine) GetDiagnosticFor(linkID string, timestamp time.Time)
|
|||
nodeAMAC := extractNodeAMAC(linkID)
|
||||
nodeBMAC := extractNodeBMAC(linkID)
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "periodic_interference",
|
||||
Severity: SeverityWARNING,
|
||||
Title: "Possible periodic interference",
|
||||
Detail: formatInterferenceDetail(nodeAMAC, nodeBMAC, 3),
|
||||
Advice: formatInterferenceAdvice(nodeAMAC, nodeBMAC),
|
||||
LinkID: linkID,
|
||||
RuleID: "periodic_interference",
|
||||
Severity: SeverityWARNING,
|
||||
Title: "Possible periodic interference",
|
||||
Detail: formatInterferenceDetail(nodeAMAC, nodeBMAC, 3),
|
||||
Advice: formatInterferenceAdvice(nodeAMAC, nodeBMAC),
|
||||
ConfidenceScore: 0.70,
|
||||
Timestamp: time.Now(),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Default: no specific issue found
|
||||
return &Diagnosis{
|
||||
LinkID: linkID,
|
||||
RuleID: "no_issue_detected",
|
||||
Severity: SeverityINFO,
|
||||
Title: "No specific issue detected",
|
||||
Detail: "The link health metrics appear normal. The false positive may be due to transient RF interference or a brief environmental change.",
|
||||
Advice: "This is a rare occurrence. If it happens frequently, consider submitting feedback so the system can learn.",
|
||||
LinkID: linkID,
|
||||
RuleID: "no_issue_detected",
|
||||
Severity: SeverityINFO,
|
||||
Title: "No specific issue detected",
|
||||
Detail: "The link health metrics appear normal. The false positive may be due to transient RF interference or a brief environmental change.",
|
||||
Advice: "This is a rare occurrence. If it happens frequently, consider submitting feedback so the system can learn.",
|
||||
ConfidenceScore: 0.50,
|
||||
Timestamp: time.Now(),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -710,11 +710,11 @@ func TestGetAllDiagnoses(t *testing.T) {
|
|||
// TestGetDiagnosticFor tests point-in-time diagnostic lookup
|
||||
func TestGetDiagnosticFor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
linkID string
|
||||
setupHistory func() []LinkHealthSnapshot
|
||||
wantRuleID string
|
||||
wantSeverity DiagnosisSeverity
|
||||
name string
|
||||
linkID string
|
||||
setupHistory func() []LinkHealthSnapshot
|
||||
wantRuleID string
|
||||
wantSeverity DiagnosisSeverity
|
||||
}{
|
||||
{
|
||||
name: "environmental_change_from_high_drift",
|
||||
|
|
@ -881,7 +881,7 @@ func TestGetDiagnosticFor_PriorityOrder(t *testing.T) {
|
|||
SNR: 0.7,
|
||||
PhaseStability: 0.3,
|
||||
PacketRate: 10.0, // Low
|
||||
DriftRate: 0.08, // High
|
||||
DriftRate: 0.08, // High
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func (rc *RepositioningComputer) ComputeRepositioningTarget(linkID string, block
|
|||
bestPos := currentPos
|
||||
bestImprovement := 0.0
|
||||
|
||||
step := 0.3 // 30cm steps
|
||||
step := 0.3 // 30cm steps
|
||||
minDist := 0.5 // Minimum distance from other nodes
|
||||
|
||||
for x := originX + step; x < originX+width-step; x += step {
|
||||
|
|
@ -167,8 +167,8 @@ func (rc *RepositioningComputer) computeWorstGDOPNear(fixedPositions []NodePosit
|
|||
for row := 0; row < rows; row++ {
|
||||
for col := 0; col < cols; col++ {
|
||||
// Convert cell to room coordinates
|
||||
cellX := originX + (float64(col) + 0.5) * cellWidth
|
||||
cellZ := originZ + (float64(row) + 0.5) * cellDepth
|
||||
cellX := originX + (float64(col)+0.5)*cellWidth
|
||||
cellZ := originZ + (float64(row)+0.5)*cellDepth
|
||||
|
||||
// Check distance from center
|
||||
dist := math.Sqrt((cellX-center.X)*(cellX-center.X) + (cellZ-center.Z)*(cellZ-center.Z))
|
||||
|
|
|
|||
|
|
@ -65,14 +65,14 @@ type Monitor struct {
|
|||
interval time.Duration
|
||||
|
||||
// Components to control
|
||||
recorder WritePauser // CSI replay buffer
|
||||
flowAccumulator WritePauser // Crowd flow accumulation
|
||||
predictor UpdatePauser // Prediction model updates
|
||||
recorder WritePauser // CSI replay buffer
|
||||
flowAccumulator WritePauser // Crowd flow accumulation
|
||||
predictor UpdatePauser // Prediction model updates
|
||||
|
||||
// State
|
||||
state State
|
||||
freeMB uint64
|
||||
lastCheck time.Time
|
||||
state State
|
||||
freeMB uint64
|
||||
lastCheck time.Time
|
||||
|
||||
// Lifecycle
|
||||
ctx context.Context
|
||||
|
|
@ -295,11 +295,11 @@ func (m *Monitor) GetState() (state State, freeMB uint64, lastCheck time.Time) {
|
|||
|
||||
// Stats returns disk-space statistics for the dashboard.
|
||||
type Stats struct {
|
||||
State State `json:"state"`
|
||||
FreeMB uint64 `json:"free_mb"`
|
||||
WarningMB uint64 `json:"warning_mb"`
|
||||
CriticalMB uint64 `json:"critical_mb"`
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
State State `json:"state"`
|
||||
FreeMB uint64 `json:"free_mb"`
|
||||
WarningMB uint64 `json:"warning_mb"`
|
||||
CriticalMB uint64 `json:"critical_mb"`
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
}
|
||||
|
||||
// GetStats returns current statistics for dashboard display.
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import (
|
|||
|
||||
// mockWritePauser is a test mock for WritePauser.
|
||||
type mockWritePauser struct {
|
||||
paused atomic.Bool
|
||||
pauseCnt atomic.Int32
|
||||
paused atomic.Bool
|
||||
pauseCnt atomic.Int32
|
||||
resumeCnt atomic.Int32
|
||||
}
|
||||
|
||||
|
|
@ -41,8 +41,8 @@ func (m *mockWritePauser) ResumeCount() int {
|
|||
|
||||
// mockUpdatePauser is a test mock for UpdatePauser.
|
||||
type mockUpdatePauser struct {
|
||||
paused atomic.Bool
|
||||
pauseCnt atomic.Int32
|
||||
paused atomic.Bool
|
||||
pauseCnt atomic.Int32
|
||||
resumeCnt atomic.Int32
|
||||
}
|
||||
|
||||
|
|
@ -311,11 +311,11 @@ func TestMonitor_NilComponents(t *testing.T) {
|
|||
|
||||
// Create monitor with nil components
|
||||
m := New(Config{
|
||||
DataDir: tmpDir,
|
||||
CheckInterval: time.Second,
|
||||
Recorder: nil,
|
||||
DataDir: tmpDir,
|
||||
CheckInterval: time.Second,
|
||||
Recorder: nil,
|
||||
FlowAccumulator: nil,
|
||||
Predictor: nil,
|
||||
Predictor: nil,
|
||||
})
|
||||
|
||||
// Should not panic
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ type CheckResult struct {
|
|||
// Response is the doctor endpoint response.
|
||||
type Response struct {
|
||||
Checks []CheckResult `json:"checks"`
|
||||
Overall string `json:"overall"` // "ok", "warn", "error"
|
||||
Overall string `json:"overall"` // "ok", "warn", "error"
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -105,11 +105,11 @@ func TestCheckDataDirWritable(t *testing.T) {
|
|||
|
||||
func TestCheckFirmwareDir(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
name string
|
||||
setupFirmware func() string
|
||||
wantName string
|
||||
wantStatus string
|
||||
wantMessage string
|
||||
wantName string
|
||||
wantStatus string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "has firmware binaries",
|
||||
|
|
@ -349,9 +349,9 @@ func TestCheckInstallSecret(t *testing.T) {
|
|||
|
||||
func TestCheckPINConfigured(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbSetup func(*sql.DB)
|
||||
wantStatus string
|
||||
name string
|
||||
dbSetup func(*sql.DB)
|
||||
wantStatus string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
|
|
@ -436,8 +436,8 @@ func TestCheckNodeTokenConsistency(t *testing.T) {
|
|||
wantMessage: "Cannot check node tokens",
|
||||
},
|
||||
{
|
||||
name: "nil getNodes function",
|
||||
getNodes: nil,
|
||||
name: "nil getNodes function",
|
||||
getNodes: nil,
|
||||
wantStatus: "ok",
|
||||
},
|
||||
}
|
||||
|
|
@ -462,8 +462,8 @@ func TestCheckNodeTokenConsistency(t *testing.T) {
|
|||
|
||||
func TestCheckOverall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checks []CheckResult
|
||||
name string
|
||||
checks []CheckResult
|
||||
wantOverall string
|
||||
}{
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,19 +8,19 @@ import (
|
|||
|
||||
// Event type constants. These match the EventType values from the events package.
|
||||
const (
|
||||
TypeDetection = "detection"
|
||||
TypeZoneEntry = "zone_entry"
|
||||
TypeZoneExit = "zone_exit"
|
||||
TypePortalCrossing = "portal_crossing"
|
||||
TypeTriggerFired = "trigger_fired"
|
||||
TypeFallAlert = "fall_alert"
|
||||
TypeAnomaly = "anomaly"
|
||||
TypeSecurityAlert = "security_alert"
|
||||
TypeNodeOnline = "node_online"
|
||||
TypeNodeOffline = "node_offline"
|
||||
TypeOTAUpdate = "ota_update"
|
||||
TypeBaselineChanged = "baseline_changed"
|
||||
TypeSystem = "system"
|
||||
TypeDetection = "detection"
|
||||
TypeZoneEntry = "zone_entry"
|
||||
TypeZoneExit = "zone_exit"
|
||||
TypePortalCrossing = "portal_crossing"
|
||||
TypeTriggerFired = "trigger_fired"
|
||||
TypeFallAlert = "fall_alert"
|
||||
TypeAnomaly = "anomaly"
|
||||
TypeSecurityAlert = "security_alert"
|
||||
TypeNodeOnline = "node_online"
|
||||
TypeNodeOffline = "node_offline"
|
||||
TypeOTAUpdate = "ota_update"
|
||||
TypeBaselineChanged = "baseline_changed"
|
||||
TypeSystem = "system"
|
||||
TypeLearningMilestone = "learning_milestone"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -128,9 +128,9 @@ func TestSeverityConstants(t *testing.T) {
|
|||
// TestEventFields verifies event struct fields work correctly.
|
||||
func TestEventFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event Event
|
||||
check func(Event) error
|
||||
name string
|
||||
event Event
|
||||
check func(Event) error
|
||||
}{
|
||||
{
|
||||
name: "full event",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ type MotionDetectedPayload struct {
|
|||
}
|
||||
|
||||
func (m MotionDetectedPayload) EventType() BusEventType { return BusMotionDetected }
|
||||
func (m MotionDetectedPayload) GetTimestamp() time.Time { return m.Timestamp }
|
||||
func (m MotionDetectedPayload) GetTimestamp() time.Time { return m.Timestamp }
|
||||
|
||||
// MotionStoppedPayload is emitted when motion ceases in a zone.
|
||||
type MotionStoppedPayload struct {
|
||||
|
|
@ -77,7 +77,7 @@ type MotionStoppedPayload struct {
|
|||
}
|
||||
|
||||
func (m MotionStoppedPayload) EventType() BusEventType { return BusMotionStopped }
|
||||
func (m MotionStoppedPayload) GetTimestamp() time.Time { return m.Timestamp }
|
||||
func (m MotionStoppedPayload) GetTimestamp() time.Time { return m.Timestamp }
|
||||
|
||||
// ZoneTransitionPayload is emitted when a blob crosses a portal between zones.
|
||||
type ZoneTransitionPayload struct {
|
||||
|
|
@ -96,7 +96,7 @@ type ZoneTransitionPayload struct {
|
|||
}
|
||||
|
||||
func (z ZoneTransitionPayload) EventType() BusEventType { return BusZoneTransition }
|
||||
func (z ZoneTransitionPayload) GetTimestamp() time.Time { return z.Timestamp }
|
||||
func (z ZoneTransitionPayload) GetTimestamp() time.Time { return z.Timestamp }
|
||||
|
||||
// ZoneEntryPayload is emitted when a blob enters a zone (not via portal).
|
||||
type ZoneEntryPayload struct {
|
||||
|
|
@ -110,7 +110,7 @@ type ZoneEntryPayload struct {
|
|||
}
|
||||
|
||||
func (z ZoneEntryPayload) EventType() BusEventType { return BusZoneEntry }
|
||||
func (z ZoneEntryPayload) GetTimestamp() time.Time { return z.Timestamp }
|
||||
func (z ZoneEntryPayload) GetTimestamp() time.Time { return z.Timestamp }
|
||||
|
||||
// ZoneExitPayload is emitted when a blob exits a zone (not via portal).
|
||||
type ZoneExitPayload struct {
|
||||
|
|
@ -124,7 +124,7 @@ type ZoneExitPayload struct {
|
|||
}
|
||||
|
||||
func (z ZoneExitPayload) EventType() BusEventType { return BusZoneExit }
|
||||
func (z ZoneExitPayload) GetTimestamp() time.Time { return z.Timestamp }
|
||||
func (z ZoneExitPayload) GetTimestamp() time.Time { return z.Timestamp }
|
||||
|
||||
// FallDetectedPayload is emitted when a potential fall is detected.
|
||||
type FallDetectedPayload struct {
|
||||
|
|
@ -140,7 +140,7 @@ type FallDetectedPayload struct {
|
|||
}
|
||||
|
||||
func (f FallDetectedPayload) EventType() BusEventType { return BusFallDetected }
|
||||
func (f FallDetectedPayload) GetTimestamp() time.Time { return f.Timestamp }
|
||||
func (f FallDetectedPayload) GetTimestamp() time.Time { return f.Timestamp }
|
||||
|
||||
// FallConfirmedPayload is emitted when a fall is confirmed after the confirmation window.
|
||||
type FallConfirmedPayload struct {
|
||||
|
|
@ -156,7 +156,7 @@ type FallConfirmedPayload struct {
|
|||
}
|
||||
|
||||
func (f FallConfirmedPayload) EventType() BusEventType { return BusFallConfirmed }
|
||||
func (f FallConfirmedPayload) GetTimestamp() time.Time { return f.Timestamp }
|
||||
func (f FallConfirmedPayload) GetTimestamp() time.Time { return f.Timestamp }
|
||||
|
||||
// NodeConnectedPayload is emitted when a node connects for the first time or after a long absence.
|
||||
type NodeConnectedPayload struct {
|
||||
|
|
@ -168,7 +168,7 @@ type NodeConnectedPayload struct {
|
|||
}
|
||||
|
||||
func (n NodeConnectedPayload) EventType() BusEventType { return BusNodeConnected }
|
||||
func (n NodeConnectedPayload) GetTimestamp() time.Time { return n.Timestamp }
|
||||
func (n NodeConnectedPayload) GetTimestamp() time.Time { return n.Timestamp }
|
||||
|
||||
// NodeDisconnectedPayload is emitted when a node disconnects unexpectedly.
|
||||
type NodeDisconnectedPayload struct {
|
||||
|
|
@ -180,7 +180,7 @@ type NodeDisconnectedPayload struct {
|
|||
}
|
||||
|
||||
func (n NodeDisconnectedPayload) EventType() BusEventType { return BusNodeDisconnected }
|
||||
func (n NodeDisconnectedPayload) GetTimestamp() time.Time { return n.Timestamp }
|
||||
func (n NodeDisconnectedPayload) GetTimestamp() time.Time { return n.Timestamp }
|
||||
|
||||
// NodeReconnectedPayload is emitted when a node reconnects after a brief disconnection.
|
||||
type NodeReconnectedPayload struct {
|
||||
|
|
@ -191,7 +191,7 @@ type NodeReconnectedPayload struct {
|
|||
}
|
||||
|
||||
func (n NodeReconnectedPayload) EventType() BusEventType { return BusNodeReconnected }
|
||||
func (n NodeReconnectedPayload) GetTimestamp() time.Time { return n.Timestamp }
|
||||
func (n NodeReconnectedPayload) GetTimestamp() time.Time { return n.Timestamp }
|
||||
|
||||
// NodeStalePayload is emitted when a node hasn't sent health updates within the expected interval.
|
||||
type NodeStalePayload struct {
|
||||
|
|
@ -202,7 +202,7 @@ type NodeStalePayload struct {
|
|||
}
|
||||
|
||||
func (n NodeStalePayload) EventType() BusEventType { return BusNodeStale }
|
||||
func (n NodeStalePayload) GetTimestamp() time.Time { return n.Timestamp }
|
||||
func (n NodeStalePayload) GetTimestamp() time.Time { return n.Timestamp }
|
||||
|
||||
// SystemStartedPayload is emitted when the mothership completes startup.
|
||||
type SystemStartedPayload struct {
|
||||
|
|
@ -213,7 +213,7 @@ type SystemStartedPayload struct {
|
|||
}
|
||||
|
||||
func (s SystemStartedPayload) EventType() BusEventType { return BusSystemStarted }
|
||||
func (s SystemStartedPayload) GetTimestamp() time.Time { return s.Timestamp }
|
||||
func (s SystemStartedPayload) GetTimestamp() time.Time { return s.Timestamp }
|
||||
|
||||
// SystemShutdownPayload is emitted when the mothership begins graceful shutdown.
|
||||
type SystemShutdownPayload struct {
|
||||
|
|
@ -223,7 +223,7 @@ type SystemShutdownPayload struct {
|
|||
}
|
||||
|
||||
func (s SystemShutdownPayload) EventType() BusEventType { return BusSystemShutdown }
|
||||
func (s SystemShutdownPayload) GetTimestamp() time.Time { return s.Timestamp }
|
||||
func (s SystemShutdownPayload) GetTimestamp() time.Time { return s.Timestamp }
|
||||
|
||||
// ConfigChangedPayload is emitted when a configuration value changes.
|
||||
type ConfigChangedPayload struct {
|
||||
|
|
@ -235,7 +235,7 @@ type ConfigChangedPayload struct {
|
|||
}
|
||||
|
||||
func (c ConfigChangedPayload) EventType() BusEventType { return BusConfigChanged }
|
||||
func (c ConfigChangedPayload) GetTimestamp() time.Time { return c.Timestamp }
|
||||
func (c ConfigChangedPayload) GetTimestamp() time.Time { return c.Timestamp }
|
||||
|
||||
// TriggerFiredPayload is emitted when an automation trigger condition is met.
|
||||
type TriggerFiredPayload struct {
|
||||
|
|
@ -253,7 +253,7 @@ type TriggerFiredPayload struct {
|
|||
}
|
||||
|
||||
func (t TriggerFiredPayload) EventType() BusEventType { return BusTriggerFired }
|
||||
func (t TriggerFiredPayload) GetTimestamp() time.Time { return t.Timestamp }
|
||||
func (t TriggerFiredPayload) GetTimestamp() time.Time { return t.Timestamp }
|
||||
|
||||
// TriggerClearedPayload is emitted when a trigger condition is no longer met.
|
||||
type TriggerClearedPayload struct {
|
||||
|
|
@ -264,19 +264,19 @@ type TriggerClearedPayload struct {
|
|||
}
|
||||
|
||||
func (t TriggerClearedPayload) EventType() BusEventType { return BusTriggerCleared }
|
||||
func (t TriggerClearedPayload) GetTimestamp() time.Time { return t.Timestamp }
|
||||
func (t TriggerClearedPayload) GetTimestamp() time.Time { return t.Timestamp }
|
||||
|
||||
// BaselineUpdatedPayload is emitted when a link baseline is updated.
|
||||
type BaselineUpdatedPayload struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
LinkID string `json:"link_id"`
|
||||
Reason string `json:"reason"` // "manual", "drift", "schedule"
|
||||
Confidence float64 `json:"confidence"`
|
||||
SampleCount int `json:"sample_count"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
LinkID string `json:"link_id"`
|
||||
Reason string `json:"reason"` // "manual", "drift", "schedule"
|
||||
Confidence float64 `json:"confidence"`
|
||||
SampleCount int `json:"sample_count"`
|
||||
}
|
||||
|
||||
func (b BaselineUpdatedPayload) EventType() BusEventType { return BusBaselineUpdated }
|
||||
func (b BaselineUpdatedPayload) GetTimestamp() time.Time { return b.Timestamp }
|
||||
func (b BaselineUpdatedPayload) GetTimestamp() time.Time { return b.Timestamp }
|
||||
|
||||
// ModelUpdatedPayload is emitted when a prediction model is updated.
|
||||
type ModelUpdatedPayload struct {
|
||||
|
|
@ -290,14 +290,14 @@ type ModelUpdatedPayload struct {
|
|||
}
|
||||
|
||||
func (m ModelUpdatedPayload) EventType() BusEventType { return BusModelUpdated }
|
||||
func (m ModelUpdatedPayload) GetTimestamp() time.Time { return m.Timestamp }
|
||||
func (m ModelUpdatedPayload) GetTimestamp() time.Time { return m.Timestamp }
|
||||
|
||||
// EventBus provides a typed publish/subscribe mechanism for internal events.
|
||||
// It supports multiple subscribers per event type with fan-out delivery.
|
||||
type EventBus struct {
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
subscribers map[BusEventType][]chan EventPayload
|
||||
capacity int // Buffer capacity for subscriber channels
|
||||
capacity int // Buffer capacity for subscriber channels
|
||||
}
|
||||
|
||||
// NewEventBus creates a new EventBus with the specified channel buffer capacity.
|
||||
|
|
@ -306,7 +306,7 @@ type EventBus struct {
|
|||
func NewEventBus(capacity int) *EventBus {
|
||||
return &EventBus{
|
||||
subscribers: make(map[BusEventType][]chan EventPayload),
|
||||
capacity: capacity,
|
||||
capacity: capacity,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -484,9 +484,9 @@ func TestEventBusConcurrentPublish(t *testing.T) {
|
|||
// TestAllPayloadTypes verifies that all defined event types have a corresponding payload struct.
|
||||
func TestAllPayloadTypes(t *testing.T) {
|
||||
payloads := []struct {
|
||||
name string
|
||||
name string
|
||||
eventType BusEventType
|
||||
payload EventPayload
|
||||
payload EventPayload
|
||||
}{
|
||||
{"MotionDetected", BusMotionDetected, MotionDetectedPayload{Timestamp: time.Now()}},
|
||||
{"MotionStopped", BusMotionStopped, MotionStoppedPayload{Timestamp: time.Now()}},
|
||||
|
|
|
|||
|
|
@ -18,22 +18,22 @@ import (
|
|||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeDetection EventType = "detection"
|
||||
EventTypeZoneEntry EventType = "zone_entry"
|
||||
EventTypeZoneExit EventType = "zone_exit"
|
||||
EventTypePortalCrossing EventType = "portal_crossing"
|
||||
EventTypeTriggerFired EventType = "trigger_fired"
|
||||
EventTypeFallAlert EventType = "fall_alert"
|
||||
EventTypeAnomaly EventType = "anomaly"
|
||||
EventTypeSecurityAlert EventType = "security_alert"
|
||||
EventTypeNodeOnline EventType = "node_online"
|
||||
EventTypeNodeOffline EventType = "node_offline"
|
||||
EventTypeOTAUpdate EventType = "ota_update"
|
||||
EventTypeBaselineChanged EventType = "baseline_changed"
|
||||
EventTypeSystem EventType = "system"
|
||||
EventTypeDetection EventType = "detection"
|
||||
EventTypeZoneEntry EventType = "zone_entry"
|
||||
EventTypeZoneExit EventType = "zone_exit"
|
||||
EventTypePortalCrossing EventType = "portal_crossing"
|
||||
EventTypeTriggerFired EventType = "trigger_fired"
|
||||
EventTypeFallAlert EventType = "fall_alert"
|
||||
EventTypeAnomaly EventType = "anomaly"
|
||||
EventTypeSecurityAlert EventType = "security_alert"
|
||||
EventTypeNodeOnline EventType = "node_online"
|
||||
EventTypeNodeOffline EventType = "node_offline"
|
||||
EventTypeOTAUpdate EventType = "ota_update"
|
||||
EventTypeBaselineChanged EventType = "baseline_changed"
|
||||
EventTypeSystem EventType = "system"
|
||||
EventTypeLearningMilestone EventType = "learning_milestone"
|
||||
EventTypeSleepStart EventType = "sleep_session_start"
|
||||
EventTypeSleepEnd EventType = "sleep_session_end"
|
||||
EventTypeSleepStart EventType = "sleep_session_start"
|
||||
EventTypeSleepEnd EventType = "sleep_session_end"
|
||||
)
|
||||
|
||||
// EventSeverity represents the severity level of an event.
|
||||
|
|
|
|||
|
|
@ -109,10 +109,10 @@ func TestInsertEvent(t *testing.T) {
|
|||
name: "event with timestamp",
|
||||
event: Event{
|
||||
TimestampMs: 1710000000000,
|
||||
Type: EventTypeZoneEntry,
|
||||
Zone: "Living Room",
|
||||
Person: "Bob",
|
||||
Severity: SeverityInfo,
|
||||
Type: EventTypeZoneEntry,
|
||||
Zone: "Living Room",
|
||||
Person: "Bob",
|
||||
Severity: SeverityInfo,
|
||||
},
|
||||
wantID: true,
|
||||
},
|
||||
|
|
@ -427,7 +427,7 @@ func TestRunArchiveJob(t *testing.T) {
|
|||
defer db.Close() //nolint:errcheck
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
oldCutoff := now - ArchiveDaysMs - 1000 // Older than archive threshold
|
||||
oldCutoff := now - ArchiveDaysMs - 1000 // Older than archive threshold
|
||||
youngCutoff := now - ArchiveDaysMs + 1000 // Newer than archive threshold
|
||||
|
||||
// Insert old events (should be archived)
|
||||
|
|
@ -753,4 +753,3 @@ func TestStartArchiveScheduler_StopsOnDone(t *testing.T) {
|
|||
|
||||
// Test passes if we got here without deadlock
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -232,10 +232,10 @@ func (s *StorageSubscriber) convertPayload(payload EventPayload) Event {
|
|||
case NodeConnectedPayload:
|
||||
base.Type = EventTypeNodeOnline
|
||||
base.DetailJSON = marshalDetail(map[string]interface{}{
|
||||
"node_mac": p.NodeMAC,
|
||||
"node_name": p.NodeName,
|
||||
"node_mac": p.NodeMAC,
|
||||
"node_name": p.NodeName,
|
||||
"firmware_version": p.FirmwareVer,
|
||||
"ip_address": p.IPAddress,
|
||||
"ip_address": p.IPAddress,
|
||||
})
|
||||
base.Severity = SeverityInfo
|
||||
|
||||
|
|
@ -261,8 +261,8 @@ func (s *StorageSubscriber) convertPayload(payload EventPayload) Event {
|
|||
case NodeStalePayload:
|
||||
base.Type = EventTypeNodeOffline
|
||||
base.DetailJSON = marshalDetail(map[string]interface{}{
|
||||
"node_mac": p.NodeMAC,
|
||||
"node_name": p.NodeName,
|
||||
"node_mac": p.NodeMAC,
|
||||
"node_name": p.NodeName,
|
||||
"last_health_ms": p.LastHealthMs,
|
||||
})
|
||||
base.Severity = SeverityWarning
|
||||
|
|
@ -270,9 +270,9 @@ func (s *StorageSubscriber) convertPayload(payload EventPayload) Event {
|
|||
case SystemStartedPayload:
|
||||
base.Type = EventTypeSystem
|
||||
base.DetailJSON = marshalDetail(map[string]interface{}{
|
||||
"message": "System started",
|
||||
"version": p.Version,
|
||||
"start_time": p.StartTime.Format(time.RFC3339),
|
||||
"message": "System started",
|
||||
"version": p.Version,
|
||||
"start_time": p.StartTime.Format(time.RFC3339),
|
||||
"duration_ms": p.DurationMs,
|
||||
})
|
||||
base.Severity = SeverityInfo
|
||||
|
|
@ -334,11 +334,11 @@ func (s *StorageSubscriber) convertPayload(payload EventPayload) Event {
|
|||
base.Type = EventTypeLearningMilestone
|
||||
base.Person = p.PersonID
|
||||
base.DetailJSON = marshalDetail(map[string]interface{}{
|
||||
"model_type": p.ModelType,
|
||||
"zone_id": p.ZoneID,
|
||||
"samples_added": p.SamplesAdded,
|
||||
"total_samples": p.TotalSamples,
|
||||
"accuracy_percent": p.AccuracyPercent,
|
||||
"model_type": p.ModelType,
|
||||
"zone_id": p.ZoneID,
|
||||
"samples_added": p.SamplesAdded,
|
||||
"total_samples": p.TotalSamples,
|
||||
"accuracy_percent": p.AccuracyPercent,
|
||||
})
|
||||
base.Severity = SeverityInfo
|
||||
|
||||
|
|
@ -425,8 +425,8 @@ func (s *StorageSubscriber) Stats() map[string]interface{} {
|
|||
defer s.mu.Unlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
"queue_size": len(s.queue),
|
||||
"queue_size": len(s.queue),
|
||||
"queue_capacity": bufferSize,
|
||||
"dropped_total": s.dropped,
|
||||
"dropped_total": s.dropped,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,12 +74,12 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
testTime := time.Now()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
payload EventPayload
|
||||
expectedType EventType
|
||||
expectedZone string
|
||||
expectedPerson string
|
||||
expectedBlobID int
|
||||
name string
|
||||
payload EventPayload
|
||||
expectedType EventType
|
||||
expectedZone string
|
||||
expectedPerson string
|
||||
expectedBlobID int
|
||||
expectedSeverity EventSeverity
|
||||
}{
|
||||
{
|
||||
|
|
@ -91,10 +91,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
BlobID: 1,
|
||||
Confidence: 0.85,
|
||||
},
|
||||
expectedType: EventTypeDetection,
|
||||
expectedZone: "Kitchen",
|
||||
expectedPerson: "Alice",
|
||||
expectedBlobID: 1,
|
||||
expectedType: EventTypeDetection,
|
||||
expectedZone: "Kitchen",
|
||||
expectedPerson: "Alice",
|
||||
expectedBlobID: 1,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -106,10 +106,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
BlobID: 2,
|
||||
DurationMs: 5000,
|
||||
},
|
||||
expectedType: EventTypeDetection,
|
||||
expectedZone: "Living Room",
|
||||
expectedPerson: "Bob",
|
||||
expectedBlobID: 2,
|
||||
expectedType: EventTypeDetection,
|
||||
expectedZone: "Living Room",
|
||||
expectedPerson: "Bob",
|
||||
expectedBlobID: 2,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -123,10 +123,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
BlobID: 1,
|
||||
Direction: "a_to_b",
|
||||
},
|
||||
expectedType: EventTypePortalCrossing,
|
||||
expectedZone: "Kitchen",
|
||||
expectedPerson: "Alice",
|
||||
expectedBlobID: 1,
|
||||
expectedType: EventTypePortalCrossing,
|
||||
expectedZone: "Kitchen",
|
||||
expectedPerson: "Alice",
|
||||
expectedBlobID: 1,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -137,10 +137,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
PersonName: "Charlie",
|
||||
BlobID: 3,
|
||||
},
|
||||
expectedType: EventTypeZoneEntry,
|
||||
expectedZone: "Bedroom",
|
||||
expectedPerson: "Charlie",
|
||||
expectedBlobID: 3,
|
||||
expectedType: EventTypeZoneEntry,
|
||||
expectedZone: "Bedroom",
|
||||
expectedPerson: "Charlie",
|
||||
expectedBlobID: 3,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -151,10 +151,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
PersonName: "Diana",
|
||||
BlobID: 4,
|
||||
},
|
||||
expectedType: EventTypeZoneExit,
|
||||
expectedZone: "Bathroom",
|
||||
expectedPerson: "Diana",
|
||||
expectedBlobID: 4,
|
||||
expectedType: EventTypeZoneExit,
|
||||
expectedZone: "Bathroom",
|
||||
expectedPerson: "Diana",
|
||||
expectedBlobID: 4,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -167,10 +167,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
ZVelocity: -2.5,
|
||||
Confidence: 0.95,
|
||||
},
|
||||
expectedType: EventTypeFallAlert,
|
||||
expectedZone: "Hallway",
|
||||
expectedPerson: "Eve",
|
||||
expectedBlobID: 5,
|
||||
expectedType: EventTypeFallAlert,
|
||||
expectedZone: "Hallway",
|
||||
expectedPerson: "Eve",
|
||||
expectedBlobID: 5,
|
||||
expectedSeverity: SeverityAlert,
|
||||
},
|
||||
{
|
||||
|
|
@ -183,10 +183,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
ConfirmationMs: 10000,
|
||||
AlertSent: true,
|
||||
},
|
||||
expectedType: EventTypeFallAlert,
|
||||
expectedZone: "Bathroom",
|
||||
expectedPerson: "Frank",
|
||||
expectedBlobID: 6,
|
||||
expectedType: EventTypeFallAlert,
|
||||
expectedZone: "Bathroom",
|
||||
expectedPerson: "Frank",
|
||||
expectedBlobID: 6,
|
||||
expectedSeverity: SeverityCritical,
|
||||
},
|
||||
{
|
||||
|
|
@ -198,7 +198,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
FirmwareVer: "1.0.0",
|
||||
IPAddress: "192.168.1.100",
|
||||
},
|
||||
expectedType: EventTypeNodeOnline,
|
||||
expectedType: EventTypeNodeOnline,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -210,7 +210,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
WasOnlineFor: 3600000,
|
||||
Reason: "timeout",
|
||||
},
|
||||
expectedType: EventTypeNodeOffline,
|
||||
expectedType: EventTypeNodeOffline,
|
||||
expectedSeverity: SeverityWarning,
|
||||
},
|
||||
{
|
||||
|
|
@ -221,7 +221,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
NodeName: "Kitchen North",
|
||||
OfflineForMs: 5000,
|
||||
},
|
||||
expectedType: EventTypeNodeOnline,
|
||||
expectedType: EventTypeNodeOnline,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -232,7 +232,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
NodeName: "Bedroom",
|
||||
LastHealthMs: 20000,
|
||||
},
|
||||
expectedType: EventTypeNodeOffline,
|
||||
expectedType: EventTypeNodeOffline,
|
||||
expectedSeverity: SeverityWarning,
|
||||
},
|
||||
{
|
||||
|
|
@ -243,7 +243,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
StartTime: testTime.Add(-1 * time.Second),
|
||||
DurationMs: 1000,
|
||||
},
|
||||
expectedType: EventTypeSystem,
|
||||
expectedType: EventTypeSystem,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -253,7 +253,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
Reason: "manual",
|
||||
DurationMs: 5000,
|
||||
},
|
||||
expectedType: EventTypeSystem,
|
||||
expectedType: EventTypeSystem,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -265,7 +265,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
NewValue: "20",
|
||||
ChangedBy: "api",
|
||||
},
|
||||
expectedType: EventTypeSystem,
|
||||
expectedType: EventTypeSystem,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -280,10 +280,10 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
BlobID: 1,
|
||||
DurationS: 35.0,
|
||||
},
|
||||
expectedType: EventTypeTriggerFired,
|
||||
expectedZone: "Living Room",
|
||||
expectedPerson: "Alice",
|
||||
expectedBlobID: 1,
|
||||
expectedType: EventTypeTriggerFired,
|
||||
expectedZone: "Living Room",
|
||||
expectedPerson: "Alice",
|
||||
expectedBlobID: 1,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -294,7 +294,7 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
TriggerName: "Couch Dwell",
|
||||
DurationS: 60.0,
|
||||
},
|
||||
expectedType: EventTypeTriggerFired,
|
||||
expectedType: EventTypeTriggerFired,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
|
|
@ -306,22 +306,22 @@ func TestStorageSubscriberAllEventTypes(t *testing.T) {
|
|||
Confidence: 0.85,
|
||||
SampleCount: 500,
|
||||
},
|
||||
expectedType: EventTypeBaselineChanged,
|
||||
expectedType: EventTypeBaselineChanged,
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
{
|
||||
name: "ModelUpdated",
|
||||
payload: ModelUpdatedPayload{
|
||||
Timestamp: testTime,
|
||||
ModelType: "prediction",
|
||||
PersonID: "Alice",
|
||||
ZoneID: "1",
|
||||
SamplesAdded: 10,
|
||||
TotalSamples: 100,
|
||||
Timestamp: testTime,
|
||||
ModelType: "prediction",
|
||||
PersonID: "Alice",
|
||||
ZoneID: "1",
|
||||
SamplesAdded: 10,
|
||||
TotalSamples: 100,
|
||||
AccuracyPercent: 78.5,
|
||||
},
|
||||
expectedType: EventTypeLearningMilestone,
|
||||
expectedPerson: "Alice",
|
||||
expectedType: EventTypeLearningMilestone,
|
||||
expectedPerson: "Alice",
|
||||
expectedSeverity: SeverityInfo,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,45 +22,45 @@ type Position struct {
|
|||
|
||||
// AnomalyEvent represents a detected anomaly with full metadata.
|
||||
type AnomalyEvent struct {
|
||||
ID string `json:"id"`
|
||||
Type AnomalyType `json:"type"`
|
||||
Score float64 `json:"score"`
|
||||
Description string `json:"description"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
ZoneID string `json:"zone_id,omitempty"`
|
||||
ZoneName string `json:"zone_name,omitempty"`
|
||||
BlobID int `json:"blob_id,omitempty"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
PersonName string `json:"person_name,omitempty"`
|
||||
DeviceMAC string `json:"device_mac,omitempty"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
Position Position `json:"position,omitempty"`
|
||||
HourOfWeek int `json:"hour_of_week,omitempty"`
|
||||
ExpectedOccupancy float64 `json:"expected_occupancy,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Type AnomalyType `json:"type"`
|
||||
Score float64 `json:"score"`
|
||||
Description string `json:"description"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
ZoneID string `json:"zone_id,omitempty"`
|
||||
ZoneName string `json:"zone_name,omitempty"`
|
||||
BlobID int `json:"blob_id,omitempty"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
PersonName string `json:"person_name,omitempty"`
|
||||
DeviceMAC string `json:"device_mac,omitempty"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
Position Position `json:"position,omitempty"`
|
||||
HourOfWeek int `json:"hour_of_week,omitempty"`
|
||||
ExpectedOccupancy float64 `json:"expected_occupancy,omitempty"`
|
||||
DwellDuration time.Duration `json:"dwell_duration,omitempty"`
|
||||
ExpectedDwell time.Duration `json:"expected_dwell,omitempty"`
|
||||
RSSIdBm int `json:"rssi_dbm,omitempty"`
|
||||
SeenBefore bool `json:"seen_before,omitempty"`
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
AcknowledgedAt time.Time `json:"acknowledged_at,omitempty"`
|
||||
Feedback string `json:"feedback,omitempty"`
|
||||
AcknowledgedBy string `json:"acknowledged_by,omitempty"`
|
||||
AlertSent bool `json:"alert_sent"`
|
||||
AlertSentAt time.Time `json:"alert_sent_at,omitempty"`
|
||||
WebhookSent bool `json:"webhook_sent"`
|
||||
WebhookSentAt time.Time `json:"webhook_sent_at,omitempty"`
|
||||
EscalationSent bool `json:"escalation_sent"`
|
||||
EscalationSentAt time.Time `json:"escalation_sent_at,omitempty"`
|
||||
RSSIdBm int `json:"rssi_dbm,omitempty"`
|
||||
SeenBefore bool `json:"seen_before,omitempty"`
|
||||
Acknowledged bool `json:"acknowledged"`
|
||||
AcknowledgedAt time.Time `json:"acknowledged_at,omitempty"`
|
||||
Feedback string `json:"feedback,omitempty"`
|
||||
AcknowledgedBy string `json:"acknowledged_by,omitempty"`
|
||||
AlertSent bool `json:"alert_sent"`
|
||||
AlertSentAt time.Time `json:"alert_sent_at,omitempty"`
|
||||
WebhookSent bool `json:"webhook_sent"`
|
||||
WebhookSentAt time.Time `json:"webhook_sent_at,omitempty"`
|
||||
EscalationSent bool `json:"escalation_sent"`
|
||||
EscalationSentAt time.Time `json:"escalation_sent_at,omitempty"`
|
||||
}
|
||||
|
||||
// WeeklyAnomalySummary aggregates anomaly counts for the past week.
|
||||
type WeeklyAnomalySummary struct {
|
||||
TotalAnomalies int `json:"total_anomalies"`
|
||||
ByType map[AnomalyType]int `json:"by_type"`
|
||||
ExpectedEvents int `json:"expected_events"`
|
||||
GenuineIntrusions int `json:"genuine_intrusions"`
|
||||
FalseAlarms int `json:"false_alarms"`
|
||||
Unacknowledged int `json:"unacknowledged"`
|
||||
TotalAnomalies int `json:"total_anomalies"`
|
||||
ByType map[AnomalyType]int `json:"by_type"`
|
||||
ExpectedEvents int `json:"expected_events"`
|
||||
GenuineIntrusions int `json:"genuine_intrusions"`
|
||||
FalseAlarms int `json:"false_alarms"`
|
||||
Unacknowledged int `json:"unacknowledged"`
|
||||
}
|
||||
|
||||
// SystemMode represents the current home occupancy mode.
|
||||
|
|
@ -84,18 +84,18 @@ type SystemModeChangeEvent struct {
|
|||
|
||||
// SleepSessionStartEvent is emitted when a sleep session is detected.
|
||||
type SleepSessionStartEvent struct {
|
||||
ZoneID string `json:"zone_id"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
BlobID int `json:"blob_id,omitempty"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
BlobID int `json:"blob_id,omitempty"`
|
||||
}
|
||||
|
||||
// SleepSessionEndEvent is emitted when a sleep session ends.
|
||||
type SleepSessionEndEvent struct {
|
||||
ZoneID string `json:"zone_id"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
StartTimestamp time.Time `json:"start_timestamp"`
|
||||
EndTimestamp time.Time `json:"end_timestamp"`
|
||||
DurationMin float64 `json:"duration_min"`
|
||||
BlobID int `json:"blob_id,omitempty"`
|
||||
ZoneID string `json:"zone_id"`
|
||||
PersonID string `json:"person_id,omitempty"`
|
||||
StartTimestamp time.Time `json:"start_timestamp"`
|
||||
EndTimestamp time.Time `json:"end_timestamp"`
|
||||
DurationMin float64 `json:"duration_min"`
|
||||
BlobID int `json:"blob_id,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,79 +16,79 @@ import (
|
|||
|
||||
// Handler provides the explainability HTTP API.
|
||||
type Handler struct {
|
||||
mu sync.RWMutex
|
||||
blobHistory map[int]*BlobExplanation // blobID -> explanation data
|
||||
blobHistoryByTime map[int64]*BlobExplanation // timestamp -> explanation for feedback lookups
|
||||
linkStates map[string]*LinkState // linkID -> link state
|
||||
fusionResult *FusionResultSnapshot // latest fusion result
|
||||
mu sync.RWMutex
|
||||
blobHistory map[int]*BlobExplanation // blobID -> explanation data
|
||||
blobHistoryByTime map[int64]*BlobExplanation // timestamp -> explanation for feedback lookups
|
||||
linkStates map[string]*LinkState // linkID -> link state
|
||||
fusionResult *FusionResultSnapshot // latest fusion result
|
||||
}
|
||||
|
||||
// BlobExplanation contains all data needed to explain a blob detection.
|
||||
type BlobExplanation struct {
|
||||
BlobID int `json:"blob_id"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Timestamp int64 `json:"timestamp_ms"`
|
||||
BlobID int `json:"blob_id"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Timestamp int64 `json:"timestamp_ms"`
|
||||
ContributingLinks []LinkContribution `json:"contributing_links"`
|
||||
AllLinks []LinkContribution `json:"all_links"`
|
||||
BLEMatch *BLEMatch `json:"ble_match,omitempty"`
|
||||
FresnelZones []FresnelZone `json:"fresnel_zones"`
|
||||
AllLinks []LinkContribution `json:"all_links"`
|
||||
BLEMatch *BLEMatch `json:"ble_match,omitempty"`
|
||||
FresnelZones []FresnelZone `json:"fresnel_zones"`
|
||||
}
|
||||
|
||||
// LinkContribution describes how much a link contributed to a blob detection.
|
||||
type LinkContribution struct {
|
||||
LinkID string `json:"link_id"` // e.g., "AA:BB:CC:DD:EE:FF"
|
||||
NodeMAC string `json:"node_mac"`
|
||||
PeerMAC string `json:"peer_mac"`
|
||||
DeltaRMS float64 `json:"delta_rms"`
|
||||
ZoneNumber int `json:"zone_number"` // Fresnel zone number at blob position
|
||||
Weight float64 `json:"weight"` // Learned weight multiplier
|
||||
Contributing bool `json:"contributing"` // true if deltaRMS exceeded threshold
|
||||
Contribution float64 `json:"contribution"` // amount added to fusion grid at blob position
|
||||
LinkID string `json:"link_id"` // e.g., "AA:BB:CC:DD:EE:FF"
|
||||
NodeMAC string `json:"node_mac"`
|
||||
PeerMAC string `json:"peer_mac"`
|
||||
DeltaRMS float64 `json:"delta_rms"`
|
||||
ZoneNumber int `json:"zone_number"` // Fresnel zone number at blob position
|
||||
Weight float64 `json:"weight"` // Learned weight multiplier
|
||||
Contributing bool `json:"contributing"` // true if deltaRMS exceeded threshold
|
||||
Contribution float64 `json:"contribution"` // amount added to fusion grid at blob position
|
||||
}
|
||||
|
||||
// BLEMatch describes a BLE device match for the blob.
|
||||
type BLEMatch struct {
|
||||
PersonID string `json:"person_id"`
|
||||
PersonLabel string `json:"person_label"`
|
||||
PersonColor string `json:"person_color"`
|
||||
DeviceAddr string `json:"device_addr"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
MatchMethod string `json:"match_method"` // "ble_triangulation" or "ble_only"
|
||||
ReportedByNodes []string `json:"reported_by_nodes"`
|
||||
TriangulationPos *[3]float64 `json:"triangulation_pos,omitempty"` // [x, y, z]
|
||||
PersonID string `json:"person_id"`
|
||||
PersonLabel string `json:"person_label"`
|
||||
PersonColor string `json:"person_color"`
|
||||
DeviceAddr string `json:"device_addr"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
MatchMethod string `json:"match_method"` // "ble_triangulation" or "ble_only"
|
||||
ReportedByNodes []string `json:"reported_by_nodes"`
|
||||
TriangulationPos *[3]float64 `json:"triangulation_pos,omitempty"` // [x, y, z]
|
||||
}
|
||||
|
||||
// FresnelZone describes a Fresnel zone ellipsoid for a link.
|
||||
type FresnelZone struct {
|
||||
LinkID string `json:"link_id"`
|
||||
CenterPos [3]float64 `json:"center_pos"` // [x, y, z] zone center
|
||||
SemiAxes [3]float64 `json:"semi_axes"` // [a, b, c] for ellipsoid
|
||||
ZoneNumber int `json:"zone_number"` // zone number for this blob position
|
||||
TXPos [3]float64 `json:"tx_pos"` // transmitter position for proper ellipsoid orientation
|
||||
RXPos [3]float64 `json:"rx_pos"` // receiver position for proper ellipsoid orientation
|
||||
Lambda float64 `json:"lambda"` // WiFi wavelength in metres
|
||||
LinkID string `json:"link_id"`
|
||||
CenterPos [3]float64 `json:"center_pos"` // [x, y, z] zone center
|
||||
SemiAxes [3]float64 `json:"semi_axes"` // [a, b, c] for ellipsoid
|
||||
ZoneNumber int `json:"zone_number"` // zone number for this blob position
|
||||
TXPos [3]float64 `json:"tx_pos"` // transmitter position for proper ellipsoid orientation
|
||||
RXPos [3]float64 `json:"rx_pos"` // receiver position for proper ellipsoid orientation
|
||||
Lambda float64 `json:"lambda"` // WiFi wavelength in metres
|
||||
}
|
||||
|
||||
// LinkState captures the current state of a link.
|
||||
type LinkState struct {
|
||||
NodeMAC string
|
||||
PeerMAC string
|
||||
NodePos [3]float64 // [x, y, z]
|
||||
PeerPos [3]float64 // [x, y, z]
|
||||
DeltaRMS float64
|
||||
Motion bool
|
||||
Weight float64 // Learned weight
|
||||
NodeMAC string
|
||||
PeerMAC string
|
||||
NodePos [3]float64 // [x, y, z]
|
||||
PeerPos [3]float64 // [x, y, z]
|
||||
DeltaRMS float64
|
||||
Motion bool
|
||||
Weight float64 // Learned weight
|
||||
HealthScore float64
|
||||
}
|
||||
|
||||
// FusionResultSnapshot captures the latest fusion result for explainability.
|
||||
type FusionResultSnapshot struct {
|
||||
Timestamp int64
|
||||
Blobs []BlobSnapshot
|
||||
GridData *GridSnapshot
|
||||
Timestamp int64
|
||||
Blobs []BlobSnapshot
|
||||
GridData *GridSnapshot
|
||||
}
|
||||
|
||||
// BlobSnapshot is a lightweight blob representation.
|
||||
|
|
@ -103,17 +103,17 @@ type BlobSnapshot struct {
|
|||
type GridSnapshot struct {
|
||||
Width, Depth, CellSize float64
|
||||
OriginX, OriginZ float64
|
||||
Data []float64 // Normalised [0-1] row-major grid data
|
||||
Data []float64 // Normalised [0-1] row-major grid data
|
||||
Rows, Cols int
|
||||
}
|
||||
|
||||
// NewHandler creates a new explainability handler.
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{
|
||||
blobHistory: make(map[int]*BlobExplanation),
|
||||
blobHistory: make(map[int]*BlobExplanation),
|
||||
blobHistoryByTime: make(map[int64]*BlobExplanation),
|
||||
linkStates: make(map[string]*LinkState),
|
||||
fusionResult: &FusionResultSnapshot{},
|
||||
linkStates: make(map[string]*LinkState),
|
||||
fusionResult: &FusionResultSnapshot{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,8 +126,8 @@ func (h *Handler) UpdateBlobs(blobs []BlobSnapshot, links []LinkState, grid *Gri
|
|||
// Update fusion result snapshot
|
||||
h.fusionResult = &FusionResultSnapshot{
|
||||
Timestamp: time.Now().Unix(),
|
||||
Blobs: blobs,
|
||||
GridData: grid,
|
||||
Blobs: blobs,
|
||||
GridData: grid,
|
||||
}
|
||||
|
||||
// Update link states
|
||||
|
|
@ -192,10 +192,10 @@ func (h *Handler) explainBlob(w http.ResponseWriter, r *http.Request) {
|
|||
if !ok {
|
||||
// Return empty explanation for unknown blob
|
||||
explanation = &BlobExplanation{
|
||||
BlobID: blobID,
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Z: 0,
|
||||
BlobID: blobID,
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Z: 0,
|
||||
Confidence: 0,
|
||||
}
|
||||
}
|
||||
|
|
@ -310,10 +310,10 @@ func (h *Handler) GetExplanationForBlob(blobID int, timestamp int64) *BlobExplan
|
|||
// This is called by the dashboard to refresh the explainability data.
|
||||
func (h *Handler) refreshData(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Blobs []BlobSnapshot `json:"blobs"`
|
||||
Links []LinkState `json:"links"`
|
||||
GridData *GridSnapshot `json:"grid_data,omitempty"`
|
||||
Identity map[int]*BLEMatch `json:"identity,omitempty"`
|
||||
Blobs []BlobSnapshot `json:"blobs"`
|
||||
Links []LinkState `json:"links"`
|
||||
GridData *GridSnapshot `json:"grid_data,omitempty"`
|
||||
Identity map[int]*BLEMatch `json:"identity,omitempty"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
|
|
@ -327,8 +327,8 @@ func (h *Handler) refreshData(w http.ResponseWriter, r *http.Request) {
|
|||
// Update fusion result snapshot
|
||||
h.fusionResult = &FusionResultSnapshot{
|
||||
Timestamp: int64(req.GridData.Rows), // placeholder
|
||||
Blobs: req.Blobs,
|
||||
GridData: req.GridData,
|
||||
Blobs: req.Blobs,
|
||||
GridData: req.GridData,
|
||||
}
|
||||
|
||||
// Update link states
|
||||
|
|
@ -429,13 +429,13 @@ func (h *Handler) computeExplanation(blob BlobSnapshot, links []LinkState, _ *Gr
|
|||
a := (pathDirect + lambda) / 2
|
||||
b := math.Sqrt(math.Max(0, a*a-(pathDirect/2)*(pathDirect/2)))
|
||||
explanation.FresnelZones = append(explanation.FresnelZones, FresnelZone{
|
||||
LinkID: linkID,
|
||||
CenterPos: [3]float64{centerX, centerY, centerZ},
|
||||
SemiAxes: [3]float64{b, b, a},
|
||||
LinkID: linkID,
|
||||
CenterPos: [3]float64{centerX, centerY, centerZ},
|
||||
SemiAxes: [3]float64{b, b, a},
|
||||
ZoneNumber: zoneNumber,
|
||||
TXPos: nodePos,
|
||||
RXPos: peerPos,
|
||||
Lambda: lambda,
|
||||
TXPos: nodePos,
|
||||
RXPos: peerPos,
|
||||
Lambda: lambda,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -475,17 +475,17 @@ func (h *Handler) BuildWebSocketSnapshot(blobID int) map[string]interface{} {
|
|||
perLinkContribs := make([]map[string]interface{}, 0, len(exp.AllLinks))
|
||||
for _, link := range exp.AllLinks {
|
||||
perLinkContribs = append(perLinkContribs, map[string]interface{}{
|
||||
"link_id": link.LinkID,
|
||||
"tx_mac": link.NodeMAC,
|
||||
"rx_mac": link.PeerMAC,
|
||||
"delta_rms": link.DeltaRMS,
|
||||
"zone_number": link.ZoneNumber,
|
||||
"weight": link.Weight,
|
||||
"learned_weight": 1.0,
|
||||
"combined_weight": link.Weight,
|
||||
"contribution_pct": link.Contribution * 100,
|
||||
"link_id": link.LinkID,
|
||||
"tx_mac": link.NodeMAC,
|
||||
"rx_mac": link.PeerMAC,
|
||||
"delta_rms": link.DeltaRMS,
|
||||
"zone_number": link.ZoneNumber,
|
||||
"weight": link.Weight,
|
||||
"learned_weight": 1.0,
|
||||
"combined_weight": link.Weight,
|
||||
"contribution_pct": link.Contribution * 100,
|
||||
"fresnel_intersection_volume": 0,
|
||||
"contributing": link.Contributing,
|
||||
"contributing": link.Contributing,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -493,23 +493,23 @@ func (h *Handler) BuildWebSocketSnapshot(blobID int) map[string]interface{} {
|
|||
fresnelZones := make([]map[string]interface{}, 0, len(exp.FresnelZones))
|
||||
for _, z := range exp.FresnelZones {
|
||||
fresnelZones = append(fresnelZones, map[string]interface{}{
|
||||
"link_id": z.LinkID,
|
||||
"tx_pos": z.TXPos[:],
|
||||
"rx_pos": z.RXPos[:],
|
||||
"center_pos": z.CenterPos[:],
|
||||
"semi_axes": z.SemiAxes[:],
|
||||
"link_id": z.LinkID,
|
||||
"tx_pos": z.TXPos[:],
|
||||
"rx_pos": z.RXPos[:],
|
||||
"center_pos": z.CenterPos[:],
|
||||
"semi_axes": z.SemiAxes[:],
|
||||
"zone_number": z.ZoneNumber,
|
||||
"lambda": z.Lambda,
|
||||
"lambda": z.Lambda,
|
||||
})
|
||||
}
|
||||
|
||||
snap := map[string]interface{}{
|
||||
"blob_id": exp.BlobID,
|
||||
"blob_position": []float64{exp.X, exp.Y, exp.Z},
|
||||
"fusion_score": exp.Confidence,
|
||||
"blob_id": exp.BlobID,
|
||||
"blob_position": []float64{exp.X, exp.Y, exp.Z},
|
||||
"fusion_score": exp.Confidence,
|
||||
"per_link_contributions": perLinkContribs,
|
||||
"fresnel_zones": fresnelZones,
|
||||
"timestamp": time.UnixMilli(exp.Timestamp),
|
||||
"fresnel_zones": fresnelZones,
|
||||
"timestamp": time.UnixMilli(exp.Timestamp),
|
||||
}
|
||||
|
||||
if exp.BLEMatch != nil {
|
||||
|
|
@ -537,5 +537,5 @@ func writeJSON(w http.ResponseWriter, v interface{}) {
|
|||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data); //nolint:errcheck
|
||||
_, _ = w.Write(data) //nolint:errcheck
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,12 +44,12 @@ type FallEvent struct {
|
|||
Identity string `json:"identity,omitempty"`
|
||||
ZoneID string `json:"zone_id,omitempty"`
|
||||
ZoneName string `json:"zone_name,omitempty"`
|
||||
StartZ float64 `json:"start_z"` // Height before fall
|
||||
EndZ float64 `json:"end_z"` // Height after fall
|
||||
ZDrop float64 `json:"z_drop"` // Total Z drop in meters
|
||||
PeakVelocity float64 `json:"peak_velocity"` // Peak downward velocity (m/s)
|
||||
FallDuration float64 `json:"fall_duration"` // Time from descent to stillness (seconds)
|
||||
StillnessTime float64 `json:"stillness_time"` // Seconds of stillness after fall
|
||||
StartZ float64 `json:"start_z"` // Height before fall
|
||||
EndZ float64 `json:"end_z"` // Height after fall
|
||||
ZDrop float64 `json:"z_drop"` // Total Z drop in meters
|
||||
PeakVelocity float64 `json:"peak_velocity"` // Peak downward velocity (m/s)
|
||||
FallDuration float64 `json:"fall_duration"` // Time from descent to stillness (seconds)
|
||||
StillnessTime float64 `json:"stillness_time"` // Seconds of stillness after fall
|
||||
Confidence float64 `json:"confidence"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Position Position `json:"position"`
|
||||
|
|
@ -67,22 +67,22 @@ type Position struct {
|
|||
|
||||
// BlobSnapshot captures blob state at a point in time.
|
||||
type BlobSnapshot struct {
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
VX, VY, VZ float64
|
||||
Posture string
|
||||
Timestamp time.Time
|
||||
DeltaRMS float64 // Motion level from contributing links
|
||||
Posture string
|
||||
Timestamp time.Time
|
||||
DeltaRMS float64 // Motion level from contributing links
|
||||
}
|
||||
|
||||
// AlertLevel represents the escalation level of a fall alert.
|
||||
type AlertLevel int
|
||||
|
||||
const (
|
||||
AlertLevelNone AlertLevel = iota
|
||||
AlertLevelInitial // T+0s
|
||||
AlertLevelEscalated // T+2min
|
||||
AlertLevelUrgent // T+5min
|
||||
AlertLevelNone AlertLevel = iota
|
||||
AlertLevelInitial // T+0s
|
||||
AlertLevelEscalated // T+2min
|
||||
AlertLevelUrgent // T+5min
|
||||
)
|
||||
|
||||
// fallTrackState holds per-blob fall detection state.
|
||||
|
|
@ -113,8 +113,8 @@ type fallTrackState struct {
|
|||
}
|
||||
|
||||
type postureEntry struct {
|
||||
posture string
|
||||
time time.Time
|
||||
posture string
|
||||
time time.Time
|
||||
}
|
||||
|
||||
// Config holds fall detector configuration.
|
||||
|
|
@ -125,14 +125,14 @@ type Config struct {
|
|||
DescentWindow float64 // Rolling window for velocity (seconds), default: 0.2
|
||||
|
||||
// Post-fall confirmation
|
||||
FloorLevelThreshold float64 // Z below this is "floor level" (m), default: 0.4
|
||||
FloorLevelThreshold float64 // Z below this is "floor level" (m), default: 0.4
|
||||
StillnessMotionThreshold float64 // DeltaRMS below this is "still", default: 0.01
|
||||
StillnessTimeRequired float64 // Seconds of stillness to confirm (s), default: 30
|
||||
|
||||
// False positive suppression
|
||||
StandardDropThreshold float64 // Normal drop threshold (m), default: 0.8
|
||||
ElevatedDropThreshold float64 // Higher threshold if recent sitting/lying (m), default: 1.2
|
||||
PostureHistoryWindow float64 // How far back to check posture (s), default: 600 (10 min)
|
||||
StandardDropThreshold float64 // Normal drop threshold (m), default: 0.8
|
||||
ElevatedDropThreshold float64 // Higher threshold if recent sitting/lying (m), default: 1.2
|
||||
PostureHistoryWindow float64 // How far back to check posture (s), default: 600 (10 min)
|
||||
|
||||
// Alert timing
|
||||
EscalationTime1 time.Duration // Time to first escalation, default: 2min
|
||||
|
|
@ -143,18 +143,18 @@ type Config struct {
|
|||
// DefaultConfig returns the default fall detection configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
DescentVelocityThreshold: -1.5,
|
||||
DescentDropThreshold: 0.8,
|
||||
DescentWindow: 0.2,
|
||||
FloorLevelThreshold: 0.4,
|
||||
StillnessMotionThreshold: 0.01,
|
||||
StillnessTimeRequired: 30.0,
|
||||
StandardDropThreshold: 0.8,
|
||||
ElevatedDropThreshold: 1.2,
|
||||
PostureHistoryWindow: 600.0,
|
||||
EscalationTime1: 2 * time.Minute,
|
||||
EscalationTime2: 5 * time.Minute,
|
||||
AlertCooldown: 5 * time.Minute,
|
||||
DescentVelocityThreshold: -1.5,
|
||||
DescentDropThreshold: 0.8,
|
||||
DescentWindow: 0.2,
|
||||
FloorLevelThreshold: 0.4,
|
||||
StillnessMotionThreshold: 0.01,
|
||||
StillnessTimeRequired: 30.0,
|
||||
StandardDropThreshold: 0.8,
|
||||
ElevatedDropThreshold: 1.2,
|
||||
PostureHistoryWindow: 600.0,
|
||||
EscalationTime1: 2 * time.Minute,
|
||||
EscalationTime2: 5 * time.Minute,
|
||||
AlertCooldown: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -164,20 +164,20 @@ type Detector struct {
|
|||
config Config
|
||||
|
||||
// Per-blob tracking
|
||||
trackStates map[int]*fallTrackState
|
||||
blobHistory map[int][]BlobSnapshot
|
||||
trackStates map[int]*fallTrackState
|
||||
blobHistory map[int][]BlobSnapshot
|
||||
|
||||
// Active fall events (for acknowledgement)
|
||||
activeFalls map[string]*FallEvent
|
||||
fallIDCounter int
|
||||
|
||||
// Callbacks
|
||||
onFall func(FallEvent)
|
||||
onEscalation func(FallEvent, AlertLevel)
|
||||
identityFunc func(blobID int) string
|
||||
zoneFunc func(blobID int) (zoneID, zoneName string)
|
||||
childrenZoneFunc func(blobID int) bool // Returns true if blob is in children's zone
|
||||
deltaRMSFunc func(blobID int) float64 // Gets motion level for blob
|
||||
onFall func(FallEvent)
|
||||
onEscalation func(FallEvent, AlertLevel)
|
||||
identityFunc func(blobID int) string
|
||||
zoneFunc func(blobID int) (zoneID, zoneName string)
|
||||
childrenZoneFunc func(blobID int) bool // Returns true if blob is in children's zone
|
||||
deltaRMSFunc func(blobID int) float64 // Gets motion level for blob
|
||||
|
||||
// Alert tracking
|
||||
recentAlerts map[int]time.Time // blobID -> last alert time
|
||||
|
|
@ -247,10 +247,10 @@ func (d *Detector) SetDeltaRMSFunc(fn func(blobID int) float64) {
|
|||
|
||||
// Update processes new blob positions. This should be called at 10Hz.
|
||||
func (d *Detector) Update(blobs []struct {
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
VX, VY, VZ float64
|
||||
Posture string
|
||||
Posture string
|
||||
}, now time.Time) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
|
@ -268,10 +268,10 @@ func (d *Detector) Update(blobs []struct {
|
|||
|
||||
// processBlob analyzes a single blob for fall patterns.
|
||||
func (d *Detector) processBlob(blob struct {
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
VX, VY, VZ float64
|
||||
Posture string
|
||||
Posture string
|
||||
}, now time.Time) {
|
||||
// Record snapshot
|
||||
snapshot := BlobSnapshot{
|
||||
|
|
@ -347,10 +347,10 @@ func (d *Detector) processBlob(blob struct {
|
|||
|
||||
// checkForDescent detects the initial rapid descent that could indicate a fall.
|
||||
func (d *Detector) checkForDescent(blob struct {
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
VX, VY, VZ float64
|
||||
Posture string
|
||||
Posture string
|
||||
}, history []BlobSnapshot, state *fallTrackState, now time.Time) {
|
||||
if len(history) < 3 {
|
||||
return
|
||||
|
|
@ -415,10 +415,10 @@ func (d *Detector) checkForDescent(blob struct {
|
|||
|
||||
// checkForFallConfirmation checks if the descent is followed by stillness at floor level.
|
||||
func (d *Detector) checkForFallConfirmation(blob struct {
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
VX, VY, VZ float64
|
||||
Posture string
|
||||
Posture string
|
||||
}, history []BlobSnapshot, state *fallTrackState, now time.Time) {
|
||||
// Check if person got up (Z rose above floor level)
|
||||
if blob.Z > d.config.FloorLevelThreshold+0.1 { // Small hysteresis
|
||||
|
|
@ -478,10 +478,10 @@ func (d *Detector) checkForFallConfirmation(blob struct {
|
|||
|
||||
// checkForRecovery monitors a confirmed fall for recovery (person gets up).
|
||||
func (d *Detector) checkForRecovery(blob struct {
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
VX, VY, VZ float64
|
||||
Posture string
|
||||
Posture string
|
||||
}, state *fallTrackState, now time.Time) {
|
||||
// If person rises significantly, they've recovered
|
||||
if blob.Z > d.config.FloorLevelThreshold+0.3 {
|
||||
|
|
@ -502,10 +502,10 @@ func (d *Detector) hasRecentSittingOrLying(state *fallTrackState) bool {
|
|||
|
||||
// triggerFallAlert fires the fall alert and sets up escalation timers.
|
||||
func (d *Detector) triggerFallAlert(blob struct {
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
ID int
|
||||
X, Y, Z float64
|
||||
VX, VY, VZ float64
|
||||
Posture string
|
||||
Posture string
|
||||
}, state *fallTrackState, fallDuration, stillnessTime float64, now time.Time) {
|
||||
// Check cooldown
|
||||
if lastAlert, exists := d.recentAlerts[blob.ID]; exists {
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ type CollisionEvent struct {
|
|||
|
||||
// CollisionStats tracks collision statistics for a pair of TX nodes
|
||||
type CollisionStats struct {
|
||||
TotalFrames int
|
||||
Collisions int
|
||||
TotalFrames int
|
||||
Collisions int
|
||||
LastCollision time.Time
|
||||
LastReset time.Time
|
||||
}
|
||||
|
|
@ -59,7 +59,7 @@ type CollisionDetector struct {
|
|||
lastArrival map[string]time.Time
|
||||
|
||||
// Re-stagger state
|
||||
lastRestagger time.Time
|
||||
lastRestagger time.Time
|
||||
restaggersEnabled bool
|
||||
|
||||
// Callback for re-stagger trigger
|
||||
|
|
@ -72,13 +72,13 @@ type CollisionDetector struct {
|
|||
// NewCollisionDetector creates a new collision detector
|
||||
func NewCollisionDetector() *CollisionDetector {
|
||||
return &CollisionDetector{
|
||||
collisionCount: make(map[string]int),
|
||||
totalFrames: make(map[string]int),
|
||||
collisionHistory: make(map[string][]CollisionEvent),
|
||||
txNodes: make(map[string]bool),
|
||||
lastArrival: make(map[string]time.Time),
|
||||
collisionCount: make(map[string]int),
|
||||
totalFrames: make(map[string]int),
|
||||
collisionHistory: make(map[string][]CollisionEvent),
|
||||
txNodes: make(map[string]bool),
|
||||
lastArrival: make(map[string]time.Time),
|
||||
restaggersEnabled: true,
|
||||
frameArrivals: make(map[string][]time.Time),
|
||||
frameArrivals: make(map[string][]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -446,8 +446,8 @@ func (m *mockPersonNameProvider) GetPersonName(personID string) string {
|
|||
|
||||
// mockModeChangeBroadcaster implements ModeChangeBroadcaster for testing.
|
||||
type mockModeChangeBroadcaster struct {
|
||||
mu sync.Mutex
|
||||
events []events.SystemModeChangeEvent
|
||||
mu sync.Mutex
|
||||
events []events.SystemModeChangeEvent
|
||||
}
|
||||
|
||||
func (m *mockModeChangeBroadcaster) BroadcastSystemModeChange(event events.SystemModeChangeEvent) {
|
||||
|
|
|
|||
|
|
@ -106,17 +106,17 @@ func (h *FleetHandler) getFleetHealth(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
entries = append(entries, fleetNodeEntry{
|
||||
MAC: n.MAC,
|
||||
Name: n.Name,
|
||||
Role: role,
|
||||
HealthScore: n.HealthScore,
|
||||
Online: online,
|
||||
PosX: n.PosX,
|
||||
PosY: n.PosY,
|
||||
PosZ: n.PosZ,
|
||||
MAC: n.MAC,
|
||||
Name: n.Name,
|
||||
Role: role,
|
||||
HealthScore: n.HealthScore,
|
||||
Online: online,
|
||||
PosX: n.PosX,
|
||||
PosY: n.PosY,
|
||||
PosZ: n.PosZ,
|
||||
FirmwareVersion: n.FirmwareVersion,
|
||||
UptimeSeconds: uptimeSeconds,
|
||||
LastSeenMs: n.LastSeenAt.UnixMilli(),
|
||||
UptimeSeconds: uptimeSeconds,
|
||||
LastSeenMs: n.LastSeenAt.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -192,17 +192,17 @@ func (h *FleetHandler) getFleet(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
entries = append(entries, fleetNodeEntry{
|
||||
MAC: n.MAC,
|
||||
Name: n.Name,
|
||||
Role: role,
|
||||
HealthScore: n.HealthScore,
|
||||
Online: online,
|
||||
PosX: n.PosX,
|
||||
PosY: n.PosY,
|
||||
PosZ: n.PosZ,
|
||||
MAC: n.MAC,
|
||||
Name: n.Name,
|
||||
Role: role,
|
||||
HealthScore: n.HealthScore,
|
||||
Online: online,
|
||||
PosX: n.PosX,
|
||||
PosY: n.PosY,
|
||||
PosZ: n.PosZ,
|
||||
FirmwareVersion: n.FirmwareVersion,
|
||||
UptimeSeconds: uptimeSeconds,
|
||||
LastSeenMs: n.LastSeenAt.UnixMilli(),
|
||||
UptimeSeconds: uptimeSeconds,
|
||||
LastSeenMs: n.LastSeenAt.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -282,9 +282,9 @@ func (h *FleetHandler) getFleetHistory(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// optimiseResponse is returned after manual optimisation
|
||||
type optimiseResponse struct {
|
||||
TriggerReason string `json:"trigger_reason"`
|
||||
CoverageScore float64 `json:"coverage_score"`
|
||||
MeanGDOP float64 `json:"mean_gdop"`
|
||||
TriggerReason string `json:"trigger_reason"`
|
||||
CoverageScore float64 `json:"coverage_score"`
|
||||
MeanGDOP float64 `json:"mean_gdop"`
|
||||
RoleAssignments map[string]string `json:"role_assignments"`
|
||||
}
|
||||
|
||||
|
|
@ -292,9 +292,9 @@ func (h *FleetHandler) triggerOptimise(w http.ResponseWriter, r *http.Request) {
|
|||
result := h.healer.ManualOptimise()
|
||||
|
||||
resp := optimiseResponse{
|
||||
TriggerReason: result.TriggerReason,
|
||||
CoverageScore: result.CoverageScore,
|
||||
MeanGDOP: result.MeanGDOP,
|
||||
TriggerReason: result.TriggerReason,
|
||||
CoverageScore: result.CoverageScore,
|
||||
MeanGDOP: result.MeanGDOP,
|
||||
RoleAssignments: h.healer.GetCurrentRoles(),
|
||||
}
|
||||
|
||||
|
|
@ -382,8 +382,8 @@ func (h *FleetHandler) locateNode(w http.ResponseWriter, r *http.Request) {
|
|||
// This is handled by the ingestion server which has access to node connections
|
||||
// For now, return success - the actual command will be sent via WebSocket
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"status": "sent",
|
||||
"mac": mac,
|
||||
"status": "sent",
|
||||
"mac": mac,
|
||||
"duration_ms": req.DurationMS,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,20 +132,20 @@ type FleetNode struct {
|
|||
HealthScore float64 `json:"health_score"`
|
||||
Unpaired bool `json:"unpaired,omitempty"`
|
||||
// Computed fields
|
||||
LastSeenMS int64 `json:"last_seen_ms"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
PacketRate float64 `json:"packet_rate"`
|
||||
ConfiguredRate int `json:"configured_rate"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
OTAInProgress bool `json:"ota_in_progress"`
|
||||
LastSeenMS int64 `json:"last_seen_ms"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
PacketRate float64 `json:"packet_rate"`
|
||||
ConfiguredRate int `json:"configured_rate"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
OTAInProgress bool `json:"ota_in_progress"`
|
||||
}
|
||||
|
||||
// fleetListResponse wraps the fleet list with migration window metadata.
|
||||
type fleetListResponse struct {
|
||||
Nodes []FleetNode `json:"nodes"`
|
||||
MigrationWindowActive bool `json:"migration_window_active"`
|
||||
MigrationDeadlineMS int64 `json:"migration_deadline_ms,omitempty"`
|
||||
MigrationRemainingSecs float64 `json:"migration_remaining_secs,omitempty"`
|
||||
Nodes []FleetNode `json:"nodes"`
|
||||
MigrationWindowActive bool `json:"migration_window_active"`
|
||||
MigrationDeadlineMS int64 `json:"migration_deadline_ms,omitempty"`
|
||||
MigrationRemainingSecs float64 `json:"migration_remaining_secs,omitempty"`
|
||||
}
|
||||
|
||||
// listFleet returns extended node data with computed fields for the fleet page.
|
||||
|
|
@ -549,9 +549,9 @@ func (h *Handler) exportConfig(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
config := map[string]interface{}{
|
||||
"version": 1,
|
||||
"version": 1,
|
||||
"exported_at": time.Now().Format(time.RFC3339),
|
||||
"nodes": nodes,
|
||||
"nodes": nodes,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -614,14 +614,14 @@ func (h *Handler) updateRoom(w http.ResponseWriter, r *http.Request) {
|
|||
// ── System Mode endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
type systemModeResponse struct {
|
||||
Mode string `json:"mode"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
AutoAwayConfig autoAwayConfigResponse `json:"auto_away_config"`
|
||||
}
|
||||
|
||||
type autoAwayConfigResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AbsenceDurationSec int `json:"absence_duration_sec"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AbsenceDurationSec int `json:"absence_duration_sec"`
|
||||
}
|
||||
|
||||
// getSystemMode returns the current system mode.
|
||||
|
|
@ -630,9 +630,9 @@ func (h *Handler) getSystemMode(w http.ResponseWriter, r *http.Request) {
|
|||
cfg := h.mgr.GetAutoAwayConfig()
|
||||
|
||||
resp := systemModeResponse{
|
||||
Mode: string(mode),
|
||||
Mode: string(mode),
|
||||
AutoAwayConfig: autoAwayConfigResponse{
|
||||
Enabled: cfg.Enabled,
|
||||
Enabled: cfg.Enabled,
|
||||
AbsenceDurationSec: int(cfg.AbsenceDuration.Seconds()),
|
||||
},
|
||||
}
|
||||
|
|
@ -793,9 +793,9 @@ func (h *Handler) disableNode(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"mac": mac,
|
||||
"prior_role": node.Role,
|
||||
"ok": true,
|
||||
"mac": mac,
|
||||
"prior_role": node.Role,
|
||||
"current_role": "idle",
|
||||
})
|
||||
}
|
||||
|
|
@ -830,10 +830,10 @@ func (h *Handler) enableNode(w http.ResponseWriter, r *http.Request) {
|
|||
} else {
|
||||
// Node isn't idle, just return current state.
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"mac": mac,
|
||||
"ok": true,
|
||||
"mac": mac,
|
||||
"current_role": node.Role,
|
||||
"note": "node already enabled",
|
||||
"note": "node already enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
@ -851,8 +851,8 @@ func (h *Handler) enableNode(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"mac": mac,
|
||||
"ok": true,
|
||||
"mac": mac,
|
||||
"restored_role": priorRole,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,28 +39,28 @@ type CoverageResult struct {
|
|||
type FleetHealer struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
registry *Registry
|
||||
gdopCalc GDOPCalculator
|
||||
notifier NodeStateNotifier
|
||||
bcaster RegistryBroadcaster
|
||||
registry *Registry
|
||||
gdopCalc GDOPCalculator
|
||||
notifier NodeStateNotifier
|
||||
bcaster RegistryBroadcaster
|
||||
|
||||
// State tracking
|
||||
online map[string]struct{}
|
||||
nodePositions map[string]NodePosition
|
||||
|
||||
// Coverage history for before/after comparison
|
||||
lastCoverage *CoverageResult
|
||||
lastCoverage *CoverageResult
|
||||
coverageHistory []*CoverageResult
|
||||
maxHistorySize int
|
||||
maxHistorySize int
|
||||
|
||||
// Healing configuration
|
||||
healInterval time.Duration
|
||||
minOnlineNodes int // Minimum nodes before degraded mode
|
||||
degradedMode bool
|
||||
healInterval time.Duration
|
||||
minOnlineNodes int // Minimum nodes before degraded mode
|
||||
degradedMode bool
|
||||
|
||||
// Role optimization
|
||||
optimalRoles map[string]string
|
||||
txNodes []string
|
||||
optimalRoles map[string]string
|
||||
txNodes []string
|
||||
}
|
||||
|
||||
// FleetHealerConfig holds configuration for FleetHealer
|
||||
|
|
@ -649,8 +649,8 @@ func (fh *FleetHealer) GetWorstCoverageZone() (x, z, gdop float64) {
|
|||
room, _ := fh.registry.GetRoom()
|
||||
cellSize := 0.2
|
||||
if room != nil {
|
||||
x = room.OriginX + (float64(worstCol) + 0.5) * cellSize
|
||||
z = room.OriginZ + (float64(worstRow) + 0.5) * cellSize
|
||||
x = room.OriginX + (float64(worstCol)+0.5)*cellSize
|
||||
z = room.OriginZ + (float64(worstRow)+0.5)*cellSize
|
||||
}
|
||||
|
||||
return x, z, worstGDOP
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import (
|
|||
// ─── Mock GDOP Calculator ─────────────────────────────────────────────────────
|
||||
|
||||
type mockGDOPCalculator struct {
|
||||
mu sync.Mutex
|
||||
mu sync.Mutex
|
||||
gdopMap []float32
|
||||
cols int
|
||||
rows int
|
||||
cols int
|
||||
rows int
|
||||
}
|
||||
|
||||
func newMockGDOPCalculator(gdop float32, cols, rows int) *mockGDOPCalculator {
|
||||
|
|
@ -368,12 +368,12 @@ func TestGenerateCombinations(t *testing.T) {
|
|||
n, k int
|
||||
wantLen int
|
||||
}{
|
||||
{4, 2, 6}, // C(4,2) = 6
|
||||
{5, 2, 10}, // C(5,2) = 10
|
||||
{5, 3, 10}, // C(5,3) = 10
|
||||
{6, 3, 20}, // C(6,3) = 20
|
||||
{3, 0, 1}, // C(3,0) = 1
|
||||
{3, 3, 1}, // C(3,3) = 1
|
||||
{4, 2, 6}, // C(4,2) = 6
|
||||
{5, 2, 10}, // C(5,2) = 10
|
||||
{5, 3, 10}, // C(5,3) = 10
|
||||
{6, 3, 20}, // C(6,3) = 20
|
||||
{3, 0, 1}, // C(3,0) = 1
|
||||
{3, 3, 1}, // C(3,3) = 1
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -49,18 +49,18 @@ type PersonNameProvider interface {
|
|||
|
||||
// BLEObservation represents a BLE RSSI observation with device info.
|
||||
type BLEObservation struct {
|
||||
DeviceMAC string // The BLE device MAC address
|
||||
NodeMAC string // The node that observed this device
|
||||
DeviceMAC string // The BLE device MAC address
|
||||
NodeMAC string // The node that observed this device
|
||||
RSSIdBm int
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// AutoAwayConfig holds configuration for auto-away detection.
|
||||
type AutoAwayConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AbsenceDuration time.Duration `json:"absence_duration"` // Default: 15 minutes
|
||||
AutoDisarmRSSI int `json:"auto_disarm_rssi"` // Default: -70 dBm
|
||||
ManualOverridePause time.Duration `json:"manual_override_pause"` // Default: 30 minutes
|
||||
Enabled bool `json:"enabled"`
|
||||
AbsenceDuration time.Duration `json:"absence_duration"` // Default: 15 minutes
|
||||
AutoDisarmRSSI int `json:"auto_disarm_rssi"` // Default: -70 dBm
|
||||
ManualOverridePause time.Duration `json:"manual_override_pause"` // Default: 30 minutes
|
||||
}
|
||||
|
||||
// DefaultAutoAwayConfig returns default auto-away configuration.
|
||||
|
|
@ -106,8 +106,8 @@ type Manager struct {
|
|||
onModeChange func(events.SystemModeChangeEvent)
|
||||
|
||||
// Collision detection and adaptive re-stagger
|
||||
collisionDetector *CollisionDetector
|
||||
collisionCheckTick time.Duration
|
||||
collisionDetector *CollisionDetector
|
||||
collisionCheckTick time.Duration
|
||||
}
|
||||
|
||||
// NewManager creates a fleet manager backed by registry.
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import (
|
|||
|
||||
// Role constants
|
||||
const (
|
||||
RoleTX = "tx"
|
||||
RoleRX = "rx"
|
||||
RoleTXRX = "tx_rx"
|
||||
RoleTX = "tx"
|
||||
RoleRX = "rx"
|
||||
RoleTXRX = "tx_rx"
|
||||
RolePassive = "passive"
|
||||
)
|
||||
|
||||
|
|
@ -66,9 +66,9 @@ func DefaultOptimisationConfig() OptimisationConfig {
|
|||
|
||||
// RoleOptimiser computes optimal role assignments to maximise coverage
|
||||
type RoleOptimiser struct {
|
||||
config OptimisationConfig
|
||||
gdopCalc GDOPCalculator
|
||||
roomConfig RoomConfig
|
||||
config OptimisationConfig
|
||||
gdopCalc GDOPCalculator
|
||||
roomConfig RoomConfig
|
||||
}
|
||||
|
||||
// NewRoleOptimiser creates a new role optimiser
|
||||
|
|
@ -90,16 +90,16 @@ func (ro *RoleOptimiser) SetRoomConfig(room RoomConfig) {
|
|||
|
||||
// OptimiseResult contains the result of a role optimisation
|
||||
type OptimiseResult struct {
|
||||
Assignments []RoleAssignment
|
||||
Links []SensingLink
|
||||
MeanGDOP float64
|
||||
CoverageScore float64
|
||||
OptimisedAt time.Time
|
||||
TriggerReason string
|
||||
GDOPBefore []float32 // GDOP map before (if available)
|
||||
GDOPAfter []float32 // GDOP map after
|
||||
GDOPCols int
|
||||
GDOPRows int
|
||||
Assignments []RoleAssignment
|
||||
Links []SensingLink
|
||||
MeanGDOP float64
|
||||
CoverageScore float64
|
||||
OptimisedAt time.Time
|
||||
TriggerReason string
|
||||
GDOPBefore []float32 // GDOP map before (if available)
|
||||
GDOPAfter []float32 // GDOP map after
|
||||
GDOPCols int
|
||||
GDOPRows int
|
||||
}
|
||||
|
||||
// Optimise computes the optimal role assignment for the given nodes
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ type NodeRecord struct {
|
|||
MAC string `json:"mac"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
PreviousRole string `json:"previous_role"` // Role before disconnect, for reconnect grace period
|
||||
PreviousRole string `json:"previous_role"` // Role before disconnect, for reconnect grace period
|
||||
WentOfflineAt time.Time `json:"went_offline_at,omitempty"` // When the node went offline
|
||||
PosX float64 `json:"pos_x"`
|
||||
PosY float64 `json:"pos_y"`
|
||||
|
|
|
|||
|
|
@ -35,23 +35,23 @@ func DefaultSelfHealConfig() SelfHealConfig {
|
|||
|
||||
// FleetChangeEvent is broadcast when the fleet configuration changes
|
||||
type FleetChangeEvent struct {
|
||||
Type string `json:"type"` // "node_lost", "node_recovered", "reoptimised"
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
TriggerReason string `json:"trigger_reason"`
|
||||
OfflineMAC string `json:"offline_mac,omitempty"`
|
||||
RecoveredMAC string `json:"recovered_mac,omitempty"`
|
||||
MeanGDOPBefore float64 `json:"mean_gdop_before"`
|
||||
MeanGDOPAfter float64 `json:"mean_gdop_after"`
|
||||
CoverageBefore float64 `json:"coverage_before"`
|
||||
CoverageAfter float64 `json:"coverage_after"`
|
||||
CoverageDelta float64 `json:"coverage_delta"`
|
||||
IsDegradation bool `json:"is_degradation"` // true if coverage dropped significantly
|
||||
WarningMessage string `json:"warning_message,omitempty"`
|
||||
RoleAssignments map[string]string `json:"role_assignments"`
|
||||
GDOPBefore []float32 `json:"gdop_before,omitempty"` // GDOP map before
|
||||
GDOPAfter []float32 `json:"gdop_after,omitempty"` // GDOP map after
|
||||
GDOPCols int `json:"gdop_cols"`
|
||||
GDOPRows int `json:"gdop_rows"`
|
||||
Type string `json:"type"` // "node_lost", "node_recovered", "reoptimised"
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
TriggerReason string `json:"trigger_reason"`
|
||||
OfflineMAC string `json:"offline_mac,omitempty"`
|
||||
RecoveredMAC string `json:"recovered_mac,omitempty"`
|
||||
MeanGDOPBefore float64 `json:"mean_gdop_before"`
|
||||
MeanGDOPAfter float64 `json:"mean_gdop_after"`
|
||||
CoverageBefore float64 `json:"coverage_before"`
|
||||
CoverageAfter float64 `json:"coverage_after"`
|
||||
CoverageDelta float64 `json:"coverage_delta"`
|
||||
IsDegradation bool `json:"is_degradation"` // true if coverage dropped significantly
|
||||
WarningMessage string `json:"warning_message,omitempty"`
|
||||
RoleAssignments map[string]string `json:"role_assignments"`
|
||||
GDOPBefore []float32 `json:"gdop_before,omitempty"` // GDOP map before
|
||||
GDOPAfter []float32 `json:"gdop_after,omitempty"` // GDOP map after
|
||||
GDOPCols int `json:"gdop_cols"`
|
||||
GDOPRows int `json:"gdop_rows"`
|
||||
}
|
||||
|
||||
// OfflineNode tracks a node that has gone offline
|
||||
|
|
@ -66,11 +66,11 @@ type OfflineNode struct {
|
|||
type SelfHealManager struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
registry *Registry
|
||||
optimiser *RoleOptimiser
|
||||
notifier NodeStateNotifier
|
||||
bcaster FleetChangeBroadcaster
|
||||
gdopCalc GDOPCalculator
|
||||
registry *Registry
|
||||
optimiser *RoleOptimiser
|
||||
notifier NodeStateNotifier
|
||||
bcaster FleetChangeBroadcaster
|
||||
gdopCalc GDOPCalculator
|
||||
|
||||
config SelfHealConfig
|
||||
|
||||
|
|
@ -180,10 +180,10 @@ func (shm *SelfHealManager) OnNodeConnected(mac, firmware, chip string) {
|
|||
|
||||
// Broadcast recovery event
|
||||
shm.broadcastEvent(FleetChangeEvent{
|
||||
Type: "node_recovered",
|
||||
Timestamp: now,
|
||||
TriggerReason: "grace_period_reconnect",
|
||||
RecoveredMAC: mac,
|
||||
Type: "node_recovered",
|
||||
Timestamp: now,
|
||||
TriggerReason: "grace_period_reconnect",
|
||||
RecoveredMAC: mac,
|
||||
RoleAssignments: shm.getCurrentRoles(),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -50,14 +50,14 @@ type LinkWeatherReport struct {
|
|||
Issues []LinkIssue
|
||||
|
||||
// Trend over time
|
||||
TrendSNR string // "improving", "stable", "degrading"
|
||||
TrendStability string
|
||||
TrendPacketRate string
|
||||
TrendSNR string // "improving", "stable", "degrading"
|
||||
TrendStability string
|
||||
TrendPacketRate string
|
||||
|
||||
// Weekly statistics
|
||||
WeekUptimePct float64
|
||||
WeekMeanSNR float64
|
||||
WeekFalsePosRate float64
|
||||
WeekUptimePct float64
|
||||
WeekMeanSNR float64
|
||||
WeekFalsePosRate float64
|
||||
|
||||
// Repositioning advice
|
||||
RepositionAdvice string
|
||||
|
|
@ -610,9 +610,9 @@ func (lwd *LinkWeatherDiagnostics) GetWeeklyTrend(linkID string) []DailyHealthSu
|
|||
}
|
||||
|
||||
type dailyAccumulator struct {
|
||||
snr float64
|
||||
snr float64
|
||||
health float64
|
||||
count int
|
||||
count int
|
||||
}
|
||||
|
||||
func (d *dailyAccumulator) add(s LinkWeatherSnapshot) {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ const (
|
|||
|
||||
// Handler provides floor plan HTTP endpoints.
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
dataDir string
|
||||
db *sql.DB
|
||||
dataDir string
|
||||
floorplanDir string
|
||||
}
|
||||
|
||||
|
|
@ -41,8 +41,8 @@ func NewHandler(db *sql.DB, dataDir string) *Handler {
|
|||
log.Printf("[WARN] Failed to create floorplan directory: %v", err)
|
||||
}
|
||||
return &Handler{
|
||||
db: db,
|
||||
dataDir: dataDir,
|
||||
db: db,
|
||||
dataDir: dataDir,
|
||||
floorplanDir: fpDir,
|
||||
}
|
||||
}
|
||||
|
|
@ -148,7 +148,7 @@ func (h *Handler) uploadImage(w http.ResponseWriter, r *http.Request) {
|
|||
// Return success with image URL
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"ok": "true",
|
||||
"ok": "true",
|
||||
"image_url": "/floorplan/image.png",
|
||||
})
|
||||
}
|
||||
|
|
@ -176,11 +176,11 @@ func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// calibrateRequest contains calibration point data.
|
||||
type calibrateRequest struct {
|
||||
AX float64 `json:"ax"`
|
||||
AY float64 `json:"ay"`
|
||||
BX float64 `json:"bx"`
|
||||
BY float64 `json:"by"`
|
||||
DistanceM float64 `json:"distance_m"`
|
||||
AX float64 `json:"ax"`
|
||||
AY float64 `json:"ay"`
|
||||
BX float64 `json:"bx"`
|
||||
BY float64 `json:"by"`
|
||||
DistanceM float64 `json:"distance_m"`
|
||||
RotationDeg float64 `json:"rotation_deg,omitempty"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -213,12 +213,12 @@ func GenerateExplainabilitySnapshot(
|
|||
(s.posA.Z + s.posB.Z) / 2,
|
||||
}
|
||||
fresnelZones = append(fresnelZones, ExplainFresnelZone{
|
||||
LinkID: s.lm.NodeMAC + ":" + s.lm.PeerMAC,
|
||||
TXPos: [3]float64{s.posA.X, s.posA.Y, s.posA.Z},
|
||||
RXPos: [3]float64{s.posB.X, s.posB.Y, s.posB.Z},
|
||||
Center: center,
|
||||
LinkID: s.lm.NodeMAC + ":" + s.lm.PeerMAC,
|
||||
TXPos: [3]float64{s.posA.X, s.posA.Y, s.posA.Z},
|
||||
RXPos: [3]float64{s.posB.X, s.posB.Y, s.posB.Z},
|
||||
Center: center,
|
||||
SemiAxes: [3]float64{b, b, a}, // X=b, Y=b, Z=a for Three.js scale
|
||||
Lambda: lambda,
|
||||
Lambda: lambda,
|
||||
})
|
||||
}
|
||||
snap.FresnelZones = fresnelZones
|
||||
|
|
|
|||
|
|
@ -52,13 +52,13 @@ type Result struct {
|
|||
|
||||
// LinkContribution describes a link's contribution to fusion.
|
||||
type LinkContribution struct {
|
||||
LinkID string // "node_mac:peer_mac"
|
||||
NodeMAC string
|
||||
PeerMAC string
|
||||
DeltaRMS float64
|
||||
ZoneNum int // Fresnel zone number at the peak position
|
||||
Weight float64 // Learned weight for this link
|
||||
Contributing bool // Whether this link contributed to a blob
|
||||
LinkID string // "node_mac:peer_mac"
|
||||
NodeMAC string
|
||||
PeerMAC string
|
||||
DeltaRMS float64
|
||||
ZoneNum int // Fresnel zone number at the peak position
|
||||
Weight float64 // Learned weight for this link
|
||||
Contributing bool // Whether this link contributed to a blob
|
||||
}
|
||||
|
||||
// Engine runs the multi-link 3D Fresnel zone fusion.
|
||||
|
|
@ -155,13 +155,13 @@ func (e *Engine) Fuse(links []LinkMotion) *Result {
|
|||
|
||||
activeLinks := 0
|
||||
activeLinkData := make([]struct {
|
||||
linkID string
|
||||
nodeMAC string
|
||||
peerMAC string
|
||||
deltaRMS float64
|
||||
weight float64
|
||||
posA NodePosition
|
||||
posB NodePosition
|
||||
linkID string
|
||||
nodeMAC string
|
||||
peerMAC string
|
||||
deltaRMS float64
|
||||
weight float64
|
||||
posA NodePosition
|
||||
posB NodePosition
|
||||
}, 0)
|
||||
|
||||
for _, lm := range links {
|
||||
|
|
@ -190,13 +190,13 @@ func (e *Engine) Fuse(links []LinkMotion) *Result {
|
|||
// Store active link data for contribution tracking
|
||||
linkID := lm.NodeMAC + ":" + lm.PeerMAC
|
||||
activeLinkData = append(activeLinkData, struct {
|
||||
linkID string
|
||||
nodeMAC string
|
||||
peerMAC string
|
||||
deltaRMS float64
|
||||
weight float64
|
||||
posA NodePosition
|
||||
posB NodePosition
|
||||
linkID string
|
||||
nodeMAC string
|
||||
peerMAC string
|
||||
deltaRMS float64
|
||||
weight float64
|
||||
posA NodePosition
|
||||
posB NodePosition
|
||||
}{
|
||||
linkID: linkID,
|
||||
nodeMAC: lm.NodeMAC,
|
||||
|
|
@ -337,13 +337,13 @@ func (e *Engine) GetGridSnapshot() *explainability.GridSnapshot {
|
|||
data := e.grid.Snapshot()
|
||||
|
||||
return &explainability.GridSnapshot{
|
||||
Width: width,
|
||||
Depth: depth,
|
||||
CellSize: cellSize,
|
||||
OriginX: ox,
|
||||
OriginZ: oz,
|
||||
Data: data,
|
||||
Rows: ny,
|
||||
Cols: nx,
|
||||
Width: width,
|
||||
Depth: depth,
|
||||
CellSize: cellSize,
|
||||
OriginX: ox,
|
||||
OriginZ: oz,
|
||||
Data: data,
|
||||
Rows: ny,
|
||||
Cols: nx,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ const defaultCellSize = 0.2 // metres
|
|||
// Grid3D is a 3D voxel grid for accumulating link activation weights.
|
||||
// Axes: X (width), Y (height), Z (depth).
|
||||
type Grid3D struct {
|
||||
mu sync.RWMutex
|
||||
cells []float64
|
||||
mu sync.RWMutex
|
||||
cells []float64
|
||||
nx, ny, nz int
|
||||
cellSize float64
|
||||
ox, oy, oz float64 // origin (min corner)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
// DiscoveryTracker tracks which features have been discovered by the user.
|
||||
// It provides first-run contextual help tooltips that are shown once per feature.
|
||||
type DiscoveryTracker struct {
|
||||
mu sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
discovered map[string]discoveryState
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ import (
|
|||
|
||||
// Qualifying settings keys that trigger repeated-edit hints
|
||||
var QualifyingSettingsKeys = map[string]bool{
|
||||
"delta_rms_threshold": true,
|
||||
"breathing_sensitivity": true,
|
||||
"tau_s": true,
|
||||
"fresnel_decay": true,
|
||||
"n_subcarriers": true,
|
||||
"motion_threshold": true,
|
||||
"delta_rms_threshold": true,
|
||||
"breathing_sensitivity": true,
|
||||
"tau_s": true,
|
||||
"fresnel_decay": true,
|
||||
"n_subcarriers": true,
|
||||
"motion_threshold": true,
|
||||
}
|
||||
|
||||
// EditTracker tracks edits to settings keys for repeated-edit hints.
|
||||
|
|
@ -28,11 +28,11 @@ type EditTracker struct {
|
|||
|
||||
// editState tracks the edit count and last edit time for a settings key.
|
||||
type editState struct {
|
||||
count int
|
||||
lastEdit time.Time
|
||||
firstEdit time.Time
|
||||
hintShown bool
|
||||
hintReset time.Time // When to allow showing hint again (24h cooldown)
|
||||
count int
|
||||
lastEdit time.Time
|
||||
firstEdit time.Time
|
||||
hintShown bool
|
||||
hintReset time.Time // When to allow showing hint again (24h cooldown)
|
||||
}
|
||||
|
||||
// NewEditTracker creates a new edit tracker.
|
||||
|
|
@ -148,17 +148,17 @@ type ZoneInfo struct {
|
|||
|
||||
// zoneQualityState tracks the quality state for a single zone.
|
||||
type zoneQualityState struct {
|
||||
zoneID int
|
||||
quality float64
|
||||
firstPoorTime time.Time // When quality first dropped below 60%
|
||||
lastPoorTime time.Time
|
||||
bannerShown bool
|
||||
resolvedCount int
|
||||
hysteresis float64 // For quality improvements
|
||||
zoneID int
|
||||
quality float64
|
||||
firstPoorTime time.Time // When quality first dropped below 60%
|
||||
lastPoorTime time.Time
|
||||
bannerShown bool
|
||||
resolvedCount int
|
||||
hysteresis float64 // For quality improvements
|
||||
}
|
||||
|
||||
const (
|
||||
QualityThreshold = 60.0 // Quality below this triggers issues
|
||||
QualityThreshold = 60.0 // Quality below this triggers issues
|
||||
QualityRecovery = 70.0 // Quality above this marks recovery
|
||||
PoorQualityDuration = 24 * time.Hour
|
||||
)
|
||||
|
|
@ -184,8 +184,8 @@ func (t *ZoneQualityTracker) UpdateQuality(zoneID int, quality float64, timestam
|
|||
state := t.zones[zoneID]
|
||||
if state == nil {
|
||||
state = &zoneQualityState{
|
||||
zoneID: zoneID,
|
||||
quality: quality,
|
||||
zoneID: zoneID,
|
||||
quality: quality,
|
||||
hysteresis: quality,
|
||||
}
|
||||
// If initial quality is already poor, set firstPoorTime
|
||||
|
|
@ -276,25 +276,25 @@ func (t *ZoneQualityTracker) Reset() {
|
|||
|
||||
// Manager coordinates all guided troubleshooting features.
|
||||
type Manager struct {
|
||||
editTracker *EditTracker
|
||||
qualityTracker *ZoneQualityTracker
|
||||
discoveryTracker *DiscoveryTracker
|
||||
fleetNotifier *FleetNotifier
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
checkInterval time.Duration
|
||||
onQualityIssue func(zoneID int, quality float64)
|
||||
onNodeOffline func(mac string, offlineDuration time.Duration)
|
||||
editTracker *EditTracker
|
||||
qualityTracker *ZoneQualityTracker
|
||||
discoveryTracker *DiscoveryTracker
|
||||
fleetNotifier *FleetNotifier
|
||||
mu sync.RWMutex
|
||||
running bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
checkInterval time.Duration
|
||||
onQualityIssue func(zoneID int, quality float64)
|
||||
onNodeOffline func(mac string, offlineDuration time.Duration)
|
||||
onCalibrationComplete func(zoneID int, qualityBefore, qualityAfter float64)
|
||||
}
|
||||
|
||||
// ManagerConfig holds configuration for the guided troubleshooting manager.
|
||||
type ManagerConfig struct {
|
||||
CheckInterval time.Duration // How often to check quality issues
|
||||
GetAllZones func() ([]ZoneInfo, error)
|
||||
GetNodeLastSeen func(mac string) time.Time
|
||||
CheckInterval time.Duration // How often to check quality issues
|
||||
GetAllZones func() ([]ZoneInfo, error)
|
||||
GetNodeLastSeen func(mac string) time.Time
|
||||
}
|
||||
|
||||
// NewManager creates a new guided troubleshooting manager.
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ func TestEditTracker_RecordEdit(t *testing.T) {
|
|||
tracker := NewEditTracker()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
edits int
|
||||
wantHint bool
|
||||
description string
|
||||
name string
|
||||
key string
|
||||
edits int
|
||||
wantHint bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "non-qualifying key",
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ import (
|
|||
|
||||
// Checker provides health check functionality for the mothership.
|
||||
type Checker struct {
|
||||
mu sync.RWMutex
|
||||
startTime time.Time
|
||||
db *sql.DB
|
||||
getNodeCount func() int
|
||||
shedder *loadshed.Shedder
|
||||
getShedLevel func() int // optional override for load_level
|
||||
level3Since time.Time // When level 3 shedding started
|
||||
checkDB func() string // injectable for testing; defaults to defaultCheckDB
|
||||
mu sync.RWMutex
|
||||
startTime time.Time
|
||||
db *sql.DB
|
||||
getNodeCount func() int
|
||||
shedder *loadshed.Shedder
|
||||
getShedLevel func() int // optional override for load_level
|
||||
level3Since time.Time // When level 3 shedding started
|
||||
checkDB func() string // injectable for testing; defaults to defaultCheckDB
|
||||
}
|
||||
|
||||
// Config holds configuration for the health checker.
|
||||
|
|
@ -47,13 +47,13 @@ func New(cfg Config) *Checker {
|
|||
|
||||
// Response is the health check response JSON structure.
|
||||
type Response struct {
|
||||
Status string `json:"status"` // "ok" or "degraded"
|
||||
UptimeS int64 `json:"uptime_s"` // seconds since start
|
||||
Version string `json:"version"` // mothership version
|
||||
NodesOnline int `json:"nodes_online"` // count of connected nodes
|
||||
DB string `json:"db"` // "ok" or "failing"
|
||||
SheddingLevel int `json:"shedding_level"` // 0-3, current load shedding level
|
||||
Reason string `json:"reason,omitempty"` // explanation of degradation (only when status=degraded)
|
||||
Status string `json:"status"` // "ok" or "degraded"
|
||||
UptimeS int64 `json:"uptime_s"` // seconds since start
|
||||
Version string `json:"version"` // mothership version
|
||||
NodesOnline int `json:"nodes_online"` // count of connected nodes
|
||||
DB string `json:"db"` // "ok" or "failing"
|
||||
SheddingLevel int `json:"shedding_level"` // 0-3, current load shedding level
|
||||
Reason string `json:"reason,omitempty"` // explanation of degradation (only when status=degraded)
|
||||
}
|
||||
|
||||
// Handler returns an http.HandlerFunc that performs the health check.
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import (
|
|||
// TestHealthCheckOK tests that health check returns OK when all components are healthy.
|
||||
func TestHealthCheckOK(t *testing.T) {
|
||||
checker := &Checker{
|
||||
startTime: time.Now(),
|
||||
db: &sql.DB{}, // Mock - we'll override checkDB for testing
|
||||
startTime: time.Now(),
|
||||
db: &sql.DB{}, // Mock - we'll override checkDB for testing
|
||||
getNodeCount: func() int { return 3 },
|
||||
shedder: loadshed.New(),
|
||||
}
|
||||
|
|
@ -53,10 +53,10 @@ func TestHealthCheckOK(t *testing.T) {
|
|||
// TestHealthCheckDBFailing tests that health check returns degraded when DB fails.
|
||||
func TestHealthCheckDBFailing(t *testing.T) {
|
||||
checker := &Checker{
|
||||
startTime: time.Now(),
|
||||
db: nil, // No DB = failing
|
||||
startTime: time.Now(),
|
||||
db: nil, // No DB = failing
|
||||
getNodeCount: func() int { return 3 },
|
||||
shedder: loadshed.New(),
|
||||
shedder: loadshed.New(),
|
||||
}
|
||||
|
||||
resp := checker.check("1.0.0")
|
||||
|
|
@ -76,10 +76,10 @@ func TestHealthCheckDBFailing(t *testing.T) {
|
|||
// (zero nodes is valid for headless deployments).
|
||||
func TestHealthCheckNoNodes(t *testing.T) {
|
||||
checker := &Checker{
|
||||
startTime: time.Now().Add(-6 * time.Minute), // 6 minutes ago
|
||||
db: &sql.DB{},
|
||||
startTime: time.Now().Add(-6 * time.Minute), // 6 minutes ago
|
||||
db: &sql.DB{},
|
||||
getNodeCount: func() int { return 0 },
|
||||
shedder: loadshed.New(),
|
||||
shedder: loadshed.New(),
|
||||
}
|
||||
|
||||
// Override checkDB to return OK
|
||||
|
|
@ -100,10 +100,10 @@ func TestHealthCheckNoNodes(t *testing.T) {
|
|||
// TestHealthCheckNoNodesWithinGracePeriod tests that health check is OK within 5 min grace period.
|
||||
func TestHealthCheckNoNodesWithinGracePeriod(t *testing.T) {
|
||||
checker := &Checker{
|
||||
startTime: time.Now().Add(-2 * time.Minute), // 2 minutes ago
|
||||
db: &sql.DB{},
|
||||
startTime: time.Now().Add(-2 * time.Minute), // 2 minutes ago
|
||||
db: &sql.DB{},
|
||||
getNodeCount: func() int { return 0 },
|
||||
shedder: loadshed.New(),
|
||||
shedder: loadshed.New(),
|
||||
}
|
||||
|
||||
// Override checkDB to return OK
|
||||
|
|
@ -152,11 +152,11 @@ func TestHealthCheckLoadLevel3(t *testing.T) {
|
|||
// JSON response and reflects the shedder's current level.
|
||||
func TestHealthCheckSheddingLevelJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shedLevel loadshed.Level
|
||||
wantLevel int
|
||||
wantStatus string
|
||||
wantDegraded bool
|
||||
name string
|
||||
shedLevel loadshed.Level
|
||||
wantLevel int
|
||||
wantStatus string
|
||||
wantDegraded bool
|
||||
}{
|
||||
{"normal", loadshed.LevelNormal, 0, "ok", false},
|
||||
{"light", loadshed.LevelLight, 1, "ok", false},
|
||||
|
|
@ -198,7 +198,7 @@ func TestHealthCheckSheddingLevelJSON(t *testing.T) {
|
|||
// TestHealthCheckHandler tests the HTTP handler returns correct status codes.
|
||||
func TestHealthCheckHandler(t *testing.T) {
|
||||
checker := New(Config{
|
||||
DB: &sql.DB{},
|
||||
DB: &sql.DB{},
|
||||
GetNodeCount: func() int { return 2 },
|
||||
Shedder: loadshed.New(),
|
||||
})
|
||||
|
|
@ -227,7 +227,7 @@ func TestHealthCheckHandler(t *testing.T) {
|
|||
// TestHealthCheckHandlerDegraded tests the HTTP handler returns 503 for degraded state.
|
||||
func TestHealthCheckHandlerDegraded(t *testing.T) {
|
||||
checker := New(Config{
|
||||
DB: nil, // Failing DB
|
||||
DB: nil, // Failing DB
|
||||
GetNodeCount: func() int { return 2 },
|
||||
Shedder: loadshed.New(),
|
||||
})
|
||||
|
|
@ -257,10 +257,10 @@ func TestHealthCheckUptimeIncrement(t *testing.T) {
|
|||
// Backdate startTime so the first check yields at least 1s uptime,
|
||||
// then wait past the next whole second boundary for a measurable increment.
|
||||
checker := &Checker{
|
||||
startTime: time.Now().Add(-1900 * time.Millisecond),
|
||||
db: &sql.DB{},
|
||||
startTime: time.Now().Add(-1900 * time.Millisecond),
|
||||
db: &sql.DB{},
|
||||
getNodeCount: func() int { return 1 },
|
||||
shedder: loadshed.New(),
|
||||
shedder: loadshed.New(),
|
||||
}
|
||||
checker.checkDB = func() string { return "ok" }
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ import (
|
|||
// FeatureMonitor checks for feature availability and fires notifications.
|
||||
// It runs periodically to check if features have become available.
|
||||
type FeatureMonitor struct {
|
||||
mu sync.Mutex
|
||||
db *sql.DB
|
||||
notifier *Notifier
|
||||
checkInterval time.Duration
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
db *sql.DB
|
||||
notifier *Notifier
|
||||
checkInterval time.Duration
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// Callbacks for checking feature availability
|
||||
checkDiurnalReady func() bool
|
||||
|
|
@ -49,10 +49,10 @@ func NewFeatureMonitor(cfg FeatureMonitorConfig) *FeatureMonitor {
|
|||
}
|
||||
|
||||
return &FeatureMonitor{
|
||||
db: cfg.DB,
|
||||
notifier: cfg.Notifier,
|
||||
checkInterval: cfg.CheckInterval,
|
||||
stopCh: make(chan struct{}),
|
||||
db: cfg.DB,
|
||||
notifier: cfg.Notifier,
|
||||
checkInterval: cfg.CheckInterval,
|
||||
stopCh: make(chan struct{}),
|
||||
notifiedPredictionReady: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,39 +18,39 @@ import (
|
|||
|
||||
// Notifier manages feature discovery notifications.
|
||||
type Notifier struct {
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
db *sql.DB
|
||||
quietHours *QuietHours
|
||||
}
|
||||
|
||||
// QuietHours defines when notifications should be suppressed.
|
||||
type QuietHours struct {
|
||||
Enabled bool
|
||||
StartHour int // 0-23
|
||||
StartMin int
|
||||
EndHour int
|
||||
EndMin int
|
||||
DaysMask int // Bitmask for days (0=Sun, 1=Mon, ..., 6=Sat)
|
||||
Enabled bool
|
||||
StartHour int // 0-23
|
||||
StartMin int
|
||||
EndHour int
|
||||
EndMin int
|
||||
DaysMask int // Bitmask for days (0=Sun, 1=Mon, ..., 6=Sat)
|
||||
}
|
||||
|
||||
// FeatureNotification represents a one-time notification for a feature.
|
||||
type FeatureNotification struct {
|
||||
EventID string `json:"event_id"` // Unique identifier
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
ActionLabel string `json:"action_label,omitempty"` // Button text
|
||||
ActionURL string `json:"action_url,omitempty"` // Link for button
|
||||
DismissedAt *time.Time `json:"dismissed_at,omitempty"`
|
||||
FiredAt time.Time `json:"fired_at"`
|
||||
EventID string `json:"event_id"` // Unique identifier
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
ActionLabel string `json:"action_label,omitempty"` // Button text
|
||||
ActionURL string `json:"action_url,omitempty"` // Link for button
|
||||
DismissedAt *time.Time `json:"dismissed_at,omitempty"`
|
||||
FiredAt time.Time `json:"fired_at"`
|
||||
}
|
||||
|
||||
// Predefined feature notification events
|
||||
const (
|
||||
EventDiurnalBaselineActivated = "diurnal_baseline_activated"
|
||||
EventFirstSleepSessionComplete = "first_sleep_session_complete"
|
||||
EventWeightUpdateApproved = "weight_update_approved"
|
||||
EventAutomationFirstFired = "automation_first_fired"
|
||||
EventPredictionModelReady = "prediction_model_ready"
|
||||
EventDiurnalBaselineActivated = "diurnal_baseline_activated"
|
||||
EventFirstSleepSessionComplete = "first_sleep_session_complete"
|
||||
EventWeightUpdateApproved = "weight_update_approved"
|
||||
EventAutomationFirstFired = "automation_first_fired"
|
||||
EventPredictionModelReady = "prediction_model_ready"
|
||||
)
|
||||
|
||||
// NewNotifier creates a new feature notification manager.
|
||||
|
|
@ -149,7 +149,7 @@ func (n *Notifier) isQuietHours(t time.Time) bool {
|
|||
|
||||
// Check day of week
|
||||
dow := int(t.Weekday())
|
||||
if n.quietHours.DaysMask != 0 && (n.quietHours.DaysMask & (1 << dow)) == 0 {
|
||||
if n.quietHours.DaysMask != 0 && (n.quietHours.DaysMask&(1<<dow)) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -329,8 +329,8 @@ func (n *Notifier) handleTest(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true,
|
||||
"fired": fired,
|
||||
"ok": true,
|
||||
"fired": fired,
|
||||
"event_id": testEventID,
|
||||
})
|
||||
}
|
||||
|
|
@ -339,11 +339,11 @@ func (n *Notifier) handleTest(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func getNotificationTitle(eventID string) string {
|
||||
titles := map[string]string{
|
||||
EventDiurnalBaselineActivated: "Your system has learned your home's daily patterns",
|
||||
EventDiurnalBaselineActivated: "Your system has learned your home's daily patterns",
|
||||
EventFirstSleepSessionComplete: "Your first sleep session was tracked overnight",
|
||||
EventWeightUpdateApproved: "Localization accuracy improved",
|
||||
EventAutomationFirstFired: "Your first automation just ran",
|
||||
EventPredictionModelReady: "Presence predictions are now available",
|
||||
EventWeightUpdateApproved: "Localization accuracy improved",
|
||||
EventAutomationFirstFired: "Your first automation just ran",
|
||||
EventPredictionModelReady: "Presence predictions are now available",
|
||||
}
|
||||
if title, ok := titles[eventID]; ok {
|
||||
return title
|
||||
|
|
@ -353,7 +353,7 @@ func getNotificationTitle(eventID string) string {
|
|||
|
||||
func getNotificationMessage(eventID string) string {
|
||||
messages := map[string]string{
|
||||
EventDiurnalBaselineActivated: "Detection accuracy should improve starting today. The system now understands the daily RF patterns in your home.",
|
||||
EventDiurnalBaselineActivated: "Detection accuracy should improve starting today. The system now understands the daily RF patterns in your home.",
|
||||
EventFirstSleepSessionComplete: "Tap to see your sleep summary. Sleep data will accumulate over the coming nights for more detailed reports.",
|
||||
EventWeightUpdateApproved: "Median position error decreased based on your BLE device positions. The system is adapting to your space.",
|
||||
EventAutomationFirstFired: " automations are now active. You can view automation history in the Automations panel.",
|
||||
|
|
@ -367,7 +367,7 @@ func getNotificationMessage(eventID string) string {
|
|||
|
||||
func getNotificationActionLabel(eventID string) string {
|
||||
labels := map[string]string{
|
||||
EventDiurnalBaselineActivated: "View Diurnal Baseline",
|
||||
EventDiurnalBaselineActivated: "View Diurnal Baseline",
|
||||
EventFirstSleepSessionComplete: "View Sleep Summary",
|
||||
EventWeightUpdateApproved: "View Accuracy Trends",
|
||||
EventAutomationFirstFired: "View Automations Log",
|
||||
|
|
@ -381,7 +381,7 @@ func getNotificationActionLabel(eventID string) string {
|
|||
|
||||
func getNotificationActionURL(eventID string) string {
|
||||
urls := map[string]string{
|
||||
EventDiurnalBaselineActivated: "#/settings/diurnal",
|
||||
EventDiurnalBaselineActivated: "#/settings/diurnal",
|
||||
EventFirstSleepSessionComplete: "#/sleep",
|
||||
EventWeightUpdateApproved: "#/accuracy",
|
||||
EventAutomationFirstFired: "#/automations",
|
||||
|
|
|
|||
|
|
@ -172,8 +172,8 @@ func TestNotifierFireWithAction(t *testing.T) {
|
|||
eventID,
|
||||
"Diurnal Baseline Ready",
|
||||
"Your system has learned patterns",
|
||||
"Ignored Label", // This is ignored for known events
|
||||
"#/ignored", // This is ignored for known events
|
||||
"Ignored Label", // This is ignored for known events
|
||||
"#/ignored", // This is ignored for known events
|
||||
)
|
||||
|
||||
if !fired {
|
||||
|
|
|
|||
|
|
@ -17,15 +17,18 @@ const (
|
|||
|
||||
// CSIFrame represents a parsed CSI binary frame
|
||||
// Header (fixed 24 bytes):
|
||||
// node_mac: 6 bytes — source node MAC
|
||||
// peer_mac: 6 bytes — transmitting peer MAC
|
||||
// timestamp_us: 8 bytes — uint64, microseconds since node boot
|
||||
// rssi: 1 byte — int8, dBm
|
||||
// noise_floor: 1 byte — int8, dBm
|
||||
// channel: 1 byte — uint8, WiFi channel
|
||||
// n_sub: 1 byte — uint8, subcarrier count
|
||||
//
|
||||
// node_mac: 6 bytes — source node MAC
|
||||
// peer_mac: 6 bytes — transmitting peer MAC
|
||||
// timestamp_us: 8 bytes — uint64, microseconds since node boot
|
||||
// rssi: 1 byte — int8, dBm
|
||||
// noise_floor: 1 byte — int8, dBm
|
||||
// channel: 1 byte — uint8, WiFi channel
|
||||
// n_sub: 1 byte — uint8, subcarrier count
|
||||
//
|
||||
// Payload (n_sub × 2 bytes):
|
||||
// Per subcarrier: int8 I, int8 Q
|
||||
//
|
||||
// Per subcarrier: int8 I, int8 Q
|
||||
type CSIFrame struct {
|
||||
NodeMAC [6]byte
|
||||
PeerMAC [6]byte
|
||||
|
|
|
|||
|
|
@ -29,17 +29,17 @@ func FuzzParseJSONFrame(f *testing.F) {
|
|||
|
||||
// 2. Valid health message
|
||||
healthMsg := HealthMessage{
|
||||
Type: "health",
|
||||
MAC: "AA:BB:CC:DD:EE:FF",
|
||||
TimestampMS: 1711234567890,
|
||||
Type: "health",
|
||||
MAC: "AA:BB:CC:DD:EE:FF",
|
||||
TimestampMS: 1711234567890,
|
||||
FreeHeapBytes: 204800,
|
||||
WifiRSSIdBm: -52,
|
||||
UptimeMS: 3600000,
|
||||
TemperatureC: 42.1,
|
||||
CSIRateHz: 20,
|
||||
WifiChannel: 6,
|
||||
IP: "192.168.1.123",
|
||||
NTPSynced: true,
|
||||
WifiRSSIdBm: -52,
|
||||
UptimeMS: 3600000,
|
||||
TemperatureC: 42.1,
|
||||
CSIRateHz: 20,
|
||||
WifiChannel: 6,
|
||||
IP: "192.168.1.123",
|
||||
NTPSynced: true,
|
||||
}
|
||||
healthBytes, _ := json.Marshal(healthMsg)
|
||||
f.Add(healthBytes)
|
||||
|
|
@ -196,11 +196,11 @@ func makeLongJSON() []byte {
|
|||
// TestParseJSONMessageProperty tests specific properties of ParseJSONMessage
|
||||
func TestParseJSONMessageProperty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
errContains string
|
||||
wantType interface{}
|
||||
wantType interface{}
|
||||
}{
|
||||
{
|
||||
name: "valid hello message",
|
||||
|
|
@ -233,32 +233,32 @@ func TestParseJSONMessageProperty(t *testing.T) {
|
|||
wantType: &OTAStatusMessage{},
|
||||
},
|
||||
{
|
||||
name: "unknown type returns typed error",
|
||||
input: `{"type":"unknown_type","mac":"AA:BB:CC:DD:EE:FF"}`,
|
||||
wantErr: true,
|
||||
name: "unknown type returns typed error",
|
||||
input: `{"type":"unknown_type","mac":"AA:BB:CC:DD:EE:FF"}`,
|
||||
wantErr: true,
|
||||
errContains: "unknown message type: unknown_type",
|
||||
},
|
||||
{
|
||||
name: "invalid JSON returns error",
|
||||
input: `{invalid json}`,
|
||||
wantErr: true,
|
||||
name: "invalid JSON returns error",
|
||||
input: `{invalid json}`,
|
||||
wantErr: true,
|
||||
errContains: "failed to parse message type",
|
||||
},
|
||||
{
|
||||
name: "missing type field returns error",
|
||||
input: `{"mac":"AA:BB:CC:DD:EE:FF"}`,
|
||||
wantErr: true,
|
||||
name: "missing type field returns error",
|
||||
input: `{"mac":"AA:BB:CC:DD:EE:FF"}`,
|
||||
wantErr: true,
|
||||
errContains: "unknown message type",
|
||||
},
|
||||
{
|
||||
name: "hello with invalid fields returns error",
|
||||
input: `{"type":"hello","mac":"invalid-mac"}`,
|
||||
wantErr: false, // ParseJSONMessage doesn't validate MAC format
|
||||
name: "hello with invalid fields returns error",
|
||||
input: `{"type":"hello","mac":"invalid-mac"}`,
|
||||
wantErr: false, // ParseJSONMessage doesn't validate MAC format
|
||||
},
|
||||
{
|
||||
name: "empty JSON object",
|
||||
input: `{}`,
|
||||
wantErr: true,
|
||||
name: "empty JSON object",
|
||||
input: `{}`,
|
||||
wantErr: true,
|
||||
errContains: "unknown message type",
|
||||
},
|
||||
}
|
||||
|
|
@ -306,7 +306,7 @@ func TestParseJSONMessageProperty(t *testing.T) {
|
|||
func containsString(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
|
||||
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
|
||||
containsInMiddle(s, substr)))
|
||||
containsInMiddle(s, substr)))
|
||||
}
|
||||
|
||||
func containsInMiddle(s, substr string) bool {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue