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

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:
jedarden 2026-05-24 15:30:02 -04:00
parent 7b6feb9318
commit c0ae81a6b0
216 changed files with 3527 additions and 3514 deletions

View file

@ -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 (

View file

@ -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++ {

View file

@ -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)

View file

@ -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,
},
}

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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"`

View file

@ -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,
},
}

View file

@ -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

View file

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

View file

@ -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,
}
}

View file

@ -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.01.0
NSub int `json:"n_sub"` // Number of subcarriers
Confidence float64 `json:"confidence"` // 0.01.0
NSub int `json:"n_sub"` // Number of subcarriers
}
// listBaselines handles GET /api/baseline

View file

@ -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}},

View file

@ -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)

View file

@ -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 {

View file

@ -123,4 +123,3 @@ func (h *DiurnalHandler) getDiurnalSlots(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, response)
}

View file

@ -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",
})
}

View file

@ -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",
})
}

View file

@ -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)

View file

@ -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.

View file

@ -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),
})
}

View file

@ -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},

View file

@ -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 := &notificationSettingsResponse{
// 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
}

View file

@ -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)

View file

@ -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

View file

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

View file

@ -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,
}

View file

@ -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))

View file

@ -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 {

View file

@ -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{

View file

@ -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.

View file

@ -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,
},

View file

@ -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.

View file

@ -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"`
}

View file

@ -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",
},

View file

@ -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

View file

@ -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()}},
},

View file

@ -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.

View file

@ -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",
},
}

View file

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

View file

@ -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},

View file

@ -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(),
})

View file

@ -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,
}
}

View file

@ -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.

View file

@ -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)

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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)",

View file

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

View file

@ -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

View file

@ -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,
},
}

View file

@ -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)
}

View file

@ -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",

View file

@ -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(),
}
}

View file

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

View file

@ -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))

View file

@ -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.

View file

@ -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

View file

@ -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"`
}

View file

@ -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
}{
{

View file

@ -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"
)

View file

@ -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",

View file

@ -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,
}
}

View file

@ -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()}},

View file

@ -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.

View file

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

View file

@ -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,
}
}

View file

@ -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,
},
}

View file

@ -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"`
}

View file

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

View file

@ -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 {

View file

@ -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),
}
}

View file

@ -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) {

View file

@ -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,
})
}

View file

@ -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,
})
}

View file

@ -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

View file

@ -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 {

View file

@ -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.

View file

@ -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

View file

@ -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"`

View file

@ -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(),
})

View file

@ -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) {

View file

@ -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"`
}

View file

@ -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

View file

@ -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,
}
}

View file

@ -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)

View file

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

View file

@ -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.

View file

@ -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",

View file

@ -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.

View file

@ -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" }

View file

@ -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),
}
}

View file

@ -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",

View file

@ -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 {

View file

@ -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

View file

@ -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